Search modes

Hybrid search

Search one index for keywords and for meaning at once, read the two rankings behind the fused order, and weight one side with the linear blend.

A support question can hold two questions at once, as 'why was my card declined and when do I get my money back' does. Its words match the article about declines, while the article about refunds has one content word in common with it, which is card. In hybrid mode, the engine performs a keyword search and a similarity search over one index in a single query and merges the two rankings, so an answer to either half can reach the reader.

You build one file here, help-desk.ts. You create an index that the engine embeds for you, ask it one question in hybrid mode, open the two rankings that produce that order, and then move the weight onto the similarity side. In this file you embed the text through the Transformers.js adapter, so install @delali/narsil-embeddings-transformers and its peer @huggingface/transformers beside @delali/narsil. The adapter downloads the model on the first call. Each code block below holds one whole file.

Answer a question that holds two questions

Here the engine holds the embedding adapter, while embedding.fields maps the vector field to the text fields that the engine embeds into it. Each insert therefore takes a title and a body, which the engine embeds into embedding. On vector search you pass the vectors by hand, which is the other way to fill that field.

You give a hybrid query both inputs. term is the text that the keyword search scores with BM25, while vector is the text or the vector that the similarity search compares. Under mode: 'hybrid', the engine merges the two rankings.

help-desk.ts
import { createNarsil } from '@delali/narsil'
import { createTransformersEmbedding } from '@delali/narsil-embeddings-transformers'
 
const narsil = await createNarsil({
  embedding: createTransformersEmbedding({ dimensions: 384 }),
})
 
await narsil.createIndex('help-desk', {
  schema: { title: 'string', body: 'string', embedding: 'vector[384]' },
  embedding: { fields: { embedding: ['title', 'body'] } },
})
 
await narsil.insertBatch('help-desk', [
  {
    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: 'gateway-declines',
    title: 'What the checkout does with a declined card',
    body: 'The gateway answers with code NRS-429 when a shopper sends more card attempts than it allows in a minute.',
  },
  {
    id: 'payout-schedule',
    title: 'When a payout reaches your account',
    body: 'Payouts leave on working days, and the bank credits them on the next working day.',
  },
  {
    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: 'reader-replacement',
    title: 'Replace a reader that stops charging',
    body: 'Send back the unit marked R7-Mini on its base, and a replacement arrives within two working days.',
  },
])
 
const question = 'why was my card declined and when do I get my money back'
 
const fused = await narsil.query('help-desk', {
  mode: 'hybrid',
  term: question,
  vector: { field: 'embedding', text: question },
  limit: 4,
  document: { include: ['title'] },
})
 
console.log(
  JSON.stringify(fused.hits, (key, value) => (typeof value === 'number' ? Number(value.toFixed(4)) : value), 2)
)
 
await narsil.shutdown()
[
  {
    "id": "gateway-declines",
    "score": 0.0328,
    "document": {
      "title": "What the checkout does with a declined card"
    }
  },
  {
    "id": "reader-replacement",
    "score": 0.032,
    "document": {
      "title": "Replace a reader that stops charging"
    }
  },
  {
    "id": "refund-timing",
    "score": 0.032,
    "document": {
      "title": "How a refund reaches your bank"
    }
  },
  {
    "id": "payout-schedule",
    "score": 0.0156,
    "document": {
      "title": "When a payout reaches your account"
    }
  }
]

The decline article tops the list, because both searches put it first. The refunds article and the reader article score the same 0.032, so the engine breaks the tie on the document id, which puts the reader article first. Those scores are small because rank fusion adds fractions, as the arithmetic below shows for each one.

Look at the two rankings behind that order

You put the same question to each search on its own, so you can see what the fusion merged. term alone is keyword mode, while mode: 'vector' alone is similarity mode.

help-desk.ts
import { createNarsil } from '@delali/narsil'
import { createTransformersEmbedding } from '@delali/narsil-embeddings-transformers'
 
const narsil = await createNarsil({
  embedding: createTransformersEmbedding({ dimensions: 384 }),
})
 
await narsil.createIndex('help-desk', {
  schema: { title: 'string', body: 'string', embedding: 'vector[384]' },
  embedding: { fields: { embedding: ['title', 'body'] } },
})
 
await narsil.insertBatch('help-desk', [
  {
    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: 'gateway-declines',
    title: 'What the checkout does with a declined card',
    body: 'The gateway answers with code NRS-429 when a shopper sends more card attempts than it allows in a minute.',
  },
  {
    id: 'payout-schedule',
    title: 'When a payout reaches your account',
    body: 'Payouts leave on working days, and the bank credits them on the next working day.',
  },
  {
    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: 'reader-replacement',
    title: 'Replace a reader that stops charging',
    body: 'Send back the unit marked R7-Mini on its base, and a replacement arrives within two working days.',
  },
])
 
const question = 'why was my card declined and when do I get my money back'
 
const keyword = await narsil.query('help-desk', { term: question, limit: 4, document: false }) 
 
const similar = await narsil.query('help-desk', {
  mode: 'vector',
  vector: { field: 'embedding', text: question },
  limit: 4,
  document: false,
})
 
const fused = await narsil.query('help-desk', {
  mode: 'hybrid',
  term: question,
  vector: { field: 'embedding', text: question },
  limit: 4,
  document: { include: ['title'] },
})
 
console.log(
  JSON.stringify(fused.hits, (key, value) => (typeof value === 'number' ? Number(value.toFixed(4)) : value), 2) 
  JSON.stringify( 
    {
      keyword: keyword.hits.map((hit) => hit.id),
      similar: similar.hits.map((hit) => hit.id),
      fused: fused.hits.map((hit) => ({ id: hit.id, score: hit.score })),
    },
    (key, value) => (typeof value === 'number' ? Number(value.toFixed(4)) : value),
    2
  )
)
 
await narsil.shutdown()
{
  "keyword": [
    "gateway-declines",
    "reader-replacement",
    "refund-timing"
  ],
  "similar": [
    "gateway-declines",
    "refund-timing",
    "reader-replacement",
    "payout-schedule"
  ],
  "fused": [
    {
      "id": "gateway-declines",
      "score": 0.0328
    },
    {
      "id": "reader-replacement",
      "score": 0.032
    },
    {
      "id": "refund-timing",
      "score": 0.032
    },
    {
      "id": "payout-schedule",
      "score": 0.0156
    }
  ]
}

The keyword list holds three documents, because three of the five hold a word from the question, while the engine fills the similarity list to its limit of four, because the engine scores every stored vector against the question.

Reciprocal rank fusion, the default strategy, scores a document as 1 / (k + rank) on each list where it appears, with ranks counting from 1 and k at 60 unless the query sets another. The decline article takes rank 1 on both lists, so its score is 1/61 + 1/61, or 0.0328. The reader article takes rank 2 on keywords and rank 3 on meaning, while the refunds article takes rank 3 on keywords and rank 2 on meaning, so both of them score 1/62 + 1/63, or 0.032. The payout article appears on one list alone, so its single 1/64 puts it last at 0.0156.

Under that arithmetic, agreement between the two searches outranks a strong showing on one of them, which puts the decline article clear at the top. The engine uses the ranks alone under this strategy, however, so two documents that swap places between the lists score the same, which is what ties the reader article to the refunds article here. A larger k flattens the difference between the ranks, so at a k of 600 the engine scores the top three documents of this query at 0.0033 apiece, while at a k of 1 they spread from 1 to 0.5833. k takes a whole number of 1 or more; for any other value, the engine fails the query with CONFIG_INVALID.

Weight the similarity side

strategy: 'linear' blends the scores in place of the ranks. The engine normalises each list against its own lowest and highest score, so the top of each list is 1 and the bottom is 0. It then scores every document as alpha * similarity + (1 - alpha) * keyword, counting a document missing from a list as 0 there. alpha weights the similarity side at 0.5 unless you set it. For a value outside the range 0 to 1, or one that is no finite number, the engine fails the query with CONFIG_INVALID.

help-desk.ts
import { createNarsil } from '@delali/narsil'
import { createTransformersEmbedding } from '@delali/narsil-embeddings-transformers'
 
const narsil = await createNarsil({
  embedding: createTransformersEmbedding({ dimensions: 384 }),
})
 
await narsil.createIndex('help-desk', {
  schema: { title: 'string', body: 'string', embedding: 'vector[384]' },
  embedding: { fields: { embedding: ['title', 'body'] } },
})
 
await narsil.insertBatch('help-desk', [
  {
    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: 'gateway-declines',
    title: 'What the checkout does with a declined card',
    body: 'The gateway answers with code NRS-429 when a shopper sends more card attempts than it allows in a minute.',
  },
  {
    id: 'payout-schedule',
    title: 'When a payout reaches your account',
    body: 'Payouts leave on working days, and the bank credits them on the next working day.',
  },
  {
    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: 'reader-replacement',
    title: 'Replace a reader that stops charging',
    body: 'Send back the unit marked R7-Mini on its base, and a replacement arrives within two working days.',
  },
])
 
const question = 'why was my card declined and when do I get my money back'
 
const keyword = await narsil.query('help-desk', { term: question, limit: 4, document: false })
 
const similar = await narsil.query('help-desk', {
  mode: 'vector',
  vector: { field: 'embedding', text: question },
  limit: 4,
  document: false,
})
 
const fused = await narsil.query('help-desk', {
  mode: 'hybrid',
  term: question,
  vector: { field: 'embedding', text: question },
  hybrid: { strategy: 'linear', alpha: 0.7 }, 
  limit: 4,
  document: { include: ['title'] },
})
 
console.log(
  JSON.stringify(
    {
      keyword: keyword.hits.map((hit) => hit.id),
      similar: similar.hits.map((hit) => hit.id),
      fused: fused.hits.map((hit) => ({ id: hit.id, score: hit.score })),
    },
    (key, value) => (typeof value === 'number' ? Number(value.toFixed(4)) : value),
    2
  )
)
 
await narsil.shutdown()
{
  "keyword": [
    "gateway-declines",
    "reader-replacement",
    "refund-timing"
  ],
  "similar": [
    "gateway-declines",
    "refund-timing",
    "reader-replacement",
    "payout-schedule"
  ],
  "fused": [
    {
      "id": "gateway-declines",
      "score": 1
    },
    {
      "id": "refund-timing",
      "score": 0.603
    },
    {
      "id": "reader-replacement",
      "score": 0.4056
    },
    {
      "id": "payout-schedule",
      "score": 0.3255
    }
  ]
}

The blend separates the pair that rank fusion tied. The decline article tops both normalised lists, so it reaches 1 whatever alpha you choose, while the refunds article at 0.603 comes clear of the reader article at 0.4056, where rank fusion scores both of them at 0.032. A rank holds no information about the distance between two scores, so fusing by rank compresses those distances while fusing by score keeps them. Start on rank fusion, and move to the blend once you have measured what each side's scores look like on your own corpus.

Where to go next

  • Vector search covers the vector field itself, the promotion threshold, and the graph that the engine searches for the similarity half of this query.
  • Full-text search covers the BM25 scoring, the fuzzy matching, and the field boosts that shape the keyword half.
  • Embedding adapters covers the adapter that you register on the engine above, including the models that you can load in your own process.