mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-07-09 17:28:42 +00:00
fix(ruvector-wasm): correct adapter for WASM build's flat-index, distance-score, and metadata gaps (#568)
The published @ruvector/wasm build behaves differently from its generated
.d.ts in three ways that bite consumers:
1. HNSW is not active — the wasm32 target compiles without the `hnsw`
feature and falls back to a flat (brute-force) index, so search is O(n).
The O(log n) win is latent until the WASM HNSW lands.
2. `result.score` is a cosine distance (lower is better), not the
"higher is better" similarity the .d.ts advertises (ordering is correct:
a, b before c).
3. Metadata does not round-trip — search/get return {}.
Add RuvectorWasmAdapter (@ruvector/wasm/adapter) which wraps VectorDB with:
- a metadata sidecar so inserted metadata round-trips
- similarity = 1 - distance (generalised per metric) with `.score` aliased
to similarity, plus the raw `distance` preserved
- indexType/usesHnsw + WASM_HNSW_AVAILABLE so callers don't assume HNSW
- client-side metadata filtering with over-fetch
Includes TS declarations with corrected doc comments, a node:test suite
covering all three findings, README guidance, and package exports.
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
524751e435
commit
08c0d742c4
5 changed files with 688 additions and 0 deletions
|
|
@ -115,6 +115,43 @@ results.forEach(result => {
|
|||
});
|
||||
```
|
||||
|
||||
> ⚠️ **Read this before trusting the raw bindings.** Three behaviours of the
|
||||
> current WASM build differ from what the generated `.d.ts` advertises:
|
||||
>
|
||||
> 1. **HNSW is not active in the WASM build.** It compiles without the `hnsw`
|
||||
> cargo feature and silently falls back to a brute-force flat index, so search
|
||||
> is O(n), not O(log n). The HNSW win is latent until the WASM HNSW lands.
|
||||
> 2. **`result.score` is a cosine *distance* (lower is better)** — the ordering is
|
||||
> correct, but it is *not* the "higher is better" similarity the `.d.ts`
|
||||
> describes.
|
||||
> 3. **Metadata does not round-trip** — `search`/`get` return `{}`.
|
||||
>
|
||||
> Use the bundled **adapter** instead of the raw `VectorDB` to get these handled
|
||||
> correctly (see below).
|
||||
|
||||
### Recommended: the corrected adapter
|
||||
|
||||
`@ruvector/wasm/adapter` wraps `VectorDB` with a metadata sidecar and a real
|
||||
`similarity = 1 - distance` so the documented "higher is better" contract holds.
|
||||
|
||||
```javascript
|
||||
import { RuvectorWasmAdapter } from '@ruvector/wasm/adapter';
|
||||
|
||||
// Loads + inits the WASM module and constructs the VectorDB for you.
|
||||
const index = await RuvectorWasmAdapter.create({ dimensions: 384, metric: 'cosine' });
|
||||
|
||||
index.insert({ id: 'doc_1', vector: embedding, metadata: { title: 'My Document' } });
|
||||
|
||||
const results = index.search({ vector: query, k: 10 });
|
||||
results.forEach(r => {
|
||||
console.log(r.id, r.similarity); // similarity: higher is better
|
||||
console.log(r.distance); // raw distance: lower is better
|
||||
console.log(r.metadata); // round-trips correctly via the sidecar
|
||||
});
|
||||
|
||||
console.log(index.indexType); // 'flat' until WASM HNSW lands
|
||||
```
|
||||
|
||||
### React Integration
|
||||
|
||||
```typescript
|
||||
|
|
|
|||
|
|
@ -4,8 +4,20 @@
|
|||
"description": "High-performance Rust vector database for browsers via WASM",
|
||||
"main": "pkg/ruvector_wasm.js",
|
||||
"types": "pkg/ruvector_wasm.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./pkg/ruvector_wasm.d.ts",
|
||||
"default": "./pkg/ruvector_wasm.js"
|
||||
},
|
||||
"./adapter": {
|
||||
"types": "./src/adapter.d.ts",
|
||||
"default": "./src/adapter.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"pkg",
|
||||
"src/adapter.js",
|
||||
"src/adapter.d.ts",
|
||||
"src/worker.js",
|
||||
"src/worker-pool.js",
|
||||
"src/indexeddb.js"
|
||||
|
|
@ -18,6 +30,7 @@
|
|||
"build:bundler": "wasm-pack build --target bundler --out-dir pkg-bundler --release",
|
||||
"build:all": "npm run build && npm run build:node && npm run build:bundler",
|
||||
"test": "wasm-pack test --headless --chrome",
|
||||
"test:adapter": "node --test tests/adapter.test.mjs",
|
||||
"test:firefox": "wasm-pack test --headless --firefox",
|
||||
"test:node": "wasm-pack test --node",
|
||||
"size": "npm run build && gzip -c pkg/ruvector_wasm_bg.wasm | wc -c && echo 'bytes (gzipped)'",
|
||||
|
|
|
|||
132
crates/ruvector-wasm/src/adapter.d.ts
vendored
Normal file
132
crates/ruvector-wasm/src/adapter.d.ts
vendored
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* Type declarations for the RuvectorWasmAdapter.
|
||||
*
|
||||
* Unlike the generated `pkg/ruvector_wasm.d.ts`, the `score` documented here is
|
||||
* a real similarity (higher is better); the raw distance is exposed separately.
|
||||
*
|
||||
* @module @ruvector/wasm/adapter
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether the published WASM build ships an active HNSW index.
|
||||
* `false` today: the WASM `VectorDB` falls back to a flat (brute-force) index.
|
||||
*/
|
||||
export const WASM_HNSW_AVAILABLE: boolean;
|
||||
|
||||
/**
|
||||
* Convert a raw distance (lower is better) into a similarity (higher is better).
|
||||
* @param metric 'cosine' | 'dot' | 'dotproduct' | 'euclidean' | 'manhattan'
|
||||
* @param distance Raw score returned by the WASM `search`.
|
||||
*/
|
||||
export function distanceToSimilarity(metric: string, distance: number): number;
|
||||
|
||||
/** A single search result, with similarity and metadata corrected. */
|
||||
export interface AdapterSearchResult {
|
||||
/** Vector id. */
|
||||
id: string;
|
||||
/** Similarity score — higher is better. */
|
||||
similarity: number;
|
||||
/** Raw distance from the underlying index — lower is better. */
|
||||
distance: number;
|
||||
/** Alias of `similarity`, so a `.score` read honours "higher is better". */
|
||||
score: number;
|
||||
/** Vector data, when returned by the index. */
|
||||
vector?: Float32Array;
|
||||
/** Round-tripped metadata from the sidecar. */
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/** Minimal shape of the underlying WASM (or test-double) VectorDB. */
|
||||
export interface WasmVectorDBLike {
|
||||
insert(
|
||||
vector: Float32Array,
|
||||
id?: string,
|
||||
metadata?: Record<string, any>
|
||||
): string;
|
||||
insertBatch(
|
||||
entries: Array<{
|
||||
id?: string;
|
||||
vector: Float32Array;
|
||||
metadata?: Record<string, any>;
|
||||
}>
|
||||
): string[];
|
||||
search(
|
||||
vector: Float32Array,
|
||||
k: number,
|
||||
filter?: Record<string, any>
|
||||
): Array<{
|
||||
id: string;
|
||||
score: number;
|
||||
vector?: Float32Array;
|
||||
metadata?: Record<string, any>;
|
||||
}>;
|
||||
get(
|
||||
id: string
|
||||
): { id?: string; vector?: Float32Array; metadata?: Record<string, any> } | null;
|
||||
delete(id: string): boolean;
|
||||
len?(): number;
|
||||
isEmpty?(): boolean;
|
||||
}
|
||||
|
||||
export interface AdapterOptions {
|
||||
/** Vector dimensions (informational). */
|
||||
dimensions?: number;
|
||||
/** Distance metric the db was created with; controls similarity conversion. */
|
||||
metric?: string;
|
||||
/** Override the index-type report. Defaults to {@link WASM_HNSW_AVAILABLE}. */
|
||||
usesHnsw?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateOptions {
|
||||
/** Vector dimensions (required). */
|
||||
dimensions: number;
|
||||
/** Distance metric. Defaults to 'cosine'. */
|
||||
metric?: string;
|
||||
/** Requested at the WASM layer (the build falls back to flat regardless). */
|
||||
useHnsw?: boolean;
|
||||
/** Pre-imported WASM module; if omitted, `@ruvector/wasm` is imported. */
|
||||
module?: any;
|
||||
}
|
||||
|
||||
/** Correct wrapper around the generated WASM `VectorDB`. */
|
||||
export class RuvectorWasmAdapter {
|
||||
constructor(db: WasmVectorDBLike, options?: AdapterOptions);
|
||||
|
||||
static create(options: CreateOptions): Promise<RuvectorWasmAdapter>;
|
||||
|
||||
/** `false` for the current WASM build — flat O(n) search. */
|
||||
readonly usesHnsw: boolean;
|
||||
/** 'hnsw' | 'flat' — index type backing this adapter. */
|
||||
readonly indexType: 'hnsw' | 'flat';
|
||||
|
||||
insert(entry: {
|
||||
id?: string;
|
||||
vector: Float32Array | number[];
|
||||
metadata?: Record<string, any>;
|
||||
}): string;
|
||||
|
||||
insertBatch(
|
||||
entries: Array<{
|
||||
id?: string;
|
||||
vector: Float32Array | number[];
|
||||
metadata?: Record<string, any>;
|
||||
}>
|
||||
): string[];
|
||||
|
||||
search(query: {
|
||||
vector: Float32Array | number[];
|
||||
k: number;
|
||||
filter?: Record<string, any>;
|
||||
}): AdapterSearchResult[];
|
||||
|
||||
get(
|
||||
id: string
|
||||
): { id: string; vector?: Float32Array; metadata?: Record<string, any> } | null;
|
||||
|
||||
delete(id: string): boolean;
|
||||
len(): number;
|
||||
isEmpty(): boolean;
|
||||
clearMetadata(): void;
|
||||
}
|
||||
|
||||
export default RuvectorWasmAdapter;
|
||||
330
crates/ruvector-wasm/src/adapter.js
Normal file
330
crates/ruvector-wasm/src/adapter.js
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
/**
|
||||
* RuvectorWasmAdapter — a correct, ergonomic wrapper around the generated
|
||||
* `@ruvector/wasm` `VectorDB`.
|
||||
*
|
||||
* It exists to paper over three behaviours of the current WASM build that bite
|
||||
* callers who take the raw bindings (and the generated `.d.ts`) at face value:
|
||||
*
|
||||
* 1. **HNSW is not active in the WASM build.** The Rust crate compiles the
|
||||
* `wasm32` target *without* the `hnsw` feature, so `VectorDB` silently falls
|
||||
* back to a brute-force flat index (`vector_db.rs`:
|
||||
* `"HNSW requested but not available (WASM build), using flat index"`).
|
||||
* Results are still correct, but search is O(n), not O(log n). The win is
|
||||
* latent until the upstream WASM HNSW lands. This adapter surfaces that fact
|
||||
* via {@link RuvectorWasmAdapter#indexType} / {@link WASM_HNSW_AVAILABLE}
|
||||
* instead of letting callers assume a logarithmic index.
|
||||
*
|
||||
* 2. **`result.score` is a cosine *distance*, not a similarity.** Lower is
|
||||
* better and the ordering is correct (a, b before c), but that contradicts
|
||||
* the generated `.d.ts` which advertises a "higher is better" score. This
|
||||
* adapter exposes both the raw `distance` and a `similarity = 1 - distance`
|
||||
* (generalised per metric) so "higher is better" actually holds.
|
||||
*
|
||||
* 3. **Metadata does not round-trip.** Inserted metadata comes back as `{}`
|
||||
* (or `undefined`) from the WASM `search`/`get` getters. This adapter keeps
|
||||
* an in-process **metadata sidecar** keyed by vector id and re-attaches it
|
||||
* on the way out, so what you put in is what you get back.
|
||||
*
|
||||
* The adapter is dependency-injectable: pass a pre-constructed `VectorDB` (real
|
||||
* or a test double), or use {@link RuvectorWasmAdapter.create} to load and init
|
||||
* the WASM module for you.
|
||||
*
|
||||
* @module @ruvector/wasm/adapter
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether the published WASM build ships an active HNSW index.
|
||||
*
|
||||
* The crate is compiled for `wasm32` without the `hnsw` cargo feature, so this
|
||||
* is `false` today: the WASM `VectorDB` uses a flat (brute-force) index. Flip
|
||||
* to `true` once the WASM build enables HNSW.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
export const WASM_HNSW_AVAILABLE = false;
|
||||
|
||||
/**
|
||||
* Convert a raw distance score (lower is better) into a similarity where
|
||||
* higher is better, matching the contract the `.d.ts` advertises.
|
||||
*
|
||||
* Mirrors the conversion used by `@ruvector/router` so the whole ecosystem
|
||||
* agrees on what "score" means.
|
||||
*
|
||||
* @param {string} metric - 'cosine' | 'dot' | 'dotproduct' | 'euclidean' | 'manhattan'
|
||||
* @param {number} distance - Raw score returned by the WASM `search`.
|
||||
* @returns {number} Similarity, higher is better.
|
||||
*/
|
||||
export function distanceToSimilarity(metric, distance) {
|
||||
switch ((metric || 'cosine').toLowerCase()) {
|
||||
case 'cosine':
|
||||
// cosine distance = 1 - cosine_similarity ⇒ similarity = 1 - distance
|
||||
return 1 - distance;
|
||||
case 'dot':
|
||||
case 'dotproduct':
|
||||
// dot "distance" is stored negated ⇒ similarity = -distance
|
||||
return -distance;
|
||||
case 'euclidean':
|
||||
case 'manhattan':
|
||||
default:
|
||||
// unbounded distances: monotonic decreasing map into (0, 1]
|
||||
return 1 / (1 + distance);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} AdapterSearchResult
|
||||
* @property {string} id - Vector id.
|
||||
* @property {number} similarity - Higher is better (see {@link distanceToSimilarity}).
|
||||
* @property {number} distance - Raw score from the WASM index (lower is better).
|
||||
* @property {number} score - Alias of `similarity`, so the documented
|
||||
* "higher is better" score contract holds for callers reading `.score`.
|
||||
* @property {Float32Array=} vector - Vector data, when returned by the index.
|
||||
* @property {Record<string, any>=} metadata - Round-tripped metadata from the sidecar.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Correct wrapper around the generated WASM `VectorDB`.
|
||||
*/
|
||||
export class RuvectorWasmAdapter {
|
||||
/**
|
||||
* @param {any} db - A constructed WASM `VectorDB` instance (or a compatible
|
||||
* test double exposing `insert`, `insertBatch`, `search`, `get`, `delete`,
|
||||
* `len`/`isEmpty`).
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.dimensions] - Vector dimensions (informational).
|
||||
* @param {string} [options.metric='cosine'] - Distance metric the `db` was
|
||||
* created with; controls the similarity conversion.
|
||||
* @param {boolean} [options.usesHnsw] - Override the index-type report. Defaults
|
||||
* to {@link WASM_HNSW_AVAILABLE}.
|
||||
*/
|
||||
constructor(db, options = {}) {
|
||||
if (!db) {
|
||||
throw new Error('RuvectorWasmAdapter requires a VectorDB instance');
|
||||
}
|
||||
this._db = db;
|
||||
this._metric = (options.metric || 'cosine').toLowerCase();
|
||||
this._dimensions = options.dimensions;
|
||||
this._usesHnsw = options.usesHnsw ?? WASM_HNSW_AVAILABLE;
|
||||
|
||||
/**
|
||||
* Metadata sidecar: id -> metadata. Works around the WASM build not
|
||||
* round-tripping metadata through `search`/`get`.
|
||||
* @type {Map<string, Record<string, any>>}
|
||||
*/
|
||||
this._metadata = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the WASM module, construct a `VectorDB`, and wrap it.
|
||||
*
|
||||
* @param {Object} [options]
|
||||
* @param {number} options.dimensions - Vector dimensions (required).
|
||||
* @param {string} [options.metric='cosine'] - Distance metric.
|
||||
* @param {boolean} [options.useHnsw=true] - Requested at the WASM layer; note
|
||||
* the WASM build falls back to flat regardless (see {@link WASM_HNSW_AVAILABLE}).
|
||||
* @param {any} [options.module] - Pre-imported WASM module (exposing `default`
|
||||
* init and `VectorDB`). If omitted, `@ruvector/wasm` is imported dynamically.
|
||||
* @returns {Promise<RuvectorWasmAdapter>}
|
||||
*/
|
||||
static async create(options = {}) {
|
||||
const { dimensions, metric = 'cosine', useHnsw = true } = options;
|
||||
if (!dimensions || dimensions <= 0) {
|
||||
throw new Error('RuvectorWasmAdapter.create requires positive `dimensions`');
|
||||
}
|
||||
|
||||
const mod = options.module ?? (await import('@ruvector/wasm'));
|
||||
// `web`/`bundler` targets export a default init() that must run once before
|
||||
// any class is constructed. `nodejs` targets have no default export.
|
||||
if (typeof mod.default === 'function') {
|
||||
await mod.default();
|
||||
}
|
||||
|
||||
const VectorDB = mod.VectorDB;
|
||||
if (typeof VectorDB !== 'function') {
|
||||
throw new Error('@ruvector/wasm did not export a VectorDB constructor');
|
||||
}
|
||||
|
||||
const db = new VectorDB(dimensions, metric, useHnsw);
|
||||
return new RuvectorWasmAdapter(db, { dimensions, metric });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this index is backed by HNSW. `false` for the current WASM build —
|
||||
* search is O(n) flat scan until upstream WASM HNSW lands.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get usesHnsw() {
|
||||
return this._usesHnsw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index type, for callers that want to reason about search complexity.
|
||||
* @returns {'hnsw' | 'flat'}
|
||||
*/
|
||||
get indexType() {
|
||||
return this._usesHnsw ? 'hnsw' : 'flat';
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a single vector, recording its metadata in the sidecar.
|
||||
*
|
||||
* @param {Object} entry
|
||||
* @param {string} [entry.id] - Optional id (auto-generated by WASM if absent).
|
||||
* @param {Float32Array | number[]} entry.vector
|
||||
* @param {Record<string, any>} [entry.metadata]
|
||||
* @returns {string} The vector id (the WASM-assigned one when not supplied).
|
||||
*/
|
||||
insert(entry) {
|
||||
const vector = toFloat32(entry.vector);
|
||||
// Still hand metadata to the WASM layer (forward-compat for when it
|
||||
// round-trips), but the sidecar is the source of truth on the way out.
|
||||
const id = this._db.insert(vector, entry.id, entry.metadata);
|
||||
if (entry.metadata !== undefined) {
|
||||
this._metadata.set(id, entry.metadata);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert vectors in a batch, recording metadata in the sidecar.
|
||||
*
|
||||
* @param {Array<{ id?: string, vector: Float32Array | number[], metadata?: Record<string, any> }>} entries
|
||||
* @returns {string[]} Vector ids in the same order as `entries`.
|
||||
*/
|
||||
insertBatch(entries) {
|
||||
const nativeEntries = entries.map((e) => ({
|
||||
id: e.id,
|
||||
vector: toFloat32(e.vector),
|
||||
metadata: e.metadata,
|
||||
}));
|
||||
const ids = this._db.insertBatch(nativeEntries);
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const meta = entries[i] && entries[i].metadata;
|
||||
if (meta !== undefined) {
|
||||
this._metadata.set(ids[i], meta);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for the `k` nearest vectors.
|
||||
*
|
||||
* Returns results ordered best-first by `similarity` (higher is better), with
|
||||
* the raw `distance` preserved and metadata re-attached from the sidecar.
|
||||
* When `filter` is supplied it is applied against the sidecar metadata (the
|
||||
* WASM filter relies on metadata that does not round-trip), over-fetching as
|
||||
* needed so `k` results survive the filter where possible.
|
||||
*
|
||||
* @param {Object} query
|
||||
* @param {Float32Array | number[]} query.vector
|
||||
* @param {number} query.k
|
||||
* @param {Record<string, any>} [query.filter] - Exact-match metadata filter.
|
||||
* @returns {AdapterSearchResult[]}
|
||||
*/
|
||||
search(query) {
|
||||
const k = query.k;
|
||||
const vector = toFloat32(query.vector);
|
||||
const hasFilter = query.filter && Object.keys(query.filter).length > 0;
|
||||
|
||||
// Over-fetch when filtering so post-filter results can still reach k.
|
||||
const fetch = hasFilter ? Math.max(k * 4, k) : k;
|
||||
const raw = this._db.search(vector, fetch, undefined) || [];
|
||||
|
||||
let mapped = raw.map((r) => {
|
||||
const distance = r.score;
|
||||
const metadata = this._metadata.has(r.id)
|
||||
? this._metadata.get(r.id)
|
||||
: r.metadata;
|
||||
const similarity = distanceToSimilarity(this._metric, distance);
|
||||
return {
|
||||
id: r.id,
|
||||
similarity,
|
||||
score: similarity,
|
||||
distance,
|
||||
vector: r.vector,
|
||||
metadata,
|
||||
};
|
||||
});
|
||||
|
||||
if (hasFilter) {
|
||||
const entries = Object.entries(query.filter);
|
||||
mapped = mapped.filter((r) => {
|
||||
const md = r.metadata;
|
||||
if (!md) return false;
|
||||
return entries.every(([key, value]) => md[key] === value);
|
||||
});
|
||||
}
|
||||
|
||||
// The flat index already orders by ascending distance, but sort defensively
|
||||
// so a, b come before c regardless of the underlying index's guarantees.
|
||||
mapped.sort((a, b) => b.similarity - a.similarity);
|
||||
|
||||
return mapped.slice(0, k);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a vector by id, with metadata re-attached from the sidecar.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {{ id: string, vector?: Float32Array, metadata?: Record<string, any> } | null}
|
||||
*/
|
||||
get(id) {
|
||||
const entry = this._db.get(id);
|
||||
if (!entry) return null;
|
||||
return {
|
||||
id: entry.id ?? id,
|
||||
vector: entry.vector,
|
||||
metadata: this._metadata.has(id) ? this._metadata.get(id) : entry.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a vector by id, dropping its sidecar metadata.
|
||||
* @param {string} id
|
||||
* @returns {boolean}
|
||||
*/
|
||||
delete(id) {
|
||||
const deleted = this._db.delete(id);
|
||||
if (deleted) {
|
||||
this._metadata.delete(id);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of vectors in the index.
|
||||
* @returns {number}
|
||||
*/
|
||||
len() {
|
||||
if (typeof this._db.len === 'function') return this._db.len();
|
||||
return this._metadata.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the index is empty.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isEmpty() {
|
||||
if (typeof this._db.isEmpty === 'function') return this._db.isEmpty();
|
||||
return this.len() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop all sidecar metadata. Call this when you recreate the underlying db.
|
||||
*/
|
||||
clearMetadata() {
|
||||
this._metadata.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a vector into a `Float32Array` without copying when already one.
|
||||
* @param {Float32Array | number[]} v
|
||||
* @returns {Float32Array}
|
||||
*/
|
||||
function toFloat32(v) {
|
||||
return v instanceof Float32Array ? v : new Float32Array(v);
|
||||
}
|
||||
|
||||
export default RuvectorWasmAdapter;
|
||||
176
crates/ruvector-wasm/tests/adapter.test.mjs
Normal file
176
crates/ruvector-wasm/tests/adapter.test.mjs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* Tests for RuvectorWasmAdapter.
|
||||
*
|
||||
* Uses a FakeVectorDB that reproduces the three problematic behaviours of the
|
||||
* real WASM build:
|
||||
* 1. flat (no HNSW) — `usesHnsw === false`
|
||||
* 2. `score` is a cosine *distance* (lower is better)
|
||||
* 3. metadata does not round-trip (search/get return `{}`)
|
||||
*
|
||||
* The adapter must hide all three: similarity higher-is-better with correct
|
||||
* ordering, and metadata round-tripped via the sidecar.
|
||||
*
|
||||
* Run: node --test crates/ruvector-wasm/tests/adapter.test.mjs
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
RuvectorWasmAdapter,
|
||||
distanceToSimilarity,
|
||||
WASM_HNSW_AVAILABLE,
|
||||
} from '../src/adapter.js';
|
||||
|
||||
function cosineDistance(a, b) {
|
||||
let dot = 0;
|
||||
let na = 0;
|
||||
let nb = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i];
|
||||
na += a[i] * a[i];
|
||||
nb += b[i] * b[i];
|
||||
}
|
||||
const denom = Math.sqrt(na) * Math.sqrt(nb);
|
||||
return denom === 0 ? 1 : 1 - dot / denom;
|
||||
}
|
||||
|
||||
/** Mimics the real WASM VectorDB: flat index, distance score, no metadata round-trip. */
|
||||
class FakeVectorDB {
|
||||
constructor() {
|
||||
this.store = new Map();
|
||||
this._auto = 0;
|
||||
}
|
||||
|
||||
insert(vector, id /*, metadata */) {
|
||||
const key = id ?? `auto-${this._auto++}`;
|
||||
// Note: metadata is intentionally dropped — reproduces the WASM bug.
|
||||
this.store.set(key, Float32Array.from(vector));
|
||||
return key;
|
||||
}
|
||||
|
||||
insertBatch(entries) {
|
||||
return entries.map((e) => this.insert(e.vector, e.id, e.metadata));
|
||||
}
|
||||
|
||||
search(vector, k /*, filter */) {
|
||||
const results = [];
|
||||
for (const [id, vec] of this.store) {
|
||||
results.push({ id, score: cosineDistance(vector, vec), metadata: {} });
|
||||
}
|
||||
results.sort((a, b) => a.score - b.score); // flat scan, ascending distance
|
||||
return results.slice(0, k);
|
||||
}
|
||||
|
||||
get(id) {
|
||||
const vec = this.store.get(id);
|
||||
return vec ? { id, vector: vec, metadata: {} } : null;
|
||||
}
|
||||
|
||||
delete(id) {
|
||||
return this.store.delete(id);
|
||||
}
|
||||
|
||||
len() {
|
||||
return this.store.size;
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.store.size === 0;
|
||||
}
|
||||
}
|
||||
|
||||
test('distanceToSimilarity: cosine distance -> higher-is-better similarity', () => {
|
||||
assert.equal(distanceToSimilarity('cosine', 0), 1);
|
||||
assert.equal(distanceToSimilarity('cosine', 0.25), 0.75);
|
||||
assert.ok(
|
||||
distanceToSimilarity('cosine', 0.1) > distanceToSimilarity('cosine', 0.4)
|
||||
);
|
||||
});
|
||||
|
||||
test('finding #2: search returns similarity (higher is better) with a, b before c', () => {
|
||||
const db = new FakeVectorDB();
|
||||
const adapter = new RuvectorWasmAdapter(db, { dimensions: 3, metric: 'cosine' });
|
||||
|
||||
// a and b are close to the query [1,0,0]; c is orthogonal.
|
||||
adapter.insert({ id: 'a', vector: [1, 0, 0] });
|
||||
adapter.insert({ id: 'b', vector: [0.9, 0.1, 0] });
|
||||
adapter.insert({ id: 'c', vector: [0, 0, 1] });
|
||||
|
||||
const results = adapter.search({ vector: [1, 0, 0], k: 3 });
|
||||
assert.deepEqual(
|
||||
results.map((r) => r.id),
|
||||
['a', 'b', 'c']
|
||||
);
|
||||
|
||||
// Higher is better, and the best result outscores the worst.
|
||||
assert.ok(results[0].similarity >= results[1].similarity);
|
||||
assert.ok(results[1].similarity >= results[2].similarity);
|
||||
assert.ok(results[0].similarity > results[2].similarity);
|
||||
// `.score` honours the documented "higher is better" contract.
|
||||
assert.equal(results[0].score, results[0].similarity);
|
||||
// Raw distance preserved (lower is better) and consistent with similarity.
|
||||
assert.ok(results[0].distance <= results[2].distance);
|
||||
assert.ok(Math.abs(results[0].similarity - (1 - results[0].distance)) < 1e-6);
|
||||
});
|
||||
|
||||
test('finding #3: metadata round-trips via the sidecar', () => {
|
||||
const db = new FakeVectorDB();
|
||||
const adapter = new RuvectorWasmAdapter(db, { dimensions: 3, metric: 'cosine' });
|
||||
|
||||
const meta = { title: 'doc-a', tags: ['x', 'y'] };
|
||||
adapter.insert({ id: 'a', vector: [1, 0, 0], metadata: meta });
|
||||
adapter.insert({ id: 'b', vector: [0, 1, 0], metadata: { title: 'doc-b' } });
|
||||
|
||||
// Raw WASM would return {}; the adapter restores the real metadata.
|
||||
const [top] = adapter.search({ vector: [1, 0, 0], k: 1 });
|
||||
assert.deepEqual(top.metadata, meta);
|
||||
|
||||
const got = adapter.get('a');
|
||||
assert.deepEqual(got.metadata, meta);
|
||||
});
|
||||
|
||||
test('insertBatch round-trips metadata in order', () => {
|
||||
const db = new FakeVectorDB();
|
||||
const adapter = new RuvectorWasmAdapter(db, { dimensions: 2, metric: 'cosine' });
|
||||
|
||||
const ids = adapter.insertBatch([
|
||||
{ id: 'one', vector: [1, 0], metadata: { n: 1 } },
|
||||
{ id: 'two', vector: [0, 1], metadata: { n: 2 } },
|
||||
]);
|
||||
assert.deepEqual(ids, ['one', 'two']);
|
||||
assert.deepEqual(adapter.get('two').metadata, { n: 2 });
|
||||
});
|
||||
|
||||
test('filter is applied against sidecar metadata', () => {
|
||||
const db = new FakeVectorDB();
|
||||
const adapter = new RuvectorWasmAdapter(db, { dimensions: 2, metric: 'cosine' });
|
||||
|
||||
adapter.insert({ id: 'a', vector: [1, 0], metadata: { kind: 'fruit' } });
|
||||
adapter.insert({ id: 'b', vector: [0.95, 0.05], metadata: { kind: 'veg' } });
|
||||
adapter.insert({ id: 'c', vector: [0.9, 0.1], metadata: { kind: 'fruit' } });
|
||||
|
||||
const results = adapter.search({ vector: [1, 0], k: 2, filter: { kind: 'fruit' } });
|
||||
assert.deepEqual(
|
||||
results.map((r) => r.id),
|
||||
['a', 'c']
|
||||
);
|
||||
});
|
||||
|
||||
test('finding #1: index type reports flat (HNSW not active in WASM build)', () => {
|
||||
const db = new FakeVectorDB();
|
||||
const adapter = new RuvectorWasmAdapter(db, { dimensions: 2 });
|
||||
assert.equal(WASM_HNSW_AVAILABLE, false);
|
||||
assert.equal(adapter.usesHnsw, false);
|
||||
assert.equal(adapter.indexType, 'flat');
|
||||
});
|
||||
|
||||
test('delete drops sidecar metadata and updates length', () => {
|
||||
const db = new FakeVectorDB();
|
||||
const adapter = new RuvectorWasmAdapter(db, { dimensions: 2 });
|
||||
adapter.insert({ id: 'a', vector: [1, 0], metadata: { keep: false } });
|
||||
assert.equal(adapter.len(), 1);
|
||||
assert.equal(adapter.delete('a'), true);
|
||||
assert.equal(adapter.len(), 0);
|
||||
assert.equal(adapter.get('a'), null);
|
||||
assert.equal(adapter.isEmpty(), true);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue