Skip to content

Retrieval quality: title-aware hybrid, adaptive fusion, context expansion, and structural filtering#557

Open
tobocop2 wants to merge 12 commits into
mainfrom
feat/retrieval-consolidated
Open

Retrieval quality: title-aware hybrid, adaptive fusion, context expansion, and structural filtering#557
tobocop2 wants to merge 12 commits into
mainfrom
feat/retrieval-consolidated

Conversation

@tobocop2

@tobocop2 tobocop2 commented Jul 18, 2026

Copy link
Copy Markdown
Owner

This rolls up the retrieval-quality work into one branch so it can be reviewed and merged as a unit. Each section is a self-contained change that also has its own PR for detailed history.

Off main, make check green (100% coverage) on the combined tree. The eval/benchmark harness tooling is intentionally NOT here — it lives on its own branch (evals/retrieval-harness) so this PR stays focused on lilbee's core.

Title-aware hybrid retrieval, plus three FTS fixes (#549)

Document titles become a searchable lexical arm: every chunk carries a nullable title (extraction metadata or cleaned filename) with its own FTS index, fused as a third reciprocal-rank arm at a configurable weight. Three defects in the same hybrid-search surface are fixed alongside it:

  • Phrase queries — the FTS indexes are built with token positions, so a multi-word query (which reaches LanceDB as a phrase) matches instead of erroring.
  • Tunable lexical weightlexical_fusion_weight scales the BM25 arm relative to the vector arm.
  • FTS optimize crash — an existing, queryable FTS index is marked hybrid-ready before the best-effort optimize(), so an optimize() failure on a large corpus (a LanceDB encoding bug) logs a warning instead of silently disabling hybrid search and dropping every query to vector-only.

Context-window expansion (#550)

After retrieval, each selected chunk pulls a few adjacent same-source chunks and merges them into one contiguous passage (deduplicating the chunker's overlap), so a hit landing mid-argument keeps the sentences around it. Spends only the token budget left after the originals are fitted, and never widens table chunks.

Adaptive per-query fusion, on by default (#556)

The benchmark showed no single fixed fusion weight wins every corpus. Adaptive fusion scales the BM25 arm per query by how confident the vector arm is: a peaked dense ranking downweights the lexical arm toward zero, a flat one keeps it. On BEIR it beat the fixed equal-weight default on all three datasets, kept the significant NFCorpus and SciFact wins, and cut FiQA's regression from -0.056 to -0.018 nDCG@10. Ships on by default (adaptive_fusion=true, adaptive_fusion_margin=0.15); set adaptive_fusion=false to pin the fixed weight.

Structural-chunk filter (bb-pkn6)

A cross-corpus A/B traced a real context-precision drop to document-structure chunks entering retrieval: the title arm matches cover/title pages (they carry the doc title) and table extraction indexes tables-of-contents, and neither ever answers a question. A conservative detector drops unambiguous TOCs and cover pages from the search results (on the top_k*2 buffer so real passages remain). On by default (filter_structural_chunks); faithfulness was unaffected in the A/B, so it recovers precision without touching answer quality.

Config crash fix (#554)

Setting the data root via LILBEE_DATA_ROOT delivered it as a string and crashed path resolution (str / "documents"). The value is coerced to Path up front so the env var works like every other way of setting the root.

Not in this PR: the eval + benchmark harness

The blind retrieval eval harness and the preregistered benchmark (which validated all of the above) live on branch evals/retrieval-harness — tooling that runs against a deployed lilbee, kept out of the core so it doesn't clutter src/.

tobocop2 added 8 commits July 17, 2026 00:48
A hit that lands mid-argument loses the text before and after it. With
neighbor_expansion set, each selected chunk pulls up to N adjacent chunks
per side from its own source and merges them into one contiguous passage,
deduplicating the chunker overlap. Expansion runs after the budget fit and
spends only leftover tokens, so originals are never dropped for neighbors;
citation numbering is untouched and page/line spans are recomputed
truthfully. Off by default (neighbor_expansion=0) pending eval evidence.

bb-m3mo
The lexical index covered only chunk text, so BM25 could never match a
document's title or filename. Every chunk now carries a nullable title
(extraction metadata when available, cleaned filename stem otherwise)
with its own FTS index, queried as a third RRF arm at a configurable
weight when title_search is on (off by default pending eval evidence).
Existing chunk-FTS call sites pin fts_columns explicitly: once a second
FTS index exists, an unpinned query searches every indexed column, which
would silently widen BM25 semantics. Old indexes keep working: schemas
evolve in place and the title arm contributes nothing until reindex.
Source rows also persist extraction title/authors/created_at for future
filtering.

bb-t8pr
Setting the data root through the LILBEE_DATA_ROOT environment variable
delivered it to the config resolver as a raw string, and deriving the
documents/data/lancedb child directories with the / operator then raised
TypeError: unsupported operand type(s) for /: 'str' and 'str'. Coerce the
value to Path up front so the env var works the same as every other way
of setting the data root.
lilbee passes raw user query text to the lexical arm, so a query LanceDB
parses as a phrase (e.g. a quoted span) reaches it as a phrase query. The
chunk and title FTS indexes were created without positions, so those
queries raised and the arm silently returned nothing. Create both indexes
with_position=True. Existing indexes need a reindex to gain phrase support;
they degrade to vector-only until then rather than erroring.

bb-e4qw

(cherry picked from commit a2cd684)
Rank fusion has weighed the vector and BM25 arms equally, which dilutes a
strong dense embedder on corpora where the lexical arm adds more noise than
signal. Add lexical_fusion_weight (default 1.0, so the equal-weight behaviour
is byte-for-byte unchanged) to scale the BM25 arm's share of the fused score
down toward the dense arm. The right value is corpus-dependent and meant to be
set from the retrieval benchmark, not guessed. Wired through the config field,
its HTTP/MCP/env surfaces, the TUI settings map, and fuse_arms.
ensure_fts_index() runs table.optimize() whenever the FTS index already
exists. On a large corpus that call can hit a LanceDB encoding bug and raise
(Arrow list offset overflow), and the failure fell through to leave _fts_ready
False -- silently dropping every subsequent query to vector-only with no error
surfaced. The index itself is complete and still serves queries, so mark hybrid
ready before the best-effort optimize and downgrade an optimize() failure to a
warning. Found while benchmarking retrieval: every hybrid config collapsed to
the vector-only baseline until this was fixed.

(cherry picked from commit 6aedffb)
The retrieval benchmark showed no single lexical_fusion_weight wins every
corpus: SciFact wants a strong BM25 arm, NFCorpus a weak one, FiQA none. Rather
than fix one weight, adaptive_fusion (off by default) scales the BM25 arm per
query by how peaked the vector ranking is -- a top hit standing clear of the
field downweights lexical toward zero, a flat ranking keeps it. adaptive_fusion_
margin sets the vector-similarity margin at which the lexical arm is fully
silenced. lexical_fusion_weight becomes the ceiling the rule scales down from,
so the default path is unchanged.

(cherry picked from commit a0c2a96)
The retrieval benchmark validated adaptive per-query fusion against the fixed
lexical weight on BEIR SciFact, NFCorpus, and FiQA: at margin 0.15 it beats the
fixed w=1.0 default on all three, keeps the significant NFCorpus and SciFact
wins, and cuts the FiQA regression from -0.056 to -0.018 (the biggest gain on
the one corpus a fixed lexical arm hurt most). Default adaptive_fusion=true,
adaptive_fusion_margin=0.15; set adaptive_fusion=false to pin the fixed weight.
tobocop2 added 4 commits July 18, 2026 02:46
The UFO A/B (bb-5bjp) traced a real context-precision drop to document-structure
chunks entering retrieval: the title arm matches cover/title pages (they carry
the doc title) and table extraction indexes tables-of-contents, and neither ever
answers a question. A conservative detector drops unambiguous TOCs (dot-leaders
to page numbers) and cover pages (classification banner + short + shouting-case)
from the search results, on the top_k*2 candidate buffer so real passages remain.
On by default; filter_structural_chunks=false keeps them. Faithfulness was
unaffected in the A/B (the relevant passages were already retrieved), so this
recovers precision without touching answer quality.

(cherry picked from commit 8ecbbf8)
@tobocop2
tobocop2 force-pushed the feat/retrieval-consolidated branch from 8ecbbf8 to 495c4da Compare July 18, 2026 06:55
@tobocop2 tobocop2 changed the title Retrieval quality: title-aware hybrid, adaptive fusion, context expansion, and the eval harness Retrieval quality: title-aware hybrid, adaptive fusion, context expansion, and structural filtering Jul 18, 2026
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