Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Hybrid Retrieval: Lexical (BM25) + Reciprocal-Rank Fusion

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Retrieval.

Dense vector search finds rows that mean the same thing as your query; lexical (BM25) search finds rows that contain the same words. Each misses what the other catches — dense search fumbles rare identifiers and exact phrases, lexical search misses paraphrase. Hybrid retrieval runs both and fuses their rankings, and it is the standard production recipe because it reliably beats either alone.

Jammi ships two pieces for this:

  • A lexical sidecar (LexicalIndex) — a tantivy BM25 inverted index that rides beside a result table’s Parquet object, the lexical peer of the USearch ANN sidecar.
  • Reciprocal-rank fusion (rrf_fuse) — merges any number of ranked lists by rank, not score.

Fusing by rank is the whole point: BM25 scores and cosine similarities live on incompatible scales, so averaging them is meaningless. RRF never looks at a raw score — it sums 1 / (k_rrf + rank) across the lists a row appears in, so the fused order depends only on where a row landed in each list. The default k_rrf is 60 (Cormack et al., SIGIR 2009; robust across 40–80).

Build a lexical index

A LexicalIndex is built over (row_id, text) pairs — the text is whatever text columns of the row you want searchable, joined by the caller. The analyzer is configurable; English (lowercase + Porter stemming) is the default, and Raw (lowercase, no stemming) is the escape hatch for text the English stemmer would mangle (codes, identifiers, non-English).

#![allow(unused)]
fn main() {
extern crate jammi_ai;
extern crate jammi_db;
fn ex() -> jammi_db::error::Result<()> {
use jammi_ai::index::{Analyzer, LexicalIndex};

let rows = vec![
    ("doc-1", "a method for reducing turbine blade vibration"),
    ("doc-2", "an apparatus for cooling turbine engine blades"),
    ("doc-3", "a recipe for baking sourdough bread"),
];

let lexical = LexicalIndex::build(rows, Analyzer::English)?;
let hits = lexical.search("turbine engine", 10)?;
for hit in &hits {
    println!("{} bm25={:.3} rank={}", hit.row_id, hit.bm25_score, hit.rank);
}
Ok(()) }
}

Each LexicalHit carries the row_id, its raw bm25_score, and its 0-based rank — the rank is what fusion consumes.

Lifecycle and scope

The lexical sidecar’s lifecycle equals the ANN sidecar’s: it is built (and rebuilt) with the table. An immutable result table that is rebuilt produces a fresh sidecar; for a mutable-table source, re-ingesting the changed rows into a new index is the caller’s mode. Search applies no row-level filter — isolation is table-level, exactly as the ANN search path: resolve the table through the tenant-scoped catalog and hand the index only that table’s rows.

Fuse dense and lexical rankings

rrf_fuse takes a slice of ranked lists — each a best-first list of _row_ids — and returns one fused ranking. The dense list is the ANN search result; the lexical list is the LexicalIndex result. A third list (e.g. a graph-retrieval channel) fuses identically, with no special-casing.

#![allow(unused)]
fn main() {
extern crate jammi_ai;
use jammi_ai::query::{rrf_fuse, DEFAULT_K_RRF};

// Best-first row-id lists from each retriever.
let dense = vec!["doc-2", "doc-1", "doc-5"];   // ANN cosine order
let lexical = vec!["doc-1", "doc-2", "doc-9"]; // BM25 order

let fused = rrf_fuse(&[dense, lexical], DEFAULT_K_RRF);
for hit in &fused {
    println!("{} rrf={:.4}", hit.row_id, hit.rrf_score);
}
}

Rows that both retrievers surface rise to the top — cross-list agreement is exactly what RRF rewards. The output is fully deterministic: it is sorted by fused score descending, ties broken ascending by row_id, and it does not depend on the order you pass the lists in. A row repeated within a single list counts only once, at its best rank.

k_rrf is exposed, not forced. Larger values flatten the gap between adjacent ranks (a deep-but-agreed-upon row matters more); smaller values sharpen the reward for top-of-list placement. DEFAULT_K_RRF (60) is the recommended start.

Record the evidence

BM25 contributions ride the built-in bm25 evidence channel, the lexical peer of vector’s similarity. It declares two columns — bm25_score (Float32) and bm25_rank (Int64) — and a contribution is supplied to merge_channels exactly as the vector channel’s is, so a fused result carries both its dense and its lexical provenance side by side. See Declare a Custom Provenance Channel for the contribution mechanics; bm25 needs no registration — it is seeded with the catalog.