> Complete content index: https://narsil.sondelali.com/llms.txt

# Vector search

> Store embeddings in vector fields, search by similarity with automatic HNSW promotion, and keep the graph healthy over time.

URL: https://narsil.sondelali.com/docs/vector-search
Section: Search modes
Version: 0.2 (@delali/narsil@0.2.2, latest)

<Tooltip tip="A dense vector is a long list of numbers an embedding model produces to represent the meaning of a text, where every position carries part of the meaning.">Dense vectors</Tooltip> capture meaning that exact keywords miss. Declare a `vector[N]` field in the schema, insert documents with arrays of that exact length, and query by similarity. Small fields use an exact brute-force scan, and once a field reaches its promotion threshold the engine builds an <Tooltip tip="HNSW, short for Hierarchical Navigable Small World, is a layered graph structure that finds approximate nearest neighbours quickly instead of comparing a query against every vector.">HNSW</Tooltip> graph in the background and switches to approximate search. This page builds one file, `docs.ts`, step by step. You declare a vector index, load an embedding, query by similarity, and tune the promotion and quantisation settings.

## Create a vector index

Start `docs.ts` with an engine and a `docs` index whose `embedding` field holds 768-dimensional vectors. The `vectorPromotion` block sets the cutoff at which the field switches from brute-force to HNSW, along with the graph parameters and the quantisation mode.

```ts title="docs.ts"

const narsil = await createNarsil()

await narsil.createIndex('docs', {
  schema: {
    title: 'string',
    embedding: 'vector[768]',
  },
  vectorPromotion: {
    threshold: 2048,
    hnswConfig: { m: 16, efConstruction: 200, metric: 'cosine' },
    quantization: 'sq8',
  },
})
```

Quantisation mode `'sq8'` is the default. It stores scalar-quantised int8 vectors, which cuts vector memory to about a quarter. Set `quantization: 'none'` to keep full float32 vectors. The `threshold` is the vector count at which promotion begins; below it, the field uses an exact scan.

## Insert a document with a vector

Add a document to `docs.ts` carrying a precomputed embedding. The vector must be a `Float32Array` of exactly 768 values to match the field's declared dimension; a mismatch fails with `VECTOR_DIMENSION_MISMATCH`.

```ts title="docs.ts"

const narsil = await createNarsil()

await narsil.createIndex('docs', {
  schema: {
    title: 'string',
    embedding: 'vector[768]',
  },
  vectorPromotion: {
    threshold: 2048,
    hnswConfig: { m: 16, efConstruction: 200, metric: 'cosine' },
    quantization: 'sq8',
  },
})

const consensusEmbedding = Float32Array.from({ length: 768 }, () => Math.random()) // [!code ++:7]

await narsil.insert('docs', {
  id: 'distributed-consensus',
  title: 'Distributed consensus explained',
  embedding: consensusEmbedding,
})
```

Here `consensusEmbedding` stands in for a `Float32Array(768)` you compute ahead of time, from your own model or through an [embedding adapter](/docs/embedding-adapters); the example fills it with random values so that the file runs, and you replace it with a real embedding. With one document indexed, the field stays well below the `2048` threshold, so it still uses the exact backend.

## Query by similarity

Add a similarity query to the end of `docs.ts`. Passing `mode: 'vector'` switches the query into vector mode, and the `vector` block specifies the field to search and the query vector.

```ts title="docs.ts"

const narsil = await createNarsil()

await narsil.createIndex('docs', {
  schema: {
    title: 'string',
    embedding: 'vector[768]',
  },
  vectorPromotion: {
    threshold: 2048,
    hnswConfig: { m: 16, efConstruction: 200, metric: 'cosine' },
    quantization: 'sq8',
  },
})

const consensusEmbedding = Float32Array.from({ length: 768 }, () => Math.random())

await narsil.insert('docs', {
  id: 'distributed-consensus',
  title: 'Distributed consensus explained',
  embedding: consensusEmbedding,
})

const queryEmbedding = Float32Array.from({ length: 768 }, () => Math.random()) // [!code ++:2]

const results = await narsil.query('docs', { // [!code ++:11]
  mode: 'vector',
  vector: {
    field: 'embedding',
    value: queryEmbedding,
    metric: 'cosine',
    similarity: 0.35,
    efSearch: 100,
  },
  limit: 10,
})
```

Like the insert, `queryEmbedding` here is a random stand-in for a real query vector. The `vector` block takes either a raw `value` array or a `text` string for auto-embedding through an [embedding adapter](/docs/embedding-adapters); passing both fails with `EMBEDDING_CONFIG_INVALID`.

- `metric` selects `'cosine'` (the default), `'dotProduct'`, or `'euclidean'`.
- `similarity` sets a score floor. Hits below it drop before `limit` applies, so results can be shorter than requested. For `euclidean`, the floor applies to the similarity mapping `1 / (1 + distance)`.
- `efSearch` raises HNSW <Tooltip tip="Recall is the share of the true nearest neighbours a search returns. Approximate search trades some recall for speed.">recall</Tooltip> at the cost of latency, and has no effect while the field still uses the brute-force backend.

## Tune the promotion settings

A larger corpus benefits from a lower promotion threshold and a denser graph, so the field switches to approximate search sooner and finds more of the true neighbours. Raise `m` and `efConstruction` for better recall, and drop `threshold` to promote earlier. Change the `vectorPromotion` block in `docs.ts` to promote at 1,024 vectors with a denser graph.

```ts title="docs.ts"

const narsil = await createNarsil()

await narsil.createIndex('docs', {
  schema: {
    title: 'string',
    embedding: 'vector[768]',
  },
  vectorPromotion: {
    threshold: 2048, // [!code --]
    hnswConfig: { m: 16, efConstruction: 200, metric: 'cosine' }, // [!code --]
    threshold: 1024, // [!code ++]
    hnswConfig: { m: 32, efConstruction: 400, metric: 'cosine' }, // [!code ++]
    quantization: 'sq8',
  },
})

const consensusEmbedding = Float32Array.from({ length: 768 }, () => Math.random())

await narsil.insert('docs', {
  id: 'distributed-consensus',
  title: 'Distributed consensus explained',
  embedding: consensusEmbedding,
})

const queryEmbedding = Float32Array.from({ length: 768 }, () => Math.random())

const results = await narsil.query('docs', {
  mode: 'vector',
  vector: {
    field: 'embedding',
    value: queryEmbedding,
    metric: 'cosine',
    similarity: 0.35,
    efSearch: 100,
  },
  limit: 10,
})
```

Denser graphs raise both build time and memory, so match `m` and `efConstruction` to the recall your queries must reach.

## Maintain the graph

These calls inspect and repair a field's HNSW graph rather than continue the running script, so they belong in a file of their own. `maintenance.ts` builds the same `docs` index and then runs them. Removed and updated vectors leave <Tooltip tip="A tombstone is a marker left in place of a removed vector so that the graph keeps its structure without a rebuild. Queries still walk past tombstones, which costs time.">tombstones</Tooltip> in the graph, which slows queries as they accumulate. `vectorMaintenanceStatus` reports, per vector field, the `tombstoneRatio`, the `graphCount`, the `bufferSize`, a `building` flag, and the `estimatedCompactMs` and `estimatedOptimizeMs` maintenance times. A field reports a graph only once it passes its promotion threshold, so the one document here keeps the exact backend and reports no graph yet.

```ts title="maintenance.ts"

const narsil = await createNarsil()

await narsil.createIndex('docs', {
  schema: {
    title: 'string',
    embedding: 'vector[768]',
  },
  vectorPromotion: {
    threshold: 1024,
    hnswConfig: { m: 32, efConstruction: 400, metric: 'cosine' },
    quantization: 'sq8',
  },
})

const consensusEmbedding = Float32Array.from({ length: 768 }, () => Math.random())

await narsil.insert('docs', {
  id: 'distributed-consensus',
  title: 'Distributed consensus explained',
  embedding: consensusEmbedding,
})

const status = narsil.vectorMaintenanceStatus('docs')

console.log(JSON.stringify(status, (k, v) => (typeof v === 'number' ? Number(v.toFixed(2)) : v), 2))

await narsil.compactVectors('docs', 'embedding')

await narsil.optimizeVectors('docs', 'embedding')
```

```json result open
[
  {
    "fieldName": "embedding",
    "tombstoneRatio": 0,
    "graphCount": 0,
    "bufferSize": 1,
    "building": false,
    "estimatedCompactMs": 0,
    "estimatedOptimizeMs": 0
  }
]
```

`compactVectors` drops tombstones without rebuilding the graph and runs synchronously. `optimizeVectors` rebuilds the graph from live vectors, which takes longer but restores query speed. Omit the field name to run maintenance on every vector field in the index.
