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

# Hybrid search

> Run keyword and vector retrieval in one query and fuse the rankings with reciprocal rank fusion or a weighted blend.

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

Hybrid mode runs the full-text and vector searches in one query and fuses the two rankings. Keyword retrieval finds exact terms and identifiers, vector retrieval finds meaning, and fusion combines both in a single query. This page builds one file, `search.ts`, step by step. You create an index that holds both text and an embedding, load a small set of documents, run a hybrid query with reciprocal rank fusion, then switch the fusion to a weighted blend.

## Create an index with text and vectors

Hybrid search requires a text field for keywords and a vector field for meaning. Start `search.ts` with an engine that registers an OpenAI-compatible embedding adapter and a `docs` index that carries both fields. The adapter turns document and query text into vectors, so the file requires an `OPENAI_API_KEY` to run, and the adapter's `dimensions` must match the `vector[768]` field.

```ts title="search.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: 768,
  }),
})

await narsil.createIndex('docs', {
  schema: {
    title: 'string',
    body: 'string',
    embedding: 'vector[768]',
  },
  language: 'english',
  embedding: {
    fields: { embedding: ['title', 'body'] },
  },
})
```

The `embedding.fields` mapping names the text fields that feed the `embedding` vector, so the engine embeds every inserted document automatically. The [embedding adapter](/docs/embedding-adapters) you register on the engine does the encoding.

## Load documents

Add a batch of documents to `search.ts`. Each one carries a title and body; Narsil embeds them into the `embedding` field on insert.

```ts title="search.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: 768,
  }),
})

await narsil.createIndex('docs', {
  schema: {
    title: 'string',
    body: 'string',
    embedding: 'vector[768]',
  },
  language: 'english',
  embedding: {
    fields: { embedding: ['title', 'body'] },
  },
})

await narsil.insertBatch('docs', [ // [!code ++:5]
  { id: 'scaling', title: 'Scaling search across workers', body: 'A large index spreads across many workers so that queries stay fast under load.' },
  { id: 'rebalance', title: 'Rebalancing partitions', body: 'Moving documents between partitions keeps each one within its size budget.' },
  { id: 'ranking', title: 'How BM25 ranks results', body: 'BM25 weighs term frequency against document length to score keyword matches.' },
])
```

## Run a hybrid query

With documents indexed, add a hybrid query to `search.ts`. It carries both a `term` for keyword matching and a `vector` for meaning, and the `hybrid` config selects how the two rankings fuse. Start with reciprocal rank fusion.

```ts title="search.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: 768,
  }),
})

await narsil.createIndex('docs', {
  schema: {
    title: 'string',
    body: 'string',
    embedding: 'vector[768]',
  },
  language: 'english',
  embedding: {
    fields: { embedding: ['title', 'body'] },
  },
})

await narsil.insertBatch('docs', [
  { id: 'scaling', title: 'Scaling search across workers', body: 'A large index spreads across many workers so that queries stay fast under load.' },
  { id: 'rebalance', title: 'Rebalancing partitions', body: 'Moving documents between partitions keeps each one within its size budget.' },
  { id: 'ranking', title: 'How BM25 ranks results', body: 'BM25 weighs term frequency against document length to score keyword matches.' },
])

const results = await narsil.query('docs', { // [!code ++:7]
  mode: 'hybrid',
  term: 'how do search engines scale',
  vector: { field: 'embedding', text: 'how do search engines scale' },
  hybrid: { strategy: 'rrf', k: 60 },
  limit: 10,
})
```

Reciprocal rank fusion combines the keyword and vector rankings by position. A document that ranks near the top on both sides gains from both, so the scaling article, which matches the keywords and is closest in meaning, comes back first. RRF scores are small by design, because the strategy sums `1 / (k + rank)` across the two rankings rather than blending raw relevance scores. The exact hits and scores depend on the embedding model you configured, so this page describes the ranking rather than pinning a number the model would set. The `vector.text` form embeds the query through the index's embedding adapter. Pass a precomputed `vector.value` array instead when you embed queries yourself.

## Switch the fusion strategy

Reciprocal rank fusion combines the two rankings by position rather than by score. The `k` parameter dampens the contribution of lower ranks and defaults to 60. It is the safer default because it requires no score calibration between the two retrieval modes.

Once you have measured that one side deserves more weight for your corpus, switch to the linear blend. It normalises both score sets to the range 0 to 1 and combines them as `alpha * vector + (1 - alpha) * text`. Here `alpha` defaults to 0.5 and clamps to that range; set it to 0.7 to lean on the vector side. Replace the `rrf` config with the `linear` one in `search.ts`.

```ts title="search.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: 768,
  }),
})

await narsil.createIndex('docs', {
  schema: {
    title: 'string',
    body: 'string',
    embedding: 'vector[768]',
  },
  language: 'english',
  embedding: {
    fields: { embedding: ['title', 'body'] },
  },
})

await narsil.insertBatch('docs', [
  { id: 'scaling', title: 'Scaling search across workers', body: 'A large index spreads across many workers so that queries stay fast under load.' },
  { id: 'rebalance', title: 'Rebalancing partitions', body: 'Moving documents between partitions keeps each one within its size budget.' },
  { id: 'ranking', title: 'How BM25 ranks results', body: 'BM25 weighs term frequency against document length to score keyword matches.' },
])

const results = await narsil.query('docs', {
  mode: 'hybrid',
  term: 'how do search engines scale',
  vector: { field: 'embedding', text: 'how do search engines scale' },
  hybrid: { strategy: 'rrf', k: 60 }, // [!code --]
  hybrid: { strategy: 'linear', alpha: 0.7 }, // [!code ++]
  limit: 10,
})
```

The ranking holds, but the scores move onto a 0 to 1 scale instead of RRF's small sums, because the linear blend normalises both score sets and combines them as `alpha * vector + (1 - alpha) * text`. With `alpha` at 0.7 the vector side carries most of the weight. Filters compose with hybrid mode the same way they do everywhere else: they restrict which documents both retrieval sides consider.

The [server-app example](https://github.com/assetcorp/narsil/blob/main/packages/ts/examples/server-app/README.md) runs all of this end to end. Its Ask view answers a question over your data and includes a toggle that reruns the same question through keyword, semantic, or hybrid retrieval so that you can watch the matched sources change as you switch modes.
