Skip to content

[#336] Hybrid lexical/semantic search (PoC) - #338

Open
Youssef-joe wants to merge 10 commits into
mnemosyne-systems:mainfrom
Youssef-joe:hybrid-search-phases
Open

Youssef-joe wants to merge 10 commits into
mnemosyne-systems:mainfrom
Youssef-joe:hybrid-search-phases

Conversation

@Youssef-joe

@Youssef-joe Youssef-joe commented Nov 13, 2026 •

Copy link
Copy Markdown

Summary

PoC for #336: add an
independently versioned lexical BM25 sidecar and fuse it with the existing semantic
ranking using plain Reciprocal Rank Fusion (k = 60), before the existing graph
expansion and token-budget cut.

This follows the Updated Design agreed in the discussion. It does not change the
symbol extraction strategy; standalone constants such as MAX_CHUNK_CHARS,
EMBED_BATCH_TOKEN_BUDGET and RRF_K are tracked as extraction coverage gaps and
excluded from the benchmark metrics.

Agreed design

  • Independently versioned lexical postings sidecar (lexical.json), next to chunks.json.
  • File discovery and SHA-256 computed once and shared with the embedding index.
  • Independent per-file processing state for each cache; a lexical file hash is recorded
    only after that file parses successfully.
  • Lexical indexing covers the full extracted Tree-sitter symbol range; the embedding
    truncation (1,600 chars) is unchanged.
  • Postings and symbol metadata only — no source text and no chunk text in the sidecar.
  • BM25 over the postings, with a code-aware tokenizer (camelCase, snake_case, ::, paths).
  • Quoted phrases: intersect term postings, then verify order/adjacency against the source;
    each candidate file is read at most once per query and only the symbol's line range is
    checked. Verification is reranking/validation, not a recall guarantee.
  • Verified exact identifier/phrase matches are promoted ahead of ordinary results.
  • RRF (k = 60) fuses the remaining lexical and semantic candidates.
  • Existing diversity penalty, graph expansion and token-budget cut preserved.
  • Lexical-only when no embeddings-capable server was detected at startup.
  • Lexical-only when embeddings fail after startup.
  • Semantic-only when lexical retrieval is unavailable but embeddings work.
  • /search and semantic_search behave identically on the same workspace.

Phases

One commit per phase; each is independently reviewable.

  • prerequisite — a71aac8 avoid UTF-8 truncation panic in embedding chunks
  • Phase 1 — a9213ac share workspace scan and hashing between search indexes
  • Phase 2 — 3de2b94 add versioned lexical postings sidecar
  • Phase 3 — 3e7fd72 add BM25 lexical-only retrieval
  • Phase 4 — cb67e00 verify quoted phrases and exact identifiers against source ranges
  • Phase 5 — 47674aa fuse lexical and semantic rankings with RRF
  • Phase 6 — b22f61a harden lexical and semantic fallback paths
  • Phase 6b — 3bc9dac bound the diversity pass to the result head
  • Phase 7 — ef17c2d add hybrid search benchmark
  • Phase 7b — 1d57789 keep lexical order among verified exact hits
  • Phase 0 — PR scaffold (this PR)

Benchmark

Fixture: src/bin/orangu-search-bench/queries.json, evaluated on this repository at
1d57789 (22,493 extracted symbols). Expectations name unique ids where a symbol name
is ambiguous, so an unrelated same-named symbol cannot satisfy a query. The phrase query
is "lexical search index could not be built" — chosen because it only exists in
production code, not in test code. 18 scored queries; 3 standalone-constant queries are
excluded as extraction coverage gaps.

metric semantic-only lexical-only hybrid
overall MRR 0.294 0.541 0.567
overall Recall@5 0.444 0.667 0.667
conceptual MRR (n=10) 0.278 0.173 0.220
conceptual Recall@5 (n=10) 0.500 0.400 0.400
exact MRR / Recall@5 (n=7) 0.357 / 0.429 1.000 / 1.000 1.000 / 1.000
phrase rank miss 1 (verified exact) 1 (verified exact)
mean query latency 85 ms 6 ms 101 ms
lexical sidecar — 43,927 KiB (42.9 MiB) —
semantic index 210 MB — —
indexing time (cold / warm) ~4.8 h / 26 s 10.8 s / 2.8 s —

Semantic and hybrid latency include one embedding request for the query; the ranking
itself is millisecond-scale after 3bc9dac. (Earlier latency figures of 230 / 276 ms
were measured under concurrent CPU load.)

Correction: an earlier version of this description reported hybrid conceptual
Recall@5 as 0.500. The run's own log shows 0.400 (4 of 10); it was a transcription error.

What the numbers say

  • Hybrid has the best overall MRR (0.567 vs lexical 0.541, semantic 0.294) and ties the
    best Recall@5.
  • On exact identifiers and the phrase query, hybrid now matches lexical-only (every
    query at rank 1) and is strictly better than semantic-only.
  • On conceptual queries hybrid sits between the two (MRR 0.220 vs semantic 0.278,
    lexical 0.173) and is below semantic on Recall@5 (0.400 vs 0.500).

The exact/phrase regression in the first run (hybrid 0.857, phrase rank 2) was an
implementation bug, fixed in 1d57789: verified exact hits were placed first but then
ordered among themselves by RRF score. RRF is rank-only, so lexical #1 and #2 differ by
~0.0003, while appearing anywhere in the semantic list adds ≥ ~0.003. For
probe_endpoint, a test that calls it was in the semantic list and outranked the
definition. Exact hits now keep the lexical (BM25) order, and RRF orders only the
remaining candidates — which is what the agreed design says.

The conceptual gap is inherent to plain RRF, which rewards presence in both lists: e.g.
"apply the semantic token budget" — semantic returns budget_cut at rank 1, hybrid at
rank 2, because another symbol sits high in lexical and low in semantic.

Decision gate

  • Hybrid ≥ semantic-only on overall MRR/Recall@5 and on conceptual queries (within an
    agreed tolerance).
  • Hybrid strictly better on exact and phrase queries.
  • Latency and sidecar size acceptable to the maintainers.
  • If plain RRF regresses conceptual queries, the numbers go back to the discussion
    rather than adding undocumented semantic/lexical weights.

Decision gate — outcome

  • Hybrid ≥ semantic-only overall — MRR 0.567 vs 0.294, Recall@5 0.667 vs 0.444.
  • Hybrid ≥ semantic-only on conceptual queries — MRR 0.220 vs 0.278, Recall@5 0.400
    vs 0.500
    .
  • Hybrid strictly better on exact queries — 1.000 vs semantic 0.357 (equal to
    lexical-only).
  • Hybrid strictly better on the phrase query — rank 1 vs semantic miss (equal to
    lexical-only).
  • Latency and sidecar size — for maintainers to judge.

Exact and phrase queries now pass within the agreed design. Conceptual queries do not,
and fixing them would need weighted RRF or query-type routing, both outside the agreed
design. Following the gate, that question goes back to #336.

Implementation notes

  • Exact hits keep the lexical order. Verified exact hits come first in the order the
    lexical ranking gave them; RRF orders only the remaining candidates. A verified hit
    keeps its exact flag if a higher-scoring graph-expanded copy of the same symbol
    replaces it.
  • Plain RRF, no weights. Equal fused scores prefer the semantic candidate, then the
    document id. This is a deterministic tie-break, not a weight.
  • Diversity stays in the semantic ranking. Lexical hits have no vectors, so the
    penalty cannot be applied after fusion without changing the design; this is the
    interpretation of "preserve the existing diversity penalty".
  • Verification is per-file-once. Candidates are grouped by file, each file is read at
    most once, and only the symbol's line range is inspected. An explicit identifier is
    matched on token boundaries, so RRF_K does not verify against RRF_K_EXTRA.
  • Fallback is path-independent. The lexical build runs off the same scan and is not
    gated on the embeddings server, so a server that disappears after startup degrades to
    BM25 instead of failing the tool.

Testing

  • cargo fmt --check — clean.
  • cargo clippy --all-targets — clean (at 1d57789).
  • cargo test --lib (at 1d57789) — 903 passed, 24 ignored.
  • Full cargo test (at the previous head) — 2,303 passed, 149 ignored, 3 failed, all in
    orangu-server, which this branch does not touch:
    • suggest::tests::format_suggestion_has_no_gpu_section_without_a_gpu — pre-existing,
      detects the host GPU.
    • workers::node::tests::a_hybrid_tree_hands_its_states_back and
      a_large_frame_crosses_tls — timing-sensitive; failed under concurrent CPU load and
      pass when re-run on their own.
  • New tests cover: scan/hash sharing, sidecar round-trip and version invalidation,
    incremental add/change/remove, full symbol range, tokenizer, BM25 ordering,
    lexical-only retrieval, phrase adjacency, identifier token boundaries, exact-match
    promotion, lexical order among exact hits, RRF tie-break, the bounded diversity pass, and both fallback paths.

Out of scope

  • Symbol extraction changes, standalone constant extraction, new configuration knobs.
  • Weighted RRF.

String::truncate panics when MAX_CHUNK_CHARS falls inside a multi-byte
character, which a real embedding run hit on files with non-ASCII comments
or string literals. Cut back to the nearest char boundary instead.
…ch indexes

Walk the workspace and compute per-file SHA-256 once per search in a shared
source module. The embedding index consumes that scan instead of performing
its own walk and hash pass; the lexical sidecar will use the same scan.
Index the full extracted Tree-sitter symbol range into a separately
versioned lexical.json sidecar next to the embedding cache. Persist
postings and symbol metadata only, keep the embedding truncation
unchanged, and update incrementally from the shared workspace scan.
A file hash is recorded only after that file parses successfully.
Rank the lexical sidecar with a code-aware tokenizer and BM25, expand
graph neighbours, and apply the existing token budget. With no
embeddings-capable server, /search and semantic_search now return ranked
lexical results instead of an error.
…gainst source ranges

Intersect posting lists to narrow candidates, group them by file, read
each file once, and verify phrase order/adjacency or identifier token
boundaries only inside the symbol's line range. Verified matches are
marked exact and ranked first. Verification is a reranking step, not a
recall guarantee.
Combine the semantic ranking (existing sketch filter and diversity penalty)
with the BM25 ranking using plain Reciprocal Rank Fusion (k = 60) before
graph expansion and the token budget. Verified exact matches are promoted
ahead of fused results; equal fused scores prefer the semantic candidate.
Keep the lexical index independent of the embeddings server: when the
embedding build or query embedding fails after startup, fall back to BM25
results instead of returning an error. When lexical retrieval is
unavailable but embeddings work, keep semantic-only behavior. Add a
regression test for the server disappearing after startup.
The diversity penalty compared every chunk with every seed selected
before it, so one query cost O(chunks^2 * dimensions). On this workspace
(~21k chunks at 768 dimensions) that is roughly six minutes per query,
which makes the benchmark — and `/search` on any repository this size —
unusable.

Only the head of the ranking can reach the result set: `search` reads at
most `top_k * 2` entries, and RRF fuses the head of the semantic ranking.
Restrict the pass to the top `DIVERSITY_CANDIDATE_LIMIT` (256) candidates.
Query time drops to milliseconds and ordering is unchanged for everything
that can actually be returned.

Found by the Phase 7 benchmark once it ran against a full-size index.
@Youssef-joe

Copy link
Copy Markdown
Author

What this branch changes, measured

All numbers below are from this repository, on the rebased branch (22,492 extracted symbols, 18 scored fixture queries; 3 standalone-constant queries excluded as extraction coverage gaps).

1. Search no longer depends on an embeddings server

/search and the semantic_search tool previously required an embeddings-capable server: no server, no search. With this branch they rank with BM25 over a sidecar and never touch the embedding path.

before after
search with no embeddings server unavailable lexical-only
embeddings die after startup request fails degrades to lexical-only
lexical index available but no embeddings unavailable semantic-only path preserved

2. Index cost — the reason the fallback matters

lexical sidecar semantic index
cold build 10.8 s 3.2 h (11,608,548 ms measured)
warm build 2.5 s cache reuse
on-disk size 43.9 MB 210 MB (4.8×)
query latency 5–6 ms see below

A user with no GPU, or a fresh clone, gets usable search in seconds instead of hours.

3. Retrieval quality — lexical-only

metric value
MRR 0.536
Recall@5 0.667
exact identifiers 7/7 at rank 1
quoted phrase rank 1, verified exact

These held constant across the rebase (+806 symbols) and across tightening the fixture so an ambiguous name can no longer satisfy a query, so they are not an artifact of easy expectations.

4. Two bugs this work exposed

Quadratic diversity pass (fixed in 3bc9dac). The pre-existing Pluribus penalty compared every chunk with every earlier seed at 768 dimensions: ~1.8×10¹¹ multiply-adds per query. Measured on the full index, the benchmark managed 10 queries in 61 minutes (366 s/query) before I stopped it; a stack sample put 100% of the main thread in semantic_ranked. This affects /search on any repository of this size, not just the benchmark. Bounding the pass to the top 256 candidates (only that head can reach the result set) restores millisecond latency.

Duplicate ids in warm lexical rebuilds (fixed in Phase 2). Term frequencies were reconstructed keyed by document id, but ids are not unique here:

duplicate ids 1,270
documents sharing an id 3,304 (14.7% of corpus)
documents that received another symbol's term frequencies 1,450
term-frequency entries affected 832,618 of 11.2M (7.4%)

So a warm rebuild silently changed BM25 weights for 6.4% of the index. Now keyed by document position, with a regression test.

5. Query formatting — observed, not changed here

"where is the embedding batch size capped" ranks the correct symbol at 11 with the plain query text the runtime sends, and at 4 with embeddinggemma's retrieval prompt (task: search result | query: …). That is an embedding-call issue, independent of fusion; I left runtime behaviour alone in this PoC and will report the semantic column both ways.

Incremental rebuild, observed live

The rebase onto main changed 158 files while a full build was in flight. On the next run the cache reused 15,046 symbols and re-embedded 7,446 (66.9% reuse), turning a 3.2 h rebuild into roughly a 1 h delta.

Pending

Semantic-only and hybrid MRR / Recall@5 / mean latency, on the same fixture. The index is at ~16,000 / 22,492 symbols and completing now; I will post the fused comparison and the decision-gate outcome as a follow-up rather than claim it early.

Note that plain RRF can regress conceptual queries when a document appears in both lists — the early (pre-fix) run showed one case where semantic scored 1.000 and hybrid 0.000 for "apply the semantic token budget". If that holds on the full index, the honest outcome is to take it back to the discussion rather than introduce weights.

@Youssef-joe
Youssef-joe marked this pull request as ready for review November 13, 2026 19:10
@Youssef-joe

Copy link
Copy Markdown
Author

Follow-up: semantic and hybrid results

The full semantic index finished, so the comparison promised above is complete. The
branch is now at 37e3157, and the description has the full tables.

metric semantic-only lexical-only hybrid
overall MRR 0.294 0.536 0.483
overall Recall@5 0.444 0.667 0.667
conceptual MRR (n=10) 0.278 0.165 0.220
exact MRR (n=7) 0.357 1.000 0.857
phrase rank miss 1 2
mean latency 230 ms 17 ms 276 ms

Gate outcome: not passed. Hybrid beats semantic-only overall. It loses to semantic
on conceptual queries and to lexical-only on exact identifiers and the phrase query.
Two causes, both visible in the --verbose trace:

  1. Verification marks a definition and a caller as equally exact. For the query
    probe_endpoint, a test that calls probe_endpoint is ranked above the definition.
  2. Plain RRF rewards appearing in both lists. A lexical rank-1 hit loses to a weaker
    hit that also appears low in the semantic list.

Question for #336: should I try the refinement for (1)? Among verified exact hits, a
symbol-name match would rank ahead of a body match. It adds no weights. If not, the
lexical sidecar and its fallback could stand on their own, with fusion left as follow-up
work.

Separately, the runtime does not send embeddinggemma's retrieval prompt
(task: search result | query: …). On one conceptual query that costs 7 ranks (11
instead of 4). I can open a separate issue for it.

Add a committed fixture and binary that compares lexical-only,
semantic-only, and hybrid ranking, reporting MRR, Recall@5, latency,
indexing time, and sidecar size. Standalone constants that the current
Rust extractor does not emit are tracked as coverage gaps. Expectations
name unique ids where a symbol name is ambiguous. --verbose prints the
lexical, semantic, and hybrid hits. Record the results in the README.
Hybrid search placed verified exact hits first but then ordered them by
their RRF score. RRF is rank-only: lexical mnemosyne-systems#1 and mnemosyne-systems#2 differ by ~0.0003,
while appearing anywhere in the semantic list adds >= ~0.003, so a test
that calls an identifier outranked its definition. Exact hits now keep
the order the lexical ranking gave them (BM25), and RRF orders only the
remaining candidates, as agreed in the design. A verified hit also keeps
its exact flag when a higher-scoring graph-expanded copy replaces it.
Update the benchmark results.
@Youssef-joe

Copy link
Copy Markdown
Author

Exact/phrase regression: cause, fix, and updated numbers

The issue

In the previous run, hybrid was worse than lexical-only on exact identifiers (MRR 0.857
vs 1.000) and on the phrase query (rank 2 vs 1). For example, the query probe_endpoint
returned probe_endpoint_times_out_when_the_configured_timeout_is_too_short (a test that
calls it) ahead of the definition.

Both symbols verify as exact, because the identifier appears inside both line ranges.
Lexical-only orders exact hits by BM25, so the definition comes first. Hybrid also put
exact hits first, but then ordered them among themselves by RRF score:

b.exact.cmp(&a.exact).then_with(|| b.score.total_cmp(&a.score)) // score = RRF

RRF is rank-only. Lexical #1 and #2 differ by 1/61 − 1/62 ≈ 0.0003, while appearing
anywhere in the semantic list adds at least 1/(60 + 256) ≈ 0.003, ten times the gap.
The calling test was in the semantic list and the definition was not, so the test won.
This was an implementation bug, not a property of the design. The design says exact
matches are prioritised and RRF fuses the remaining candidates. The code let RRF
order the exact matches too.

The fix (1d57789)

  • Verified exact hits keep the order the lexical ranking gave them. RRF orders only the
    non-exact candidates, both before and after graph expansion.
  • A verified hit keeps its exact flag when a higher-scoring graph-expanded copy of the
    same symbol replaces it.
  • No weights, k stays 60, and conceptual queries are unaffected (exact verification
    only runs for identifier-like queries and quoted phrases).
  • Regression test hybrid_search_keeps_lexical_order_among_exact_hits. It fails on the
    previous code with the caller first and passes with the fix.

Numbers at 1d57789

22,493 symbols, 18 scored queries, embeddinggemma-300M Q8_0 on CPU.

metric semantic-only lexical-only hybrid
overall MRR 0.294 0.541 0.567
overall Recall@5 0.444 0.667 0.667
conceptual MRR (n=10) 0.278 0.173 0.220
conceptual Recall@5 (n=10) 0.500 0.400 0.400
exact MRR (n=7) 0.357 1.000 1.000
phrase rank miss 1 1
mean latency 85 ms 6 ms 101 ms
  • Hybrid now has the best overall MRR, and matches lexical-only on every exact and phrase
    query.
  • Conceptual queries are unchanged by the fix. Hybrid is still below semantic-only on
    MRR and Recall@5.
  • Lexical-only moved slightly (0.536 → 0.541) only because the corpus changed (this
    branch's new code is part of the indexed repo).
  • Earlier latencies (230 / 276 ms) were measured under concurrent CPU load. These are
    from an otherwise idle machine.

Correction: my previous comment and the description reported hybrid conceptual
Recall@5 as 0.500. The run's own log shows 0.400 (4 of 10). It was a transcription
error and is fixed in the description and the bench README.

Where this leaves the gate

Exact and phrase queries now pass within the agreed design. The conceptual gap remains,
and it is inherent to plain RRF rewarding presence in both lists: in "apply the semantic token budget", semantic has budget_cut at rank 1 and hybrid at rank 2.
Closing it would need weighted RRF or query-type routing, both outside what was agreed,
so I'll bring that question back to #336.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant