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

# Embedding adapters

> Turn text into vectors automatically on insert and at query time, with OpenAI-compatible APIs, local models, or your own adapter.

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

Embedding adapters turn text into vectors automatically, on insert and at query time. You configure an adapter once, map each vector field to the text fields it embeds, and Narsil handles the rest: inserts embed the source text before indexing, and vector queries embed the search text the same way. This page builds one file, `embeddings.ts`, step by step. You start with a default adapter for the whole engine, then move to a named adapter that survives durability recovery. Each step highlights exactly what changed.

## Auto-embed an index

Pass an `embedding` adapter to `createNarsil` and it becomes the default for every index. The OpenAI adapter talks to any OpenAI-compatible endpoint through `fetch`. Start `embeddings.ts` with an engine and an `articles` index whose `embedding` field is built from the `title` and `body` text. Multiple source fields concatenate before embedding.

```ts title="embeddings.ts"

const narsil = await createNarsil({
  embedding: createOpenAIEmbedding({
    baseUrl: 'https://api.openai.com/v1',
    apiKey: process.env.OPENAI_API_KEY ?? '',
    model: 'text-embedding-3-small',
    dimensions: 1536,
  }),
})

await narsil.createIndex('articles', {
  schema: {
    title: 'string',
    body: 'string',
    embedding: 'vector[1536]',
  },
  embedding: {
    fields: {
      embedding: ['title', 'body'],
    },
  },
})
```

The `dimensions` value on the adapter must match the `vector[1536]` field size. A mismatch fails at index creation with `EMBEDDING_DIMENSION_MISMATCH`.

## Insert without touching a vector

With the index in place, add two articles to `embeddings.ts`. Neither insert carries an `embedding` value: Narsil reads `title` and `body`, embeds them through the default adapter, and stores the result in the `embedding` field for you.

```ts title="embeddings.ts"

const narsil = await createNarsil({
  embedding: createOpenAIEmbedding({
    baseUrl: 'https://api.openai.com/v1',
    apiKey: process.env.OPENAI_API_KEY ?? '',
    model: 'text-embedding-3-small',
    dimensions: 1536,
  }),
})

await narsil.createIndex('articles', {
  schema: {
    title: 'string',
    body: 'string',
    embedding: 'vector[1536]',
  },
  embedding: {
    fields: {
      embedding: ['title', 'body'],
    },
  },
})

await narsil.insert('articles', { // [!code ++:5]
  id: 'scaling-search',
  title: 'Scaling search with partitions',
  body: 'Partitioning spreads one index across many workers so that throughput rises with hardware.',
})

await narsil.insert('articles', { // [!code ++:5]
  id: 'vector-basics',
  title: 'Vector search basics',
  body: 'Dense vectors capture meaning beyond the exact keywords a reader typed.',
})
```

## Query in plain text

A vector query passes `text` instead of a raw `Float32Array`, and the same default adapter embeds it before the search runs. Add the query to the end of `embeddings.ts` and read the ranked hits.

```ts title="embeddings.ts"

const narsil = await createNarsil({
  embedding: createOpenAIEmbedding({
    baseUrl: 'https://api.openai.com/v1',
    apiKey: process.env.OPENAI_API_KEY ?? '',
    model: 'text-embedding-3-small',
    dimensions: 1536,
  }),
})

await narsil.createIndex('articles', {
  schema: {
    title: 'string',
    body: 'string',
    embedding: 'vector[1536]',
  },
  embedding: {
    fields: {
      embedding: ['title', 'body'],
    },
  },
})

await narsil.insert('articles', {
  id: 'scaling-search',
  title: 'Scaling search with partitions',
  body: 'Partitioning spreads one index across many workers so that throughput rises with hardware.',
})

await narsil.insert('articles', {
  id: 'vector-basics',
  title: 'Vector search basics',
  body: 'Dense vectors capture meaning beyond the exact keywords a reader typed.',
})

const results = await narsil.query('articles', { // [!code ++:6]
  mode: 'vector',
  vector: { field: 'embedding', text: 'how do search engines scale?' },
})

console.log(results.hits.map((hit) => hit.id))
```

The query embeds `how do search engines scale?` through the default adapter and ranks the two articles by <Tooltip tip="Cosine similarity measures how closely two vectors point in the same direction, on a scale where 1 means identical meaning and 0 means unrelated.">cosine similarity</Tooltip>. The scaling article comes back first, because its meaning is closest to the query, even though neither document repeats the exact phrase. Each hit has the same shape as any other search mode, `{ id, score, document }`, and the exact scores depend on the embedding model you configured, so this page describes the ranking rather than pinning a number the model would set. The insert embedded `title` and `body` into the `embedding` field without you constructing a vector, and the query embedded its `text` the same way. The vector remains in the index and is never returned on `hit.document`, so each hit shows only the `title` and `body` you inserted.

## Name the adapter so that it survives recovery

An adapter instance is a function, so it cannot be serialised to disk. When you turn on durability, an index that points at a default adapter has nothing to rebind to after a restart. Give the adapter a name instead: move it into an `embeddingAdapters` map, reference that name from the index with `adapter:`, and turn on `durability`. The engine persists the name in index metadata and rebinds the live adapter on the next start.

```ts title="embeddings.ts"

const narsil = await createNarsil({
  embedding: createOpenAIEmbedding({ // [!code --:6]
    baseUrl: 'https://api.openai.com/v1',
    apiKey: process.env.OPENAI_API_KEY ?? '',
    model: 'text-embedding-3-small',
    dimensions: 1536,
  }),
  embeddingAdapters: { // [!code ++:8]
    'openai-small': createOpenAIEmbedding({
      baseUrl: 'https://api.openai.com/v1',
      apiKey: process.env.OPENAI_API_KEY ?? '',
      model: 'text-embedding-3-small',
      dimensions: 1536,
    }),
  },
  durability: { directory: './narsil-data' }, // [!code ++]
})

await narsil.createIndex('articles', {
  schema: {
    title: 'string',
    body: 'string',
    embedding: 'vector[1536]',
  },
  embedding: {
    adapter: 'openai-small', // [!code ++]
    fields: {
      embedding: ['title', 'body'],
    },
  },
})

await narsil.insert('articles', {
  id: 'scaling-search',
  title: 'Scaling search with partitions',
  body: 'Partitioning spreads one index across many workers so that throughput rises with hardware.',
})

await narsil.insert('articles', {
  id: 'vector-basics',
  title: 'Vector search basics',
  body: 'Dense vectors capture meaning beyond the exact keywords a reader typed.',
})

const results = await narsil.query('articles', {
  mode: 'vector',
  vector: { field: 'embedding', text: 'how do search engines scale?' },
})

console.log(results.hits.map((hit) => hit.id))
```

The inserts and the query keep working unchanged, because the index now resolves its adapter by name.

## Rebind an adapter at runtime

`registerEmbeddingAdapter(name, adapter)` replaces the live adapter behind a name and rebinds every index that references it. Call it to rotate an API key or swap the model without recreating any index. This call is separate from the running script, so it gets its own file, `rotate-key.ts`, holding the same named adapter and reading the new key from `OPENAI_API_KEY_ROTATED`.

```ts title="rotate-key.ts"

const narsil = await createNarsil({
  embeddingAdapters: {
    'openai-small': createOpenAIEmbedding({
      baseUrl: 'https://api.openai.com/v1',
      apiKey: process.env.OPENAI_API_KEY ?? '',
      model: 'text-embedding-3-small',
      dimensions: 1536,
    }),
  },
  durability: { directory: './narsil-data' },
})

narsil.registerEmbeddingAdapter(
  'openai-small',
  createOpenAIEmbedding({
    baseUrl: 'https://api.openai.com/v1',
    apiKey: process.env.OPENAI_API_KEY_ROTATED ?? '',
    model: 'text-embedding-3-small',
    dimensions: 1536,
  }),
)
```

## Bundled adapters

| Adapter | Package | Notes |
| --- | --- | --- |
| OpenAI | `@delali/narsil/embeddings/openai` | The adapter has no dependencies, uses `fetch`, retries retryable failures with exponential backoff and jitter, and chunks batches at 2,048 inputs per request. `baseUrl` points at any OpenAI-compatible endpoint. |
| Transformers.js | `@delali/narsil-embeddings-transformers` | The adapter runs models locally with lazy initialisation, supports WebGPU, WASM, and CPU backends, and handles asymmetric models such as E5 and BGE through `documentPrefix` and `queryPrefix`. It requires `@huggingface/transformers` as a peer dependency. |

## Write your own

Any object that satisfies the `EmbeddingAdapter` interface works in place of the bundled adapters. This is a reference shape, so it is separate from the running file above.

```ts
interface EmbeddingAdapter {
  embed(input: string, purpose: 'document' | 'query', signal?: AbortSignal): Promise<Float32Array>
  embedBatch?(inputs: string[], purpose: 'document' | 'query', signal?: AbortSignal): Promise<Float32Array[]>
  readonly dimensions: number
  shutdown?(): Promise<void>
}
```

The `purpose` argument marks whether the adapter is embedding a document at insert time or a query at search time, which <Tooltip tip="An asymmetric embedding model encodes queries and documents differently, usually by adding a prefix to each, because a short question and a long passage express meaning in different shapes.">asymmetric models</Tooltip> such as E5 and BGE use to prefix the input differently. When `embedBatch` is missing, Narsil falls back to parallel `embed()` calls with a concurrency limit of 8. The `dimensions` value must match the vector field's declared size; a mismatch fails at index creation with `EMBEDDING_DIMENSION_MISMATCH`.
