A keyword search compares the words in a question with the words in each article. Somebody may ask your help centre when their money comes back. The article that answers them uses the word refund, however, so a keyword search for that question returns nothing. A vector search compares the numbers that an model produces for each text, so it can put that refunds article first.
You build two files here. In articles.ts you store six help-centre articles and search them by meaning, while in help-centre.ts you grow an index to 5,000 articles, where you can watch the engine build a graph, code the vectors, and clear the tombstones after you remove a fifth of them. In both files you embed the text through the Transformers.js adapter, which loads a model into your process, so install @delali/narsil-embeddings-transformers and its peer @huggingface/transformers beside @delali/narsil. The adapter downloads the model on the first call and loads it from your cache after that. Each code block below holds one whole file.
Store an article and its vector
Xenova/all-MiniLM-L6-v2, the adapter's default model, produces 384 numbers for each text, so you declare vector[384] in the schema and the same width on the adapter. A vector of any other length fails with VECTOR_DIMENSION_MISMATCH. The field takes a Float32Array or a plain array of numbers, and the engine stores a Float32Array either way.
embedBatch embeds the six articles in one call. The program passes each title and body to the model as one text, so a question can match either.
import { createNarsil } from '@delali/narsil'
import { createTransformersEmbedding } from '@delali/narsil-embeddings-transformers'
const embedding = createTransformersEmbedding({ dimensions: 384 })
const articles = [
{
id: 'refund-timing',
title: 'How a refund reaches your bank',
body: 'A refund returns to the card that paid for the order, and your bank posts it within five working days.',
},
{
id: 'parcel-tracking',
title: 'Track a parcel after dispatch',
body: 'Every dispatched order has a tracking code, and the carrier updates it at each depot.',
},
{
id: 'password-reset',
title: 'Reset the password on your account',
body: 'A reset link arrives by email and stays valid for one hour.',
},
{
id: 'mobile-card-payments',
title: 'Take card payments in the mobile app',
body: 'The mobile reader pairs over Bluetooth and settles to the same account as the web checkout.',
},
{
id: 'api-key-rotation',
title: 'Rotate an API key',
body: 'A new key works at once, and the old key keeps answering for twenty-four hours.',
},
{
id: 'billing-statement',
title: 'Read your monthly billing statement',
body: 'The statement lists every charge, every refund, and the fees deducted before payout.',
},
]
const vectors = await embedding.embedBatch(
articles.map((article) => `${article.title}. ${article.body}`),
'document'
)
const narsil = await createNarsil()
await narsil.createIndex('articles', {
schema: { title: 'string', body: 'string', embedding: 'vector[384]' },
})
await narsil.insertBatch(
'articles',
articles.map((article, position) => ({ ...article, embedding: vectors[position] }))
)
await narsil.shutdown()insertBatch takes the whole array in one call and reports each rejected document by id, which indexes and documents covers in full.
Find an article with no word from the question
You put one question to the index twice. The first query passes term, which is keyword mode, while the second passes mode: 'vector' with the question's own vector. The adapter embeds that question under the 'query' purpose, so that an adapter for an asymmetric model can add the prefix that the model expects on a question.
The document projection keeps title alone. A stored vector comes back as a Float32Array, which JSON.stringify prints as an object keyed by position. The engine also reads each hit's vector out of the index to put it in the response, so the program leaves the vector out, which spares the engine that read and keeps the output readable.
import { createNarsil } from '@delali/narsil'
import { createTransformersEmbedding } from '@delali/narsil-embeddings-transformers'
const embedding = createTransformersEmbedding({ dimensions: 384 })
const articles = [
{
id: 'refund-timing',
title: 'How a refund reaches your bank',
body: 'A refund returns to the card that paid for the order, and your bank posts it within five working days.',
},
{
id: 'parcel-tracking',
title: 'Track a parcel after dispatch',
body: 'Every dispatched order has a tracking code, and the carrier updates it at each depot.',
},
{
id: 'password-reset',
title: 'Reset the password on your account',
body: 'A reset link arrives by email and stays valid for one hour.',
},
{
id: 'mobile-card-payments',
title: 'Take card payments in the mobile app',
body: 'The mobile reader pairs over Bluetooth and settles to the same account as the web checkout.',
},
{
id: 'api-key-rotation',
title: 'Rotate an API key',
body: 'A new key works at once, and the old key keeps answering for twenty-four hours.',
},
{
id: 'billing-statement',
title: 'Read your monthly billing statement',
body: 'The statement lists every charge, every refund, and the fees deducted before payout.',
},
]
const vectors = await embedding.embedBatch(
articles.map((article) => `${article.title}. ${article.body}`),
'document'
)
const narsil = await createNarsil()
await narsil.createIndex('articles', {
schema: { title: 'string', body: 'string', embedding: 'vector[384]' },
})
await narsil.insertBatch(
'articles',
articles.map((article, position) => ({ ...article, embedding: vectors[position] }))
)
const question = 'when do I get my money back'
const keyword = await narsil.query('articles', {
term: question,
limit: 3,
document: { include: ['title'] },
})
const similar = await narsil.query('articles', {
mode: 'vector',
vector: { field: 'embedding', value: await embedding.embed(question, 'query') },
limit: 3,
document: { include: ['title'] },
})
console.log(
JSON.stringify(
{ question, keyword: keyword.hits, similar: similar.hits },
(key, value) => (typeof value === 'number' ? Number(value.toFixed(2)) : value),
2
)
)
await narsil.shutdown(){
"question": "when do I get my money back",
"keyword": [],
"similar": [
{
"id": "refund-timing",
"score": 0.54,
"document": {
"title": "How a refund reaches your bank"
}
},
{
"id": "billing-statement",
"score": 0.37,
"document": {
"title": "Read your monthly billing statement"
}
},
{
"id": "password-reset",
"score": 0.29,
"document": {
"title": "Reset the password on your account"
}
}
]
}The keyword search returns nothing, because no article holds the word money or the word back. The similarity search puts the refunds article first at 0.54 and the billing statement second at 0.37, and both of those cover the money that the reader asks about. Compare scores within one result set, so read 0.54 against the 0.37 below it and never against a score from another search.
Set a floor under the weakest match
The engine scores every stored vector against the query, so it fills the limit however far the nearest article is from the question. The password article above scores 0.29 on a question about money, which is the shape of a hit that a reader would call wrong. similarity sets the score that a hit must reach, and the engine drops the hits below it before limit applies.
metric sets the comparison. Cosine ranks by direction alone, and the engine applies it to any query without a metric of its own. dotProduct suits vectors that your model normalises for you. euclidean ranks by distance, where the engine maps each distance to 1 / (1 + distance) before it applies the floor, so a floor of 0.8 there admits a distance of 0.25.
import { createNarsil } from '@delali/narsil'
import { createTransformersEmbedding } from '@delali/narsil-embeddings-transformers'
const embedding = createTransformersEmbedding({ dimensions: 384 })
const articles = [
{
id: 'refund-timing',
title: 'How a refund reaches your bank',
body: 'A refund returns to the card that paid for the order, and your bank posts it within five working days.',
},
{
id: 'parcel-tracking',
title: 'Track a parcel after dispatch',
body: 'Every dispatched order has a tracking code, and the carrier updates it at each depot.',
},
{
id: 'password-reset',
title: 'Reset the password on your account',
body: 'A reset link arrives by email and stays valid for one hour.',
},
{
id: 'mobile-card-payments',
title: 'Take card payments in the mobile app',
body: 'The mobile reader pairs over Bluetooth and settles to the same account as the web checkout.',
},
{
id: 'api-key-rotation',
title: 'Rotate an API key',
body: 'A new key works at once, and the old key keeps answering for twenty-four hours.',
},
{
id: 'billing-statement',
title: 'Read your monthly billing statement',
body: 'The statement lists every charge, every refund, and the fees deducted before payout.',
},
]
const vectors = await embedding.embedBatch(
articles.map((article) => `${article.title}. ${article.body}`),
'document'
)
const narsil = await createNarsil()
await narsil.createIndex('articles', {
schema: { title: 'string', body: 'string', embedding: 'vector[384]' },
})
await narsil.insertBatch(
'articles',
articles.map((article, position) => ({ ...article, embedding: vectors[position] }))
)
const question = 'when do I get my money back'
const keyword = await narsil.query('articles', {
term: question,
limit: 3,
document: { include: ['title'] },
})
const similar = await narsil.query('articles', {
mode: 'vector',
vector: { field: 'embedding', value: await embedding.embed(question, 'query') },
vector: { field: 'embedding', value: await embedding.embed(question, 'query'), similarity: 0.45 },
limit: 3,
document: { include: ['title'] },
})
console.log(
JSON.stringify(
{ question, keyword: keyword.hits, similar: similar.hits },
(key, value) => (typeof value === 'number' ? Number(value.toFixed(2)) : value),
2
)
)
await narsil.shutdown(){
"question": "when do I get my money back",
"keyword": [],
"similar": [
{
"id": "refund-timing",
"score": 0.54,
"document": {
"title": "How a refund reaches your bank"
}
}
]
}With the floor at 0.45 the engine keeps the refunds article alone, and it drops the billing statement at 0.37 along with the password article. Pick that number against your own corpus and your own model, because a floor that suits one question can empty the results for the next one.
Let a field grow into a graph
Below its promotion threshold, the engine answers a search on a vector field with a brute-force scan, where it compares the query against every stored vector. Once the field holds more vectors than that threshold, which is 1,024 unless you name another, the engine builds an graph in the background and answers from that graph.
vectorMaintenanceStatus, which you call without an await, reports the state of that work, one entry per vector field. waitForWrites returns once the engine has stored the documents, while the graph build continues past it, so the loop below polls building until the graph exists. The engine still answers a search throughout that build, because bufferSize counts the vectors outside the graph and the engine scans those one by one.
The 5,000 titles below stand in for a help centre of that size, and the panel reports counts that stay the same whatever those titles contain.
import { createNarsil } from '@delali/narsil'
import { createTransformersEmbedding } from '@delali/narsil-embeddings-transformers'
const embedding = createTransformersEmbedding({ dimensions: 384 })
const ARTICLE_COUNT = 5_000
const SUBJECTS = ['refunds', 'delivery', 'accounts', 'payments', 'billing']
const titles = Array.from(
{ length: ARTICLE_COUNT },
(_unused, position) => `Help centre article ${position} on ${SUBJECTS[position % SUBJECTS.length]}`
)
const vectors = await embedding.embedBatch(titles, 'document')
const narsil = await createNarsil()
await narsil.createIndex('help-centre', {
schema: { title: 'string', embedding: 'vector[384]' },
})
await narsil.insertBatch(
'help-centre',
titles.map((title, position) => ({ id: `article-${position}`, title, embedding: vectors[position] }))
)
while (narsil.vectorMaintenanceStatus('help-centre')[0].building) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
console.log(JSON.stringify(narsil.vectorMaintenanceStatus('help-centre'), null, 2))
await narsil.shutdown()[
{
"fieldName": "embedding",
"tombstoneRatio": 0,
"graphCount": 1,
"bufferSize": 0,
"building": false,
"estimatedCompactMs": 0,
"estimatedOptimizeMs": 0
}
]graphCount is 1 once a graph exists, and bufferSize falls to 0 once the graph holds every vector. hnswConfig shapes that graph, where m sets each node's neighbour count to 16, efConstruction sets the build-time exploration at 200, and metric fixes the comparison that the engine builds the graph for. A query then sets efSearch, which is 50 by default, and a higher value raises at the cost of latency, while it changes nothing on a field that stays under its threshold. On a filtered similarity search the engine applies filterThreshold, a selectivity ratio of 0.03 by default, so it scans the matches alone where a filter covers a smaller share of the graph, and it searches the graph where a filter covers a larger share.
Where your installation holds the native package for your platform, these searches execute in a core written in C, while on every other installation the engine searches through WebAssembly. Both paths return the same hits from an exact scan, which a continuous integration job checks on every platform with a published package, as the native search core page describes.
Choose how the engine codes the vectors and where it keeps them
quantization sets how the engine codes each vector that enters the graph. Every osq mode centres the vector on the field's centroid and then fits an interval to that vector alone. The digit in the name is the number of bits in each dimension's code, so osq8 stores a byte per dimension while osq1 stores a bit. Where the configuration leaves the mode unset, the engine takes osq4 at 384 dimensions and above, and osq8 below 384. none stores no code at all, so a search on that field ranks by the vectors alone.
The engine ranks its candidates by those codes and then re-scores the nearest of them against the full-precision vectors. oversample on the query sets how many times limit the engine re-scores. Where a query omits it, the engine takes 3 for osq1 and osq2, 2 for osq8, and 2 for osq4 at 1,024 dimensions and above, dropping to 5 for osq4 below 1,024, where a four-bit code orders the true neighbours less reliably.
storage sets where the full-precision vectors stay. A memory field holds them in memory, while a disk field keeps its codes and its graph in memory, and the engine then reads a vector from the checkpoint file whenever a search re-scores that candidate or you read the document back. A disk field therefore needs filesystem durability, which is why the file below adds createFilesystemPersistence. The engine fails with CONFIG_INVALID where you ask for disk on an engine without it. Where the configuration leaves it unset, an engine with filesystem durability takes disk, while every other engine takes memory.
import { mkdirSync, rmSync } from 'node:fs'
import { createNarsil } from '@delali/narsil'
import { createFilesystemPersistence } from '@delali/narsil/adapters/filesystem'
import { createTransformersEmbedding } from '@delali/narsil-embeddings-transformers'
const embedding = createTransformersEmbedding({ dimensions: 384 })
const ARTICLE_COUNT = 5_000
const SUBJECTS = ['refunds', 'delivery', 'accounts', 'payments', 'billing']
const titles = Array.from(
{ length: ARTICLE_COUNT },
(_unused, position) => `Help centre article ${position} on ${SUBJECTS[position % SUBJECTS.length]}`
)
const vectors = await embedding.embedBatch(titles, 'document')
rmSync('./help-centre-data', { recursive: true, force: true })
mkdirSync('./help-centre-data', { recursive: true })
const narsil = await createNarsil()
const narsil = await createNarsil({
persistence: createFilesystemPersistence({ directory: './help-centre-data' }),
})
await narsil.createIndex('help-centre', {
schema: { title: 'string', embedding: 'vector[384]' },
vectorPromotion: { threshold: 2_048, quantization: 'osq8', storage: 'disk' },
})
await narsil.insertBatch(
'help-centre',
titles.map((title, position) => ({ id: `article-${position}`, title, embedding: vectors[position] }))
)
while (narsil.vectorMaintenanceStatus('help-centre')[0].building) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
console.log(JSON.stringify(narsil.vectorMaintenanceStatus('help-centre'), null, 2))
await narsil.shutdown()threshold sets the vector count at which the build starts, so raising it to 2,048 keeps this field on the exact scan for longer. The status output stays the same, because the coding mode and the storage mode change where the engine keeps the vectors and leave the counts alone.
Clear the tombstones after a removal
Removing or updating a vector leaves a in the graph, which keeps the graph's structure whole while the engine walks past it on every search. tombstoneRatio reports the share of the graph's nodes that the tombstones hold, so that you can see when enough of them build up to slow a search down.
compactVectors drops the tombstones, keeps the graph, and returns once that work is over. optimizeVectors rebuilds the graph from the live vectors, which takes longer and restores the search speed. Name a field to maintain that field alone, or leave the name out to cover every vector field in the index.
import { mkdirSync, rmSync } from 'node:fs'
import { createNarsil } from '@delali/narsil'
import { createFilesystemPersistence } from '@delali/narsil/adapters/filesystem'
import { createTransformersEmbedding } from '@delali/narsil-embeddings-transformers'
const embedding = createTransformersEmbedding({ dimensions: 384 })
const ARTICLE_COUNT = 5_000
const SUBJECTS = ['refunds', 'delivery', 'accounts', 'payments', 'billing']
const titles = Array.from(
{ length: ARTICLE_COUNT },
(_unused, position) => `Help centre article ${position} on ${SUBJECTS[position % SUBJECTS.length]}`
)
const vectors = await embedding.embedBatch(titles, 'document')
rmSync('./help-centre-data', { recursive: true, force: true })
mkdirSync('./help-centre-data', { recursive: true })
const narsil = await createNarsil({
persistence: createFilesystemPersistence({ directory: './help-centre-data' }),
})
await narsil.createIndex('help-centre', {
schema: { title: 'string', embedding: 'vector[384]' },
vectorPromotion: { threshold: 2_048, quantization: 'osq8', storage: 'disk' },
})
await narsil.insertBatch(
'help-centre',
titles.map((title, position) => ({ id: `article-${position}`, title, embedding: vectors[position] }))
)
while (narsil.vectorMaintenanceStatus('help-centre')[0].building) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
for (let position = 0; position < 1_000; position += 1) {
await narsil.remove('help-centre', `article-${position}`)
}
const [withdrawn] = narsil.vectorMaintenanceStatus('help-centre')
await narsil.compactVectors('help-centre', 'embedding')
const [compacted] = narsil.vectorMaintenanceStatus('help-centre')
console.log(JSON.stringify(narsil.vectorMaintenanceStatus('help-centre'), null, 2))
console.log(JSON.stringify({ withdrawn, compacted }, null, 2))
await narsil.shutdown(){
"withdrawn": {
"fieldName": "embedding",
"tombstoneRatio": 0.2,
"graphCount": 1,
"bufferSize": 0,
"building": false,
"estimatedCompactMs": 75,
"estimatedOptimizeMs": 0
},
"compacted": {
"fieldName": "embedding",
"tombstoneRatio": 0,
"graphCount": 1,
"bufferSize": 0,
"building": false,
"estimatedCompactMs": 0,
"estimatedOptimizeMs": 0
}
}Once the program removes 1,000 of the 5,000 articles, tombstoneRatio stands at 0.2, which the compaction returns to 0 with the graph still in place. Both estimates scale with the tombstone count, the vector count, and the field's dimension, so measure the two calls on your own corpus before you schedule either.
Where to go next
- Hybrid search covers the mode where the engine searches by keyword and by meaning at once and fuses the two rankings into one order.
- Embedding adapters covers the adapters that embed your text inside the engine, so an insert takes the text alone and a query passes
textwhere the queries above passvalue. - Persistence and durability covers the checkpoint file that the engine reads a
diskfield's vectors from.