Search modes

Vector search

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

Table of Contents

Dense vectors 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 HNSW 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.

docs.ts
import { createNarsil } from '@delali/narsil'
 
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.

docs.ts
import { createNarsil } from '@delali/narsil'
 
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,
})

Here consensusEmbedding stands in for a Float32Array(768) you compute ahead of time, from your own model or through an embedding adapter; the example fills it with random values so 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.

docs.ts
import { createNarsil } from '@delali/narsil'
 
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()) 
 
const results = await narsil.query('docs', { 
  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; 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 recall 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.

docs.ts
import { createNarsil } from '@delali/narsil'
 
const narsil = await createNarsil()
 
await narsil.createIndex('docs', {
  schema: {
    title: 'string',
    embedding: 'vector[768]',
  },
  vectorPromotion: {
    threshold: 2048, 
    hnswConfig: { m: 16, efConstruction: 200, metric: 'cosine' }, 
    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 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 need.

Maintain the graph

These calls inspect and repair a field's HNSW graph rather than continue the running script, so run them on their own against the same docs index. Removed and updated vectors leave tombstones 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.

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')

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.