Docs · Data in and out
Vector search
Exact nearest-neighbour search over embeddings on every core, with filters on any other column.
VectorIndex finds the embeddings closest to a query vector, with an optional filter on the other columns. It is exact, not approximate: there is no index to build or tune, and the results are the same as a brute-force loop’s. It runs in Node, in browsers and on edge runtimes.
Vector search is experimental: the API may change.
import { Engine, VectorIndex } from 'vectrojs';
const engine = new Engine();
const index = await VectorIndex.create(engine, {
vectors: [[1, 0, 0], [0.9, 0.1, 0], [0, 1, 0], [0, 0.8, 0.6], [0.7, 0.7, 0]],
dim: 3,
metric: 'cosine',
columns: {
id: [1, 2, 3, 4, 5],
title: ['cats', 'kittens', 'cars', 'trucks', 'cat cars'],
year: [2018, 2021, 2019, 2023, 2022],
},
});
console.log(await index.searchAsync([1, 0.05, 0], { k: 3 }));
console.log(await index.searchAsync([1, 0.05, 0], { k: 2, filter: 'year >= since', params: { since: 2020 } }));
await index.dispose();
await engine.close();
Each hit is an object with the index’s columns and a score, best first.
Creating an index
VectorIndex.create(engine, options) takes:
| Option | Meaning |
|---|---|
vectors |
the embeddings: an array of arrays, or one flat Float32Array of n * dim values |
dim |
the length of each embedding |
metric |
'cosine' (default), 'dot' or 'l2' |
columns |
{ name: values }, one value per vector: anything to return with each hit or filter on |
engine can be a normal Engine (Node, browser or vectrojs/portable) or a SplitEngine. The names v0, v1, … are used for the vector columns, so they can’t be column names.
Searching
index.searchAsync(query, { k, filter, params }) returns the k best rows (default 10). filter is any expression over the other columns, with params for its values; only matching rows are scored. index.length is the number of vectors, and index.dispose() frees them.
The score depends on the metric:
| Metric | Score | Best |
|---|---|---|
cosine |
the cosine of the angle (vectors and query are normalized when stored and searched) | highest |
dot |
the dot product | highest |
l2 |
the squared Euclidean distance | lowest |
How it works
Embeddings are stored as float32 columns, one per dimension, in a normal Vectro table. The score is an ordinary fused expression (q0*v0 + q1*v1 + …), computed 24 dimensions per pass with the query values passed as scalars, and top(k) picks the best. So vector search reuses the compiled kernels, filters, threads and split tables with no extra code. Storing vectors as float32 halves their memory; scores can differ from a float64 loop in the last digits, so near ties may swap places.
import { Engine, VectorIndex } from 'vectrojs';
const engine = new Engine();
const n = 50_000, dim = 128;
const flat = new Float32Array(n * dim);
for (let i = 0; i < flat.length; i++) flat[i] = Math.random() - 0.5;
const index = await VectorIndex.create(engine, {
vectors: flat,
dim,
metric: 'l2',
columns: { id: Float64Array.from({ length: n }, (_, i) => i), shard: Float64Array.from({ length: n }, (_, i) => i % 4) },
});
// a query close to vector 42
const query = Array.from(flat.subarray(42 * dim, 43 * dim), (x) => x + (Math.random() - 0.5) * 0.01);
let t = performance.now();
const hits = await index.searchAsync(query, { k: 3 });
console.log(hits[0].id, `${(performance.now() - t).toFixed(1)} ms (first search compiles its kernels)`);
t = performance.now();
await index.searchAsync(query, { k: 3 });
console.log(`${(performance.now() - t).toFixed(1)} ms`);
console.log((await index.searchAsync(query, { k: 3, filter: 'shard == 1' })).map((h) => h.id % 4));
await index.dispose();
await engine.close();
On pages without isolation headers
In a browser, a normal Engine uses threads only on cross-origin isolated pages. A SplitEngine works on any page: pass it to VectorIndex.create and every Web Worker searches its own rows in one round trip, then the page keeps the best k.
import { SplitEngine, VectorIndex } from 'vectrojs/browser';
const engine = new SplitEngine(); // one worker per core
const index = await VectorIndex.create(engine, { vectors, dim: 384, columns: { id: ids } });
await index.searchAsync(queryEmbedding, { k: 10 });
In Chrome, with 200,000 vectors, 8 Web Workers took 12.4 ms against 21 ms on the page’s own thread; at 50,000 vectors the two were about equal (10.1 ms against 8.8 ms). Keep the tab in front when measuring: browsers slow down hidden tabs.
How fast
npm run bench:vectors in the Vectro repository (Apple M1 Pro, Node, 384 dimensions, top 10, cosine):
| Embeddings | Vectro | Plain JS loop (Float32Array) |
|---|---|---|
| 100,000 (154 MB) | 11.7 ms | 35.9 ms (3.1×) |
| 200,000 (307 MB) | 13.8 ms | 71.5 ms (5.2×) |
| 500,000 (768 MB) | 22.8 ms | 179.3 ms (7.9×) |
Exact search reads every vector that passes the filter, so its time grows with the number of vectors. At 384 dimensions a vector takes 1,536 bytes, so an engine’s 4 GB holds fewer than 2.8 million, less the room a search needs for its score columns. For far more vectors than that, an approximate index (HNSW, IVF) in a vector database is the better tool.
Wide tables
Top-k on a table with many columns stays cheap if it only carries the columns it returns. table.select(...columns) makes a narrow view with no copy, and on split tables topAsync(k, by, { columns }) sends back only those columns.
The MCP server builds a vector index for JSON-lines files with an embedding field, so an assistant can ask for the rows most similar to a row or a vector. The full API is in the VectorIndex reference.