Search modes

Embedding adapters

Let the engine embed your text on every write and every search, keep a batch going when one document has no text, name the adapter an index uses, and wrap it in your own.

An embedding adapter converts text into the numbers that a vector field holds. Once you give the engine an adapter and map a vector field to the text fields that the engine builds it from, the engine embeds each document as you write it and each question as you search, so your code passes text and the engine produces every vector. You build one file here, embeddings.ts, on a small catalogue of kitchen appliances. You embed with 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. Each code block below holds the whole file, with green lines marking the lines that you add at each step and struck lines marking the lines that you remove.

Embed an index as you write it

createNarsil({ embedding }) makes the adapter the default for every index on the engine, while embedding.fields on the index maps the vector field to the text fields that the engine builds it from. The last document in the batch holds a supplier and no name or summary.

embeddings.ts
import { createNarsil } from '@delali/narsil'
import { createTransformersEmbedding } from '@delali/narsil-embeddings-transformers'
 
const embedding = createTransformersEmbedding({ dimensions: 384 })
 
const narsil = await createNarsil({ embedding })
 
await narsil.createIndex('appliances', {
  schema: { name: 'string', summary: 'string', supplier: 'string', embedding: 'vector[384]' },
  embedding: { fields: { embedding: ['name', 'summary'] } },
})
 
const loaded = await narsil.insertBatch('appliances', [
  { id: 'kt-2100', name: 'Ayele stovetop kettle', summary: 'A two-litre enamel kettle that whistles when the water reaches the boil.', supplier: 'Ayele Homeware, Accra' },
  { id: 'gr-4400', name: 'Mori burr grinder', summary: 'Forty grind settings from espresso to cafetiere, with a stainless steel burr set.', supplier: 'Mori Kikai, Osaka' },
  { id: 'bl-1200', name: 'Larsson counter blender', summary: 'A 1200 watt motor that crushes ice and frozen fruit without stalling.', supplier: 'Larsson Kok, Gothenburg' },
  { id: 'tp-0800', name: 'Obi four-slot toaster', summary: 'Four wide slots with a reheat setting and a removable crumb tray.', supplier: 'Obi Appliances, Enugu' },
  { id: 'sc-3000', name: 'Villalobos pressure cooker', summary: 'A six-litre pot that cuts a bean stew from three hours to forty minutes.', supplier: 'Villalobos Cocina, Medellín' },
  { id: 'mw-5500', name: 'Dubois combination oven', summary: 'A microwave and convection oven in one cabinet, with a browning element.', supplier: 'Dubois Électroménager, Lyon' },
  { id: 'xx-0000', supplier: 'Haryanto Dapur, Surabaya' },
])
 
console.log(
  JSON.stringify(
    {
      succeeded: loaded.succeeded,
      failed: loaded.failed.map(({ docId, error }) => ({ docId, code: error.code, details: error.details })),
    },
    null,
    2
  )
)
 
await narsil.shutdown()
{
  "succeeded": [
    "kt-2100",
    "gr-4400",
    "bl-1200",
    "tp-0800",
    "sc-3000",
    "mw-5500"
  ],
  "failed": [
    {
      "docId": "xx-0000",
      "code": "EMBEDDING_NO_SOURCE",
      "details": {
        "field": "embedding",
        "documentIndex": 6
      }
    }
  ]
}

The engine embeds the six appliances in one embedBatch call, joining each name and summary with a newline, and stores the vectors in embedding. The last document holds no text in either source field, so the engine rejects it with EMBEDDING_NO_SOURCE and stores the other six, while details.documentIndex points at its place in the batch. Where the adapter throws, however, the engine rejects every document in the batch with EMBEDDING_FAILED, since it sends every text in that one call. The engine makes no retry of its own, which leaves retries to the adapter.

Search by the meaning of a question

For a vector query that passes text in place of value, the engine embeds the question with the same adapter before it compares the vectors. The program now prints the three nearest appliances in place of the batch report.

embeddings.ts
import { createNarsil } from '@delali/narsil'
import { createTransformersEmbedding } from '@delali/narsil-embeddings-transformers'
 
const embedding = createTransformersEmbedding({ dimensions: 384 })
 
const narsil = await createNarsil({ embedding })
 
await narsil.createIndex('appliances', {
  schema: { name: 'string', summary: 'string', supplier: 'string', embedding: 'vector[384]' },
  embedding: { fields: { embedding: ['name', 'summary'] } },
})
 
const loaded = await narsil.insertBatch('appliances', [ 
await narsil.insertBatch('appliances', [ 
  { id: 'kt-2100', name: 'Ayele stovetop kettle', summary: 'A two-litre enamel kettle that whistles when the water reaches the boil.', supplier: 'Ayele Homeware, Accra' },
  { id: 'gr-4400', name: 'Mori burr grinder', summary: 'Forty grind settings from espresso to cafetiere, with a stainless steel burr set.', supplier: 'Mori Kikai, Osaka' },
  { id: 'bl-1200', name: 'Larsson counter blender', summary: 'A 1200 watt motor that crushes ice and frozen fruit without stalling.', supplier: 'Larsson Kok, Gothenburg' },
  { id: 'tp-0800', name: 'Obi four-slot toaster', summary: 'Four wide slots with a reheat setting and a removable crumb tray.', supplier: 'Obi Appliances, Enugu' },
  { id: 'sc-3000', name: 'Villalobos pressure cooker', summary: 'A six-litre pot that cuts a bean stew from three hours to forty minutes.', supplier: 'Villalobos Cocina, Medellín' },
  { id: 'mw-5500', name: 'Dubois combination oven', summary: 'A microwave and convection oven in one cabinet, with a browning element.', supplier: 'Dubois Électroménager, Lyon' },
  { id: 'xx-0000', supplier: 'Haryanto Dapur, Surabaya' },
])
 
console.log( 
  JSON.stringify( 
    { 
      succeeded: loaded.succeeded, 
      failed: loaded.failed.map(({ docId, error }) => ({ docId, code: error.code, details: error.details })), 
    }, 
    null, 
    2
  ) 
) 
const results = await narsil.query('appliances', { 
  mode: 'vector',
  vector: { field: 'embedding', text: 'something that makes coffee taste better' },
  limit: 3,
  document: { include: ['name', 'supplier'] },
})
 
console.log(JSON.stringify(results.hits, (key, value) => (typeof value === 'number' ? Number(value.toFixed(2)) : value), 2))
 
await narsil.shutdown()
[
  {
    "id": "gr-4400",
    "score": 0.38,
    "document": {
      "name": "Mori burr grinder",
      "supplier": "Mori Kikai, Osaka"
    }
  },
  {
    "id": "bl-1200",
    "score": 0.24,
    "document": {
      "name": "Larsson counter blender",
      "supplier": "Larsson Kok, Gothenburg"
    }
  },
  {
    "id": "sc-3000",
    "score": 0.21,
    "document": {
      "name": "Villalobos pressure cooker",
      "supplier": "Villalobos Cocina, Medellín"
    }
  }
]

None of the three shares a word with the question, yet the burr grinder comes first at 0.38, since its summary holds espresso and cafetiere. The blender and the pressure cooker follow at 0.24 and 0.21, which are weak matches for a question about coffee, so set the similarity floor that vector search covers wherever you want the engine to drop such hits. A stored vector comes back on get and on every hit as a Float32Array, so the program names the fields to return with document.include.

Name the adapter that an index uses

embeddingAdapters holds adapters under names, while adapter on the index names the one that the engine embeds that index with. The query returns the same three appliances as the step before.

embeddings.ts
import { createNarsil } from '@delali/narsil'
import { createTransformersEmbedding } from '@delali/narsil-embeddings-transformers'
 
const embedding = createTransformersEmbedding({ dimensions: 384 })
 
const narsil = await createNarsil({ embedding }) 
const narsil = await createNarsil({ embeddingAdapters: { 'kitchen-model': embedding } }) 
 
await narsil.createIndex('appliances', {
  schema: { name: 'string', summary: 'string', supplier: 'string', embedding: 'vector[384]' },
  embedding: { fields: { embedding: ['name', 'summary'] } }, 
  embedding: { adapter: 'kitchen-model', fields: { embedding: ['name', 'summary'] } }, 
})
 
await narsil.insertBatch('appliances', [
  { id: 'kt-2100', name: 'Ayele stovetop kettle', summary: 'A two-litre enamel kettle that whistles when the water reaches the boil.', supplier: 'Ayele Homeware, Accra' },
  { id: 'gr-4400', name: 'Mori burr grinder', summary: 'Forty grind settings from espresso to cafetiere, with a stainless steel burr set.', supplier: 'Mori Kikai, Osaka' },
  { id: 'bl-1200', name: 'Larsson counter blender', summary: 'A 1200 watt motor that crushes ice and frozen fruit without stalling.', supplier: 'Larsson Kok, Gothenburg' },
  { id: 'tp-0800', name: 'Obi four-slot toaster', summary: 'Four wide slots with a reheat setting and a removable crumb tray.', supplier: 'Obi Appliances, Enugu' },
  { id: 'sc-3000', name: 'Villalobos pressure cooker', summary: 'A six-litre pot that cuts a bean stew from three hours to forty minutes.', supplier: 'Villalobos Cocina, Medellín' },
  { id: 'mw-5500', name: 'Dubois combination oven', summary: 'A microwave and convection oven in one cabinet, with a browning element.', supplier: 'Dubois Électroménager, Lyon' },
  { id: 'xx-0000', supplier: 'Haryanto Dapur, Surabaya' },
])
 
const results = await narsil.query('appliances', {
  mode: 'vector',
  vector: { field: 'embedding', text: 'something that makes coffee taste better' },
  limit: 3,
  document: { include: ['name', 'supplier'] },
})
 
console.log(JSON.stringify(results.hits, (key, value) => (typeof value === 'number' ? Number(value.toFixed(2)) : value), 2))
 
await narsil.shutdown()

The engine stores the name with the index and never the adapter, because an adapter is a function, so pass the same adapters to createNarsil, under the same names, each time the process starts. An engine that opens an index without its adapter fails a write or a text query on it with EMBEDDING_CONFIG_INVALID. Where the index uses a name, the message names the missing adapter. The same engine still accepts a document that brings its own vector and a query that passes value. registerEmbeddingAdapter(name, adapter) binds an adapter to a name on a running engine, which repairs such an index in place and lets you swap a model without recreating the index. It refuses an adapter whose dimensions differ from the field's width with EMBEDDING_DIMENSION_MISMATCH before it changes anything. With names, you can also give each index its own adapter, while the engine-wide default applies to every index.

Wrap an adapter in your own

Any object with dimensions and an embed(input, purpose, signal) method works as an adapter, while embedBatch and shutdown are optional. purpose is 'document' on a write and 'query' on a search, so that an adapter for an asymmetric model can add a different prefix to each. Here the wrapper stores each vector under its purpose and text, so that the model embeds each distinct text once.

embeddings.ts
import { createNarsil } from '@delali/narsil'
import { createNarsil, type EmbeddingAdapter } from '@delali/narsil'
import { createTransformersEmbedding } from '@delali/narsil-embeddings-transformers'
 
const embedding = createTransformersEmbedding({ dimensions: 384 })
 
const narsil = await createNarsil({ embeddingAdapters: { 'kitchen-model': embedding } }) 
const cache = new Map<string, Float32Array>() 
let modelCalls = 0
 
const cachedEmbedding: EmbeddingAdapter = {
  dimensions: embedding.dimensions,
  async embed(input, purpose, signal) {
    const key = `${purpose}:${input}`
    const cached = cache.get(key)
    if (cached !== undefined) return cached
    modelCalls += 1
    const vector = await embedding.embed(input, purpose, signal)
    cache.set(key, vector)
    return vector
  },
  shutdown: () => embedding.shutdown(),
}
 
const narsil = await createNarsil({ embeddingAdapters: { 'kitchen-model': cachedEmbedding } })
 
await narsil.createIndex('appliances', {
  schema: { name: 'string', summary: 'string', supplier: 'string', embedding: 'vector[384]' },
  embedding: { adapter: 'kitchen-model', fields: { embedding: ['name', 'summary'] } },
})
 
await narsil.insertBatch('appliances', [
  { id: 'kt-2100', name: 'Ayele stovetop kettle', summary: 'A two-litre enamel kettle that whistles when the water reaches the boil.', supplier: 'Ayele Homeware, Accra' },
  { id: 'gr-4400', name: 'Mori burr grinder', summary: 'Forty grind settings from espresso to cafetiere, with a stainless steel burr set.', supplier: 'Mori Kikai, Osaka' },
  { id: 'bl-1200', name: 'Larsson counter blender', summary: 'A 1200 watt motor that crushes ice and frozen fruit without stalling.', supplier: 'Larsson Kok, Gothenburg' },
  { id: 'tp-0800', name: 'Obi four-slot toaster', summary: 'Four wide slots with a reheat setting and a removable crumb tray.', supplier: 'Obi Appliances, Enugu' },
  { id: 'sc-3000', name: 'Villalobos pressure cooker', summary: 'A six-litre pot that cuts a bean stew from three hours to forty minutes.', supplier: 'Villalobos Cocina, Medellín' },
  { id: 'mw-5500', name: 'Dubois combination oven', summary: 'A microwave and convection oven in one cabinet, with a browning element.', supplier: 'Dubois Électroménager, Lyon' },
  { id: 'xx-0000', supplier: 'Haryanto Dapur, Surabaya' },
])
 
const results = await narsil.query('appliances', { 
  mode: 'vector', 
  vector: { field: 'embedding', text: 'something that makes coffee taste better' }, 
  limit: 3, 
  document: { include: ['name', 'supplier'] }, 
}) 
const question = 'something that makes coffee taste better'
const first = await narsil.query('appliances', { mode: 'vector', vector: { field: 'embedding', text: question }, limit: 3 })
const again = await narsil.query('appliances', { mode: 'vector', vector: { field: 'embedding', text: question }, limit: 3 })
const ids = (hits: Array<{ id: string }>): string[] => hits.map((hit) => hit.id)
 
console.log(JSON.stringify(results.hits, (key, value) => (typeof value === 'number' ? Number(value.toFixed(2)) : value), 2)) 
console.log(JSON.stringify({ first: ids(first.hits), again: ids(again.hits), modelCalls, cachedTexts: cache.size }, null, 2)) 
 
await narsil.shutdown()
{
  "first": [
    "gr-4400",
    "bl-1200",
    "sc-3000"
  ],
  "again": [
    "gr-4400",
    "bl-1200",
    "sc-3000"
  ],
  "modelCalls": 7,
  "cachedTexts": 7
}

The model embedded seven texts, one for each of the six appliances and one for the question, while the second query took its vector from the cache and returned the same three appliances. A wrapper without embedBatch makes the engine call embed once for each document in a batch, so give your own adapter embedBatch wherever the model embeds a batch faster than it embeds the same texts one at a time.

Choose an adapter

Two adapters come with Narsil. createTransformersEmbedding from @delali/narsil-embeddings-transformers loads a model into your process on its first call, Xenova/all-MiniLM-L6-v2 unless you name another, and it needs no key. It loads the model at full precision, fp32, unless you set dtype. Under a quantised dtype such as q8, the adapter downloads a smaller model. Because that model computes one scale for the numbers of a whole batch, the adapter then embeds each text of a batch on its own, so that each text receives the vector that a single call returns. A batch therefore takes as long as embedding the same texts one at a time. documentPrefix and queryPrefix prepend text for an asymmetric model such as E5 or BGE, while pooling and normalize set how the adapter reduces the model's output to one vector.

createOpenAIEmbedding from @delali/narsil/embeddings/openai calls any endpoint that serves the OpenAI embeddings API, given a baseUrl, a model, the dimensions, and an apiKey, which may be a function that the adapter calls before each request. It sends up to 2,048 texts in one request, retries a 429, 500, 502, 503, or 504 response up to three times by default with a delay that roughly doubles each time, and honours a retry-after header. It ignores purpose, so the model embeds a question and a document the same way.

Where to go next

  • Vector search covers the vector field, the similarity floor, and the graph that the engine builds as a field grows.
  • Hybrid search covers a query that searches the same index for keywords and for meaning at once.