-
Notifications
You must be signed in to change notification settings - Fork 0
eval(retrieval): add content_mrr@10 passage-rank metric #248
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fcc931a
1f56972
b4f2734
927a6ee
a1a6b7f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,6 +53,7 @@ export type GoldenRetrievalResult = { | |
| hitAtK: boolean; | ||
| topK: number; | ||
| reciprocalRankAt10: number; | ||
| contentReciprocalRankAt10: number; | ||
| latencyMs: number; | ||
| retrievalStrategy: string | null; | ||
| retrievalPlan: string | null; | ||
|
|
@@ -276,6 +277,20 @@ function resultDocumentEvidenceText(result: SearchResult) { | |
| } | ||
|
|
||
| function resultContentText(result: SearchResult) { | ||
| return normalized( | ||
| [ | ||
| result.title, | ||
| result.file_name, | ||
| result.section_heading, | ||
| result.section_path?.join(" "), | ||
| resultContentEvidenceText(result), | ||
| ] | ||
| .filter(Boolean) | ||
| .join(" "), | ||
| ); | ||
| } | ||
|
|
||
| function resultContentEvidenceText(result: SearchResult) { | ||
| const tableFactText = (result.table_facts ?? []) | ||
| .map((fact) => | ||
| [fact.table_title, fact.row_label, fact.clinical_parameter, fact.threshold_value, fact.action] | ||
|
|
@@ -285,22 +300,26 @@ function resultContentText(result: SearchResult) { | |
| .join(" "); | ||
| const imageText = (result.images ?? []) | ||
| .map((image) => | ||
| [image.caption, image.tableTitle, image.tableLabel, image.tableTextSnippet].filter(Boolean).join(" "), | ||
| [ | ||
| image.caption, | ||
| image.tableTitle, | ||
| image.tableLabel, | ||
| image.tableTextSnippet, | ||
| image.accessibleTableMarkdown, | ||
| (image.tableRows ?? []).flat().join(" "), | ||
| ] | ||
| .filter(Boolean) | ||
| .join(" "), | ||
| ) | ||
| .join(" "); | ||
| // First-class table/visual evidence: when a hit carries the answer in a typed index unit | ||
| // (medication-chart row, risk-matrix cell, flowchart step, …) it is not in `content`, so | ||
| // include it here — otherwise contentReciprocalRankAt10 would miss table/visual answers. | ||
| const indexUnitText = result.index_unit | ||
| ? [result.index_unit.title, result.index_unit.content].filter(Boolean).join(" ") | ||
| : ""; | ||
| return normalized( | ||
| [ | ||
| result.title, | ||
| result.file_name, | ||
| result.section_heading, | ||
| result.section_path?.join(" "), | ||
| result.retrieval_synopsis, | ||
| result.content, | ||
| tableFactText, | ||
| imageText, | ||
| ] | ||
| .filter(Boolean) | ||
| .join(" "), | ||
| [result.retrieval_synopsis, result.content, tableFactText, imageText, indexUnitText].filter(Boolean).join(" "), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a table hit carries the answer only in Useful? React with 👍 / 👎. |
||
| ); | ||
| } | ||
|
|
||
|
|
@@ -344,6 +363,35 @@ function reciprocalRankAt10(expectedSubstrings: string[], results: SearchResult[ | |
| return index >= 0 ? 1 / (index + 1) : 0; | ||
| } | ||
|
|
||
| /** | ||
| * Passage-level rank quality ("best-passage rank"): the mean over expected content terms of | ||
| * the reciprocal rank of the earliest top-10 result whose content carries that term. Where | ||
| * `contentRecallAt5` only asks *whether* an answer-bearing passage appears in the top 5, this | ||
| * asks *how high* it ranks — so a rerank/chunking change that lifts the right passage from #5 | ||
| * to #1 is measurable (content recall stays 1.0 across that move; doc-level `mrr@10` is blind | ||
| * to passage order within a document). 1.0 iff every expected term rides the rank-1 passage. | ||
| * Cases with no expected content terms return 0 and are excluded from the summary average. | ||
| */ | ||
| function contentReciprocalRankAt10( | ||
| expectedTerms: GoldenRetrievalCase["expectedContentTerms"], | ||
| results: SearchResult[], | ||
| ) { | ||
| if (expectedTerms.length === 0) return 0; | ||
| const top = results.slice(0, 10); | ||
|
BigSimmo marked this conversation as resolved.
|
||
| const total = expectedTerms.reduce((sum, expectation) => { | ||
| const alternatives = contentExpectationAlternatives(expectation); | ||
| const index = top.findIndex((result) => | ||
| alternatives.some((term) => textContainsClinicalTerm(resultContentEvidenceText(result), term)), | ||
| ); | ||
| return sum + (index >= 0 ? 1 / (index + 1) : 0); | ||
| }, 0); | ||
| return total / expectedTerms.length; | ||
| } | ||
|
|
||
| export function retrievalLimitForGoldenCase(testCase: GoldenRetrievalCase) { | ||
| return Math.max(testCase.topK, 10); | ||
|
Comment on lines
+391
to
+392
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Captured RAG eval cases are converted with Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| function hasTableEvidence(results: SearchResult[], limit = 5) { | ||
| return results.slice(0, limit).some((result) => { | ||
| if ((result.table_facts?.length ?? 0) > 0) return true; | ||
|
|
@@ -490,6 +538,7 @@ export function evaluateGoldenRetrievalCase(args: { | |
| hitAtK, | ||
| topK, | ||
| reciprocalRankAt10: reciprocalRankAt10(args.testCase.expectedDocumentSubstrings, args.results), | ||
| contentReciprocalRankAt10: contentReciprocalRankAt10(args.testCase.expectedContentTerms, args.results), | ||
| latencyMs: args.latencyMs, | ||
| retrievalStrategy: args.telemetry.retrieval_strategy ?? null, | ||
| retrievalPlan: args.telemetry.retrieval_plan ?? null, | ||
|
|
@@ -520,6 +569,9 @@ export function evaluateGoldenRetrievalCase(args: { | |
| export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[]) { | ||
| const documentRecallDenominator = Math.max(results.length, 1); | ||
| const contentRecallDenominator = Math.max(results.length, 1); | ||
| // Passage-rank quality is only meaningful for cases that declare answer-bearing content | ||
| // terms; averaging over cases without them would dilute the signal with structural zeros. | ||
| const contentRankCases = results.filter((result) => result.expectedContentTerms.length > 0); | ||
| const strategyCounts = results.reduce<Record<string, number>>((counts, result) => { | ||
| const strategy = result.retrievalStrategy ?? "none"; | ||
| counts[strategy] = (counts[strategy] ?? 0) + 1; | ||
|
|
@@ -562,6 +614,13 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[] | |
| mrr_at_10: Number( | ||
| (results.reduce((sum, result) => sum + result.reciprocalRankAt10, 0) / Math.max(results.length, 1)).toFixed(4), | ||
| ), | ||
| content_mrr_at_10: Number( | ||
| ( | ||
| contentRankCases.reduce((sum, result) => sum + result.contentReciprocalRankAt10, 0) / | ||
| Math.max(contentRankCases.length, 1) | ||
| ).toFixed(4), | ||
|
BigSimmo marked this conversation as resolved.
|
||
| ), | ||
| content_mrr_case_count: contentRankCases.length, | ||
| median_latency_ms: percentile( | ||
| results.map((result) => result.latencyMs), | ||
| 50, | ||
|
|
@@ -646,6 +705,9 @@ function printHumanSummary(summary: ReturnType<typeof summarizeGoldenRetrievalRe | |
| console.log(` content_recall@5=${summary.content_recall_at_5}`); | ||
| console.log(` top_k_hit_rate=${summary.top_k_hit_rate}`); | ||
| console.log(` mrr@10=${summary.mrr_at_10}`); | ||
| console.log( | ||
| ` content_mrr@10=${summary.content_mrr_at_10} (over ${summary.content_mrr_case_count} content-term case(s))`, | ||
| ); | ||
| console.log(` median_latency_ms=${summary.median_latency_ms}`); | ||
| console.log(` p90_latency_ms=${summary.p90_latency_ms}`); | ||
| console.log(` retrieval_strategy_counts=${JSON.stringify(summary.retrieval_strategy_counts)}`); | ||
|
|
@@ -740,7 +802,7 @@ async function main() { | |
| searchChunksWithTelemetry({ | ||
| query: testCase.query, | ||
| ownerId, | ||
| topK: testCase.topK, | ||
| topK: retrievalLimitForGoldenCase(testCase), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| minSimilarity: 0.12, | ||
| skipCache: args.mode !== "latency", | ||
| }), | ||
|
|
@@ -787,7 +849,7 @@ async function main() { | |
| ? "FAIL" | ||
| : "PASS"; | ||
| console.log( | ||
| `${status} ${result.id} hit@${result.topK}=${result.hitAtK ? "1" : "0"} docRecall@5=${result.documentRecallAt5.toFixed(2)} contentRecall@5=${result.contentRecallAt5.toFixed(2)} rr@10=${result.reciprocalRankAt10.toFixed(2)} latency=${result.latencyMs}ms strategy=${result.retrievalStrategy ?? "none"} gate=${result.coverageGateReason ?? "none"} layers=${JSON.stringify(result.retrievalLayerCounts ?? {})}`, | ||
| `${status} ${result.id} hit@${result.topK}=${result.hitAtK ? "1" : "0"} docRecall@5=${result.documentRecallAt5.toFixed(2)} contentRecall@5=${result.contentRecallAt5.toFixed(2)} rr@10=${result.reciprocalRankAt10.toFixed(2)} contentRR@10=${result.contentReciprocalRankAt10.toFixed(2)} latency=${result.latencyMs}ms strategy=${result.retrievalStrategy ?? "none"} gate=${result.coverageGateReason ?? "none"} layers=${JSON.stringify(result.retrievalLayerCounts ?? {})}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For memory-boosted results, downstream answer/source paths use
result.memory_cards[*].contentas structured evidence, but the new content-rank evidence text omits it. In broad-summary or comparison cases where the top chunk carries an expected term only through an attached memory card,contentReciprocalRankAt10records a miss even though the answer path has usable evidence, creating false passage-rank regressions. Include memory-card content in this evidence text alongside table, visual, and index-unit content.Useful? React with 👍 / 👎.