Files
gbrain/src/commands/eval.ts
T
d547a64600 feat: search quality boost — compiled truth ranking + detail parameter (v0.8.1) (#64)
* feat: search quality boost — compiled truth ranking, detail parameter, cosine re-scoring

Compiled truth chunks now rank 2x higher in hybrid search via RRF
normalization + source boost. New --detail flag (low/medium/high)
controls timeline inclusion. Cosine re-scoring blends query-chunk
similarity before dedup for query-specific ranking.

Also: remove DISTINCT ON from keyword search (dedup handles per-page
capping), add chunk_id + chunk_index to SearchResult, add
getEmbeddingsByChunkIds to BrainEngine interface.

Inspired by Ramp Labs' "Latent Briefing" paper (April 2026).

* feat: RRF normalization, source-aware dedup, detail param in operations

RRF scores normalized to 0-1 before 2.0x compiled truth boost.
Source-aware dedup guarantees compiled truth chunk per page.
Detail parameter added to query operation, dedupResults added to
bare search operation. Debug logging via GBRAIN_SEARCH_DEBUG=1.

* chore: bump version and changelog (v0.8.1)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: CJK word count in query expansion

CJK text is not space-delimited. A query like "向量搜索优化" was counted
as 1 word and silently skipped expansion. Now counts characters for CJK
queries instead of space-separated tokens.

Co-Authored-By: YIING99 <yiing99@users.noreply.github.com>

* feat: retrieval evaluation harness — P@k, R@k, MRR, nDCG@k + gbrain eval

Full IR evaluation framework: precisionAtK, recallAtK, mrr, ndcgAtK
metrics with runEval() orchestrator. gbrain eval CLI with single-run
table and A/B comparison mode (--config-a / --config-b) for parameter
tuning. HybridSearchOpts now accepts rrfK and dedupOpts overrides.

Co-Authored-By: 4shut0sh <4shut0sh@users.noreply.github.com>

* test: search quality tests — RRF boost, dedup guarantee, cosine similarity, E2E benchmark

42 new tests across 3 files:
- test/search.test.ts: RRF normalization, compiled truth 2x boost, dedup key
  collision prevention, cosine similarity edge cases, CJK word count detection
- test/dedup.test.ts: source-aware compiled truth guarantee, layer interactions,
  custom maxPerPage, empty/single result edge cases
- test/e2e/search-quality.test.ts: full pipeline against PGLite with basis vector
  embeddings — chunk_id/chunk_index fields, detail parameter filtering,
  getEmbeddingsByChunkIds, keyword multi-chunk, vector ordering

Also: export rrfFusion + cosineSimilarity for unit testing, fix PGLite
getEmbeddingsByChunkIds to parse string vectors from pgvector.

* test: search quality benchmark with A/B comparison (baseline vs PR#64)

Benchmark measures P@1, MRR, nDCG@5, and source accuracy across 8 queries
against 5 seeded pages. Key finding: boost helps entity lookups but
over-corrects temporal queries. Validates the --detail parameter as the
right control mechanism. Output at docs/benchmarks/2026-04-13.md.

* feat: query intent classifier — auto-selects detail level, 100% source accuracy

Zero-latency heuristic classifier detects query intent from text patterns:
- "Who is Pedro?" → entity → detail=low (compiled truth only)
- "When did we last meet?" → temporal → detail=high (no boost, natural ranking)
- "Variant fund announcement" → event → detail=high
- General queries → detail=medium (default with boost)

The key insight: skip the 2.0x compiled truth boost for detail=high queries.
Temporal/event queries want natural ranking where timeline entries can win.

Benchmark results (source accuracy = does the top chunk match expected type):
- Baseline: 100% (already good, no boost needed)
- Boost only: 71.4% (boost over-corrects temporal queries)
- Boost + intent classifier: 100% (best of both worlds)

35 unit tests for the classifier. 590 total tests pass.

* feat: query intent classifier — auto-selects detail level, 100% source accuracy

Heuristic classifier detects query intent from text patterns (zero latency,
no LLM call). Maps temporal queries ("when did we last meet") to detail=high,
entity queries ("who is X") to detail=low, events to detail=high.

Benchmark results (29 pages, 20 queries, graded relevance):
- Baseline: P@1=0.947, MRR=0.974, source accuracy=89.5%
- Boost only: P@1=0.895, MRR=0.939, source accuracy=63.2% (over-correction)
- Boost + intent: P@1=0.947, MRR=0.974, source accuracy=89.5% (fully recovered)

The intent classifier eliminates the boost's over-correction on temporal queries
while preserving its benefits for entity lookups. 35 unit tests for the classifier.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: search quality benchmark with A/B comparison (baseline vs PR#64)

Rich benchmark: 29 pages, 58 chunks, 20 queries with graded relevance.
Now measures CHUNK-LEVEL quality, not just page-level retrieval.

Key findings (C. Boost+Intent vs A. Baseline):
- Unique pages in top-10: 7.2 → 8.7 (+21% broader coverage)
- Compiled truth ratio: 51.6% → 66.8% (+15pp more signal)
- CT-first rate: 100% (compiled truth leads for entity queries)
- Timeline accessible: 100% (temporal queries still find dates)
- Source accuracy: 89.5% maintained (intent classifier prevents regression)

The boost alone (B) causes -26pp source accuracy regression.
Intent classifier (C) recovers it fully.

* docs: clean benchmark report — ELI10 search quality analysis for PR#64

Replaces two drafts with one clean report. Explains what changed, why it
matters, and what the numbers mean. All fictional data, no private info.

Key findings: 21% more page coverage per query, 29% more compiled truth
in results. Intent classifier prevents boost from burying timeline for
temporal queries. Full per-query breakdown with before/after comparison.

* chore: remove auto-generated benchmark file (clean version is 2026-04-14-search-quality.md)

* docs: update project documentation for search quality boost

CLAUDE.md: added search/intent.ts, search/eval.ts, commands/eval.ts to key
files. Added 5 new test files (search, dedup, intent, eval, e2e/search-quality).
Updated test count from 23+4 to 28+5. Added docs/benchmarks/ to key files.

README.md: updated search pipeline diagram with intent classifier, RRF
normalization, compiled truth boost, cosine re-scoring, and 5-layer dedup.
Added --detail flag explanation and benchmark instructions.

CHANGELOG.md: added search quality entries to v0.9.3 (intent classifier,
--detail flag, gbrain eval, CJK fix). Credited @4shut0sh and @YIING99.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: headline benchmark gains in changelog

* docs: add community attribution rule to CHANGELOG voice section

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: YIING99 <yiing99@users.noreply.github.com>
Co-authored-by: 4shut0sh <4shut0sh@users.noreply.github.com>
2026-04-13 21:03:40 -10:00

334 lines
12 KiB
TypeScript

/**
* gbrain eval — Retrieval Evaluation Command
*
* Runs search quality benchmarks against user-defined ground truth (qrels).
* Supports single-config runs and A/B comparison mode for tuning parameters.
*
* Usage:
* gbrain eval --qrels <path|json>
* gbrain eval --qrels <path> --config-a <path|json> --config-b <path|json>
* gbrain eval --qrels <path> --strategy hybrid --rrf-k 30 --k 5
*/
import { readFileSync, existsSync } from 'fs';
import type { BrainEngine } from '../core/engine.ts';
import {
runEval,
parseQrels,
type EvalConfig,
type EvalReport,
type QueryResult,
} from '../core/search/eval.ts';
export async function runEvalCommand(engine: BrainEngine, args: string[]): Promise<void> {
const opts = parseArgs(args);
if (opts.help) {
printHelp();
return;
}
if (!opts.qrels) {
console.error('Error: --qrels <path|json> is required\n');
printHelp();
process.exit(1);
}
let qrels;
try {
qrels = parseQrels(opts.qrels);
} catch (err: any) {
console.error(`Error loading qrels: ${err.message}`);
process.exit(1);
}
if (qrels.length === 0) {
console.error('Error: qrels file contains no queries');
process.exit(1);
}
const k = opts.k ?? 5;
const configA = buildConfig(opts, 'a');
if (opts.configB || opts.configBPath) {
// A/B comparison mode
const configB = buildConfig(opts, 'b');
const [reportA, reportB] = await Promise.all([
runEval(engine, qrels, configA, k),
runEval(engine, qrels, configB, k),
]);
printABTable(reportA, reportB, k);
} else {
// Single-run mode
const report = await runEval(engine, qrels, configA, k);
printSingleTable(report);
}
}
// ─────────────────────────────────────────────────────────────────
// Argument parsing
// ─────────────────────────────────────────────────────────────────
interface ParsedArgs {
help: boolean;
qrels?: string;
configAPath?: string;
configBPath?: string;
configB?: EvalConfig;
strategy?: EvalConfig['strategy'];
rrfK?: number;
expand?: boolean;
dedupCosine?: number;
dedupTypeRatio?: number;
dedupMaxPerPage?: number;
limit?: number;
k?: number;
}
function parseArgs(args: string[]): ParsedArgs {
const opts: ParsedArgs = { help: false };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
switch (arg) {
case '--help': case '-h': opts.help = true; break;
case '--qrels': opts.qrels = next; i++; break;
case '--config-a': opts.configAPath = next; i++; break;
case '--config-b': opts.configBPath = next; i++; break;
case '--strategy': opts.strategy = next as EvalConfig['strategy']; i++; break;
case '--rrf-k': opts.rrfK = parseInt(next, 10); i++; break;
case '--expand': opts.expand = true; break;
case '--no-expand': opts.expand = false; break;
case '--dedup-cosine': opts.dedupCosine = parseFloat(next); i++; break;
case '--dedup-type-ratio': opts.dedupTypeRatio = parseFloat(next); i++; break;
case '--dedup-max-per-page': opts.dedupMaxPerPage = parseInt(next, 10); i++; break;
case '--limit': opts.limit = parseInt(next, 10); i++; break;
case '--k': opts.k = parseInt(next, 10); i++; break;
}
}
return opts;
}
function buildConfig(opts: ParsedArgs, side: 'a' | 'b'): EvalConfig {
const pathOpt = side === 'a' ? opts.configAPath : opts.configBPath;
// Start from file or inline JSON if provided
let base: EvalConfig = {};
if (pathOpt) {
base = loadConfigFile(pathOpt);
}
// CLI flags override config file (only for side A — side B comes entirely from its config file)
if (side === 'a') {
if (opts.strategy !== undefined) base.strategy = opts.strategy;
if (opts.rrfK !== undefined) base.rrf_k = opts.rrfK;
if (opts.expand !== undefined) base.expand = opts.expand;
if (opts.dedupCosine !== undefined) base.dedup_cosine_threshold = opts.dedupCosine;
if (opts.dedupTypeRatio !== undefined) base.dedup_type_ratio = opts.dedupTypeRatio;
if (opts.dedupMaxPerPage !== undefined) base.dedup_max_per_page = opts.dedupMaxPerPage;
if (opts.limit !== undefined) base.limit = opts.limit;
// Defaults for side A
if (!base.name) base.name = 'Config A';
if (!base.strategy) base.strategy = 'hybrid';
} else {
if (!base.name) base.name = 'Config B';
if (!base.strategy) base.strategy = 'hybrid';
}
return base;
}
function loadConfigFile(pathOrJson: string): EvalConfig {
const trimmed = pathOrJson.trimStart();
if (trimmed.startsWith('{')) {
return JSON.parse(pathOrJson) as EvalConfig;
}
if (!existsSync(pathOrJson)) {
console.error(`Config file not found: ${pathOrJson}`);
process.exit(1);
}
return JSON.parse(readFileSync(pathOrJson, 'utf-8')) as EvalConfig;
}
// ─────────────────────────────────────────────────────────────────
// Output formatting
// ─────────────────────────────────────────────────────────────────
function printSingleTable(report: EvalReport): void {
const { config, k, queries } = report;
const label = config.name ?? config.strategy ?? 'hybrid';
console.log(`\ngbrain eval — ${queries.length} quer${queries.length === 1 ? 'y' : 'ies'} · strategy: ${label} · k=${k}\n`);
const COL_QUERY = 36;
const COL_NUM = 7;
const header = padR('Query', COL_QUERY) + padL(`P@${k}`, COL_NUM) + padL(`R@${k}`, COL_NUM) + padL('MRR', COL_NUM) + padL(`nDCG@${k}`, COL_NUM);
const divider = '─'.repeat(header.length);
console.log(header);
console.log(divider);
for (const q of queries) {
console.log(
padR(truncate(q.query, COL_QUERY - 1), COL_QUERY) +
padL(fmt(q.precision_at_k), COL_NUM) +
padL(fmt(q.recall_at_k), COL_NUM) +
padL(fmt(q.mrr), COL_NUM) +
padL(fmt(q.ndcg_at_k), COL_NUM),
);
}
console.log(divider);
console.log(
padR('Mean', COL_QUERY) +
padL(fmt(report.mean_precision), COL_NUM) +
padL(fmt(report.mean_recall), COL_NUM) +
padL(fmt(report.mean_mrr), COL_NUM) +
padL(fmt(report.mean_ndcg), COL_NUM),
);
console.log('');
}
function printABTable(reportA: EvalReport, reportB: EvalReport, k: number): void {
const labelA = reportA.config.name ?? 'Config A';
const labelB = reportB.config.name ?? 'Config B';
const n = reportA.queries.length;
console.log(`\ngbrain eval — ${n} quer${n === 1 ? 'y' : 'ies'} · A/B comparison · k=${k}\n`);
const COL_QUERY = 34;
const COL_METRIC = 8;
const COLS_PER_SIDE = 3; // P@k, MRR, nDCG@k
// Header line 1: section labels
const aLabel = ` ${labelA} `.slice(0, COL_METRIC * COLS_PER_SIDE - 2);
const bLabel = ` ${labelB} `.slice(0, COL_METRIC * COLS_PER_SIDE - 2);
const line1 =
' '.repeat(COL_QUERY) +
padR(`── ${aLabel} `, COL_METRIC * COLS_PER_SIDE) +
padR(`── ${bLabel} `, COL_METRIC * COLS_PER_SIDE) +
` Δ nDCG`;
console.log(line1);
// Header line 2: metric names
const metricHeader = (suffix: string) =>
padL(`P@${k}`, COL_METRIC) + padL('MRR', COL_METRIC) + padL(`nDCG@${k}`, COL_METRIC);
const line2 =
padR('Query', COL_QUERY) +
metricHeader('A') +
' ' + metricHeader('B') +
' ' + padL('Δ nDCG', 10);
console.log(line2);
console.log('─'.repeat(line2.length));
for (let i = 0; i < reportA.queries.length; i++) {
const qa = reportA.queries[i];
const qb = reportB.queries[i];
const delta = qb.ndcg_at_k - qa.ndcg_at_k;
const deltaStr = delta > 0 ? `+${fmt(delta)}` : fmt(delta);
console.log(
padR(truncate(qa.query, COL_QUERY - 1), COL_QUERY) +
padL(fmt(qa.precision_at_k), COL_METRIC) +
padL(fmt(qa.mrr), COL_METRIC) +
padL(fmt(qa.ndcg_at_k), COL_METRIC) +
' ' +
padL(fmt(qb.precision_at_k), COL_METRIC) +
padL(fmt(qb.mrr), COL_METRIC) +
padL(fmt(qb.ndcg_at_k), COL_METRIC) +
' ' + padL(deltaStr, 10),
);
}
const divider = '─'.repeat(line2.length);
console.log(divider);
const meanDelta = reportB.mean_ndcg - reportA.mean_ndcg;
const meanDeltaStr = (meanDelta > 0 ? '+' : '') + fmt(meanDelta);
const winner = meanDelta > 0 ? ' ✓ B wins' : meanDelta < 0 ? ' ✓ A wins' : ' tie';
console.log(
padR('Mean', COL_QUERY) +
padL(fmt(reportA.mean_precision), COL_METRIC) +
padL(fmt(reportA.mean_mrr), COL_METRIC) +
padL(fmt(reportA.mean_ndcg), COL_METRIC) +
' ' +
padL(fmt(reportB.mean_precision), COL_METRIC) +
padL(fmt(reportB.mean_mrr), COL_METRIC) +
padL(fmt(reportB.mean_ndcg), COL_METRIC) +
' ' + padL(meanDeltaStr + winner, 10),
);
console.log('');
}
// ─────────────────────────────────────────────────────────────────
// Formatting helpers
// ─────────────────────────────────────────────────────────────────
function fmt(n: number): string {
return n.toFixed(2);
}
function padR(s: string, width: number): string {
return s.length >= width ? s.slice(0, width) : s + ' '.repeat(width - s.length);
}
function padL(s: string, width: number): string {
return s.length >= width ? s.slice(0, width) : ' '.repeat(width - s.length) + s;
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + '…' : s;
}
function printHelp(): void {
console.log(`
gbrain eval — measure and compare retrieval quality
USAGE
gbrain eval --qrels <path>
gbrain eval --qrels <path> --config-a <path> --config-b <path>
OPTIONS
--qrels <path|json> Path to qrels JSON file (required)
Or inline JSON: '[{"query":"...","relevant":["slug"]}]'
--config-a <path|json> Config for strategy A (default: hybrid with defaults)
--config-b <path|json> Config for strategy B (triggers A/B mode)
--strategy <s> Search strategy: hybrid | keyword | vector
--rrf-k <n> Override RRF K constant (default: 60)
--expand / --no-expand Enable/disable multi-query expansion
--dedup-cosine <f> Override cosine dedup threshold (default: 0.85)
--dedup-type-ratio <f> Override type ratio cap (default: 0.6)
--dedup-max-per-page <n> Override max chunks per page (default: 2)
--limit <n> Max results to fetch per query (default: 10)
--k <n> Metric cutoff depth (default: 5)
QRELS FORMAT
{
"version": 1,
"queries": [
{
"query": "who founded NovaMind",
"relevant": ["people/sarah-chen", "companies/novamind"],
"grades": { "people/sarah-chen": 3, "companies/novamind": 2 }
}
]
}
"grades" is optional — enables graded nDCG. Without it, binary relevance is used.
CONFIG FORMAT
{ "name": "rrf-k-30", "strategy": "hybrid", "rrf_k": 30, "expand": false }
EXAMPLES
gbrain eval --qrels ./my-queries.json
gbrain eval --qrels ./qrels.json --strategy keyword
gbrain eval --qrels ./qrels.json --rrf-k 30
gbrain eval --qrels ./qrels.json --config-a baseline.json --config-b experiment.json
`.trim());
}