Fix hybrid-mode scoring: log a cosine relevance score, not an RRF rank score - #146
Draft
jpr5 wants to merge 4 commits into
Draft
Fix hybrid-mode scoring: log a cosine relevance score, not an RRF rank score#146jpr5 wants to merge 4 commits into
jpr5 wants to merge 4 commits into
Conversation
ChunkResult.similarity is a per-retriever ranking score: a cosine in vector mode, a ts_rank in keyword mode, and a Reciprocal Rank Fusion score in hybrid mode. rrfMerge OVERWRITES it with the fused value, so after a hybrid search the true cosine similarity of a result is simply gone — and the RRF scale tops out at 2/(RRF_K+1) ~= 0.033, nowhere near the 0-1 scale everything downstream assumes. Add ChunkResult.cosine_similarity, populated by searchChunks, explicitly null for keyword-only hits, and carried through rrfMerge untouched. topCosineScore reduces a result set to the best cosine (or null), which is the only value that may be persisted or compared against a threshold. It lives in src/relevance.ts rather than db/queries.ts: it is a pure reducer with no DB dependency, and keeping it out of the queries module means a test that mocks db/queries.js does not silently blank it.
…in_score really gates The search and knowledge tools computed top_score as Math.max(...similarity). In hybrid mode -- which is what production runs -- that persisted an RRF rank score, so a perfect docs match logged ~0.016 against a low-confidence threshold of 0.5. Every scored query was flagged, and the dashboard's score column read 0.02 for everything. Both tools now reduce over cosine_similarity. Keyword mode consequently logs NULL rather than a ts_rank; analytics already treats a NULL score as 'no score' rather than 'a low score', so this records an honest absence instead of a number on the wrong scale. The min_score tool description claimed 'Minimum similarity score (0-1) ... below this threshold are filtered out', which is not what it does in the mode production runs: it gates only the vector candidates, before fusion, so a keyword-only match can still come back under the floor. Say so.
top_score now always holds a cosine, but rows already in query_log were written under the old mode-dependent semantics and there is no way to rescale them after the fact -- an RRF 0.0164 carries no derivable cosine. Guessing from the value would silently reinterpret history, so score_kind declares the scale instead and there is deliberately no backfill: legacy rows stay NULL and every score-based reader requires score_kind = 'cosine'. That means the low-confidence FILTER and the dashboard's score column EXCLUDE pre-change traffic rather than misreading it. Both cards start empty on the existing deployment and refill as new queries land. LOW_CONFIDENCE_SCORE_THRESHOLD is now derived from COSINE_SCORE_MAX rather than written as a bare 0.5, so it cannot drift onto a scale the metric doesn't live on. The value is unchanged. The dashboard column is relabelled Avg Cosine with a tooltip -- it renders a cosine similarity, and blank now means 'no cosine recorded', not 'zero'. The migration is an additive nullable ADD COLUMN IF NOT EXISTS, consistent with the request_source and v1.15.2 attribution columns above it.
New src/__tests__/relevance-score-scale.test.ts covers three levels: - the reducer (topCosineScore ignores rank scores, skips keyword-only rows rather than coercing them to 0, and drops a non-finite value); - the live hybrid tool -> logQuery path, driven through the REAL rrfMerge: the returned rows are still RRF-ranked, but what gets persisted is the cosine, and a 0.87 match and a 0.21 match no longer log the same number. That discrimination assertion is the regression guard -- reintroducing Math.max(...similarity) collapses both to ~0.0164 and fails it; - the SQL readers against real Postgres (PGlite): a legacy RRF-scaled row is neither counted as low-confidence nor folded into Avg Cosine, while genuine cosine rows classify correctly on either side of the threshold. It also states the arithmetic of the original bug as an assertion: the RRF ceiling is more than an order of magnitude below the threshold, so the two can never legally share a column. Mutation-checked: reverting the tool to Math.max(...similarity) and dropping the score_kind guards fails 7 of these tests. The existing search-tool test factories now set cosine_similarity the way searchChunks actually does, and the PGlite analytics seeds tag their scale the way logQuery does -- otherwise they would have quietly exercised the 'unknown scale, excluded' path instead of the intended one.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Production runs
search_mode: hybrid(deploy/copilotkit-docs.yaml), and in that modequery_log.top_scorewas being fed a Reciprocal Rank Fusion score instead of a relevance score.rrfMergeoverwritesChunkResult.similaritywith the fused rank value, whose ceiling is2/(RRF_K+1) ≈ 0.0328, and the tools then didMath.max(...results.map(r => r.similarity)).Three things broke off that one line:
LOW_CONFIDENCE_SCORE_THRESHOLD = 0.5was compared against a metric capped at 0.033, so every scored query was flagged low-confidence — a 100% false-positive rate.min_scorewas documented as "Minimum similarity score (0-1). Results below this threshold are filtered out", which is not what it does in hybrid mode: it gates only the vector candidates, before fusion, so a keyword-only hit can be returned below the floor.What changed
Scoring is a shared mechanism (tool response →
query_log→ dashboard → low-confidence flag →min_scoresemantics), so the fix names the two scales rather than patching one call site. RRF is untouched as a ranking device.ChunkResultnow carries two numbers.similaritystays the per-retriever RANKING score (cosine / ts_rank / RRF). Newcosine_similarityis the RELEVANCE score — a real cosine in [0,1] from the vector retriever, explicitlynullfor keyword-only hits — and it survivesrrfMergeuntouched.topCosineScore()(newsrc/relevance.ts) reduces a result set to the best cosine, or null. Both the search and knowledge tools log that astop_score. Keyword mode consequently logs NULL rather than a ts_rank; the analytics layer already reads NULL as "no score", never as "a low score", so this records an honest absence instead of a number on the wrong scale.LOW_CONFIDENCE_SCORE_THRESHOLDis derived —COSINE_SCORE_MAX * 0.5— so it cannot drift onto a scale the metric doesn't live on. Value unchanged at 0.5.min_score's description states what it actually gates, per mode.src/relevance.tsexists rather than parking the reducer indb/queries.tsfor two reasons: it is a pure function with no DB dependency, and several suites mockdb/queries.jswholesale — a helper living there would have been silentlyundefinedinside them.Historical
query_logrowsExisting rows hold RRF-scaled (and, in keyword mode, ts_rank-scaled) values under the old semantics. Those scales are not recoverable per-row — an RRF 0.0164 carries no derivable cosine — and inferring one from the magnitude would be exactly the silent reinterpretation to avoid.
So: a nullable
score_kindcolumn declares the scale oftop_score, and there is deliberately no backfill. New rows are tagged'cosine'(derived at the write boundary fromtop_score, so the two can never disagree); legacy rows stay NULL = "unknown scale". Both score-based readers — the low-confidence FILTER ingetAnalyticsSummaryandavg_top_scoreingetTopQueries— now requirescore_kind = 'cosine', so history is excluded rather than misread.Operator-visible consequence: on the existing deployment the low-confidence counter and the Avg Cosine column start empty and refill as new traffic lands. That is intended — an empty honest metric beats a full dishonest one. Every other card (totals, empty-result rate, latency, by-source, per-day) is unaffected.
The migration is an additive nullable
ADD COLUMN IF NOT EXISTS, matching the existingrequest_sourceand v1.15.2 attribution columns. The GREEN run below exercised it against a database created before the column existed, so the in-place upgrade path is covered.Gap Reports
I traced this before assuming it.
scripts/gap-analysis/monthly-gap-analysis.tsbuilds its LLM prompt fromtotal_queries_window,empty_result_rate_window,empty_result_count_window, and the top-queries / empty-queries clusters — it never readslow_confidence_*oravg_top_score(both are carried in the TypeScript contract incluster.ts/weekly-search-report.tsbut are not rendered). The bi-weekly Gap Reports were therefore not contaminated by the false-positive low-confidence signal and do not need a re-run. The blast radius was the/analyticsdashboard and/api/analytics/*consumers.LOCAL RED-GREEN PROOF
Real surface, not a fake: real OpenAI
text-embedding-3-smallembeddings → real PGlite+pgvector index over the actual production docs corpus (CopilotKit/showcase/shell-docs/src/content/docs, 656.mdxfiles → 2,999 chunks) → the real registered MCP search tool insearch_mode: hybridwithmin_score: 0.3(mirroringdeploy/copilotkit-docs.yamlexactly) → realhybridSearchChunks/rrfMerge→ reallogQuery→ realquery_log→ realgetAnalyticsSummary/getTopQueries.Identical index, identical queries, identical harness across both runs.
true top cosineis computed independently of the tool and is the ground truth the metric should report.RED (before the fix)
An excellent match (
useCopilotAction, true cosine 0.595) logs 0.01639 and is flagged low-confidence. So does a mediocre one, and so does a genuinely poor one — all three log the identical value. The metric carries zero information, and 100% of scored queries are flagged.GREEN (after the fix, same index, same queries)
The logged score now tracks ground truth to five decimals and discriminates: 0.60 / 0.69 for the good matches (not flagged) vs 0.50 for the off-topic one (flagged). Low-confidence rate drops from a meaningless 75% to a defensible 25% — the one query that genuinely has no good answer in the corpus. The dashboard column shows three distinct, interpretable numbers instead of three identical 0.02s.
Regression tests
src/__tests__/relevance-score-scale.test.ts(17 tests) pins the invariant at three levels: the reducer, the live hybrid tool →logQuerypath driven through the realrrfMerge, and the SQL readers against real Postgres via PGlite.The load-bearing one is "DISCRIMINATES between an excellent match and a poor one": reintroducing
Math.max(...similarity)collapses a 0.87 match and a 0.21 match to the same ~0.0164 and fails on both the inequality and the classification. There is also an explicit scale assertion — the RRF ceiling must be more than an order of magnitude below the threshold — which states the arithmetic of this bug as a test.Mutation-checked: reverting the tool to
Math.max(...similarity)and dropping thescore_kindguards fails 7 of these tests. They are not vacuous.Local gate
prettier --check·tsc --noEmit·tsc --noEmit -p tsconfig.scripts.json·check-version-sync.sh·npm test(3573 passed, 1 skipped, 179 files) ·npm run build— all green.🤖 Generated with Claude Code
https://claude.ai/code/session_01WX4mtASFe6K4TnJBhRhMNE