fix(search): debug ranks match fusion's filtered set; label trigram hits

lex_rank/sem_rank were computed from the raw pre-filter lex/sem dicts, so a
chunk removed by the type/tag/state/time filter still shifted the reported
rank of the chunks that remained -- debug reported rank 2 where fusion had
actually used rank 1. Compute ranks from the same post-filter subset _fuse
consumes.

A chunk that matched only via the trigram substring pass was reported with a
"bm25" field holding bm25(chunks_tri) * -0.5 -- a different number from a
different table, indistinguishable from a real chunks_fts bm25. Track the
source table per chunk and report it under "tri_bm25" with "via": "tri"
instead. The eval harness tunes on these numbers.

Test: tests/test_search.py::test_debug_ranks_reflect_the_filtered_set_not_the_raw_channel,
      tests/test_search.py::test_trigram_only_hit_is_labelled_as_trigram_not_chunks_fts_bm25
This commit is contained in:
Markus committed 2026-09-12 18:37:44 +00:00
1 parent 7cfaeb3a02
commit ea6f1b5275
2 files changed
+69 -9

No files matched your search

+36 -9
View File
@@ -119,6 +119,10 @@ def search(
# 1. lexical -----------------------------------------------------------
lex: dict[int, float] = {}
# Which table actually produced each lex[cid] -- "fts" (chunks_fts) or
# "tri" (chunks_tri, at a discounted weight). Needed so the debug payload
# never reports a trigram-derived number under the `bm25` (chunks_fts) key.
lex_source: dict[int, str] = {}
match = to_match(query) if "lex" in channels else None
if match:
w = settings.bm25_weights
@@ -129,6 +133,7 @@ def search(
).fetchall()
for r in rows:
lex[r["cid"]] = -float(r["s"]) # bm25() is negative; larger is better here
lex_source[r["cid"]] = "fts"
if settings.fts_trigram and "lex" in channels:
tri = to_trigram_match(query)
if tri and _has_table(conn, "chunks_tri"):
@@ -138,7 +143,11 @@ def search(
(tri, LEX_N),
).fetchall()
for r in rows:
lex.setdefault(r["cid"], -float(r["s"]) * 0.5)
cid = r["cid"]
if cid in lex:
continue
lex[cid] = -float(r["s"]) * 0.5
lex_source[cid] = "tri"
# 2. dense -------------------------------------------------------------
sem: dict[int, float] = {}
@@ -203,9 +212,11 @@ def search(
# 4. fuse at chunk level ------------------------------------------------
alive = {r["cid"] for v in kept.values() for r in v["chunks"]}
lex_alive = {c: s for c, s in lex.items() if c in alive}
sem_alive = {c: s for c, s in sem.items() if c in alive}
scores = _fuse(
{c: s for c, s in lex.items() if c in alive},
{c: s for c, s in sem.items() if c in alive},
lex_alive,
sem_alive,
fusion=fusion,
alpha=cc_alpha,
)
@@ -217,8 +228,12 @@ def search(
if requested > available > 0:
scale = requested / available
scores = {cid: score * scale for cid, score in scores.items()}
lex_rank = _ranks(lex)
sem_rank = _ranks(sem)
# Ranks reported in debug must match what `_fuse` actually ranked over
# (the post-filter subset), not the raw pre-filter channel -- otherwise a
# chunk filtered out of contention still shifts the reported rank of the
# chunks that remain.
lex_rank = _ranks(lex_alive)
sem_rank = _ranks(sem_alive)
# 5. aggregate to notes -------------------------------------------------
out: list[SearchResult] = []
@@ -233,16 +248,28 @@ def search(
score = best * (inbox_weight if state == "inbox" else 1.0)
dbg = None
if debug:
source = lex_source.get(best_cid)
in_lex = best_cid in lex
in_sem = best_cid in sem
if in_lex and in_sem:
via = "both"
elif in_lex:
via = source or "lex"
else:
via = "sem"
dbg = {
"chunk_id": best_cid,
"lex_rank": lex_rank.get(best_cid),
"sem_rank": sem_rank.get(best_cid),
"bm25": round(-lex[best_cid], 4) if best_cid in lex else None,
# Only chunks_fts's own bm25 is reported under "bm25" -- a
# trigram-only hit gets "tri_bm25" instead (undoing the *0.5
# discount to report the real bm25(chunks_tri) value), never a
# number that looks like it came from chunks_fts.
"bm25": round(lex[best_cid], 4) if source == "fts" else None,
"tri_bm25": round(lex[best_cid] * 2, 4) if source == "tri" else None,
"cos": round(sem[best_cid], 4) if best_cid in sem else None,
"fused": round(best, 6),
"via": "both"
if best_cid in lex and best_cid in sem
else ("lex" if best_cid in lex else "sem"),
"via": via,
"heading": row["heading"],
}
out.append(
+33
View File
@@ -120,6 +120,39 @@ def test_rrf_formula_is_pinned():
assert scores[2] == pytest.approx(1 / 62)
async def test_debug_ranks_reflect_the_filtered_set_not_the_raw_channel(svc):
"""lex_rank/sem_rank must match the ranking `_fuse` actually used (over the
post-filter subset), not the ranking over the unfiltered lex/sem dicts."""
# "Bifrost" repeated in the title drives up its chunks_fts title-weighted
# bm25 score, so `a` outranks `b` in the *raw* lexical channel -- but `a`
# is filtered out by types=["fact"], so among the results `b` actually is
# the lexical rank 1.
await _add(svc, title="Bifrost Bifrost Bifrost Gateway", body="Bifrost overall", type="procedure")
b = await _add(svc, title="Bifrost Gateway", body="Bifrost fronts models", type="fact")
r = await svc.search("Bifrost", types=["fact"], debug=True)
assert [x.id for x in r.results] == [b]
assert r.results[0].debug["lex_rank"] == 1
async def test_trigram_only_hit_is_labelled_as_trigram_not_chunks_fts_bm25(svc):
"""A chunk that entered only via the trigram substring pass must not be
reported with a `bm25` field claiming to be chunks_fts's bm25 -- that
number is bm25(chunks_tri) * -0.5, from a different table entirely."""
a = await _add(svc, title="Bifrost", body="ein logfilexyz123abc taucht auf", type="fact")
# "xyz123" is a substring of the compound token "logfilexyz123abc": whole-word
# FTS (chunks_fts) cannot match it, only the trigram channel can. channels=
# ("lex",) isolates the lexical side: with only one note in the vault, dense
# KNN would otherwise always return it too (there is nothing else to rank
# against), which would obscure whether the *lexical* hit came from fts or
# trigram behind a generic "both".
r = await svc.search("xyz123", channels=("lex",), debug=True)
assert r.results and r.results[0].id == a
dbg = r.results[0].debug
assert dbg["via"] == "tri"
assert dbg["bm25"] is None
assert dbg["tri_bm25"] is not None
async def test_inbox_results_are_penalised_and_unverified(svc):
active = await _add(svc, title="Gleicher Text hier", body="Bifrost Gateway", type="fact")
inbox = await _add(svc, title="Gleicher Text hier", body="Bifrost Gateway", type="fact", state="inbox")