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:
1 parent
7cfaeb3a02
commit
ea6f1b5275
2 files changed
+69
-9
No files matched your search
+36
-9
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user