Search modes

Hybrid search

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

Table of Contents

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 needs 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 needs an OPENAI_API_KEY to run, and the adapter's dimensions must match the vector[768] field.

search.ts
import { createNarsil } from '@delali/narsil'
import { createOpenAIEmbedding } from '@delali/narsil/embeddings/openai'
 
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 tells Narsil which text fields feed the embedding vector, so inserted documents get embedded automatically. The embedding adapter 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.

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

search.ts
import { createNarsil } from '@delali/narsil'
import { createOpenAIEmbedding } from '@delali/narsil/embeddings/openai'
 
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 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 },
  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 needs 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.

search.ts
import { createNarsil } from '@delali/narsil'
import { createOpenAIEmbedding } from '@delali/narsil/embeddings/openai'
 
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 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 }, 
  hybrid: { strategy: 'linear', alpha: 0.7 }, 
  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 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 you can watch the matched sources change as you switch modes.