Skip to content

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.