Files
gbrain/test/search.test.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

193 lines
7.1 KiB
TypeScript

/**
* Search pipeline unit tests — RRF normalization, compiled truth boost,
* cosine similarity, dedup key, and CJK word count.
*/
import { describe, test, expect } from 'bun:test';
import { rrfFusion, cosineSimilarity } from '../src/core/search/hybrid.ts';
import type { SearchResult } from '../src/core/types.ts';
function makeResult(overrides: Partial<SearchResult> = {}): SearchResult {
return {
slug: 'test-page',
page_id: 1,
title: 'Test',
type: 'concept',
chunk_text: 'test chunk text',
chunk_source: 'compiled_truth',
chunk_id: 1,
chunk_index: 0,
score: 0,
stale: false,
...overrides,
};
}
describe('rrfFusion', () => {
test('normalizes scores to 0-1 range', () => {
const list: SearchResult[] = [
makeResult({ slug: 'a', chunk_id: 1, chunk_text: 'aaa' }),
makeResult({ slug: 'b', chunk_id: 2, chunk_text: 'bbb' }),
];
const results = rrfFusion([list], 60);
// Top result should have score >= 1.0 (normalized to 1.0, then boosted 2.0x for compiled_truth)
expect(results[0].score).toBe(2.0); // 1.0 * 2.0 boost
});
test('boosts compiled_truth chunks 2x over timeline', () => {
const compiledChunk = makeResult({ slug: 'a', chunk_id: 1, chunk_source: 'compiled_truth', chunk_text: 'compiled text' });
const timelineChunk = makeResult({ slug: 'b', chunk_id: 2, chunk_source: 'timeline', chunk_text: 'timeline text' });
// Put timeline first (higher rank) in the list
const results = rrfFusion([[timelineChunk, compiledChunk]], 60);
// Timeline was rank 0, compiled was rank 1
// Timeline raw: 1/(60+0) = 0.01667, compiled raw: 1/(60+1) = 0.01639
// Normalized: timeline = 1.0, compiled = 0.983
// Boosted: timeline = 1.0 * 1.0 = 1.0, compiled = 0.983 * 2.0 = 1.967
// Compiled should now rank first
expect(results[0].slug).toBe('a');
expect(results[0].chunk_source).toBe('compiled_truth');
expect(results[0].score).toBeGreaterThan(results[1].score);
});
test('timeline-only results are not boosted', () => {
const list: SearchResult[] = [
makeResult({ slug: 'a', chunk_id: 1, chunk_source: 'timeline', chunk_text: 'tl1' }),
makeResult({ slug: 'b', chunk_id: 2, chunk_source: 'timeline', chunk_text: 'tl2' }),
];
const results = rrfFusion([list], 60);
// Top result: normalized to 1.0, no boost (timeline = 1.0x)
expect(results[0].score).toBe(1.0);
});
test('returns empty for empty lists', () => {
expect(rrfFusion([], 60)).toEqual([]);
expect(rrfFusion([[]], 60)).toEqual([]);
});
test('single result normalizes to 1.0 before boost', () => {
const results = rrfFusion([[makeResult({ chunk_source: 'timeline' })]], 60);
expect(results).toHaveLength(1);
expect(results[0].score).toBe(1.0); // 1.0 normalized * 1.0 timeline boost
});
test('uses chunk_id for dedup key when available', () => {
const chunk1 = makeResult({ slug: 'a', chunk_id: 10, chunk_text: 'same prefix text' });
const chunk2 = makeResult({ slug: 'a', chunk_id: 20, chunk_text: 'same prefix text' });
const results = rrfFusion([[chunk1, chunk2]], 60);
// Both should survive because chunk_id differs
expect(results).toHaveLength(2);
});
test('falls back to text prefix when chunk_id is missing', () => {
const chunk1 = makeResult({ slug: 'a', chunk_id: undefined as any, chunk_text: 'same text' });
const chunk2 = makeResult({ slug: 'a', chunk_id: undefined as any, chunk_text: 'same text' });
const results = rrfFusion([[chunk1, chunk2]], 60);
// Same slug + same text prefix = collapsed to 1
expect(results).toHaveLength(1);
});
test('merges scores across multiple lists', () => {
const chunk = makeResult({ slug: 'a', chunk_id: 1, chunk_source: 'timeline' });
// Chunk appears at rank 0 in both lists
const results = rrfFusion([[chunk], [{ ...chunk }]], 60);
expect(results).toHaveLength(1);
// Score should be 2 * 1/(60+0) = 0.0333, normalized to 1.0, no boost
expect(results[0].score).toBe(1.0);
});
test('respects custom K parameter', () => {
const list = [makeResult({ chunk_source: 'timeline' })];
const k30 = rrfFusion([list], 30);
const k90 = rrfFusion([list], 90);
// Both have single result, normalized to 1.0
expect(k30[0].score).toBe(1.0);
expect(k90[0].score).toBe(1.0);
});
});
describe('cosineSimilarity', () => {
test('identical vectors return 1.0', () => {
const v = new Float32Array([1, 2, 3]);
expect(cosineSimilarity(v, v)).toBeCloseTo(1.0, 5);
});
test('orthogonal vectors return 0.0', () => {
const a = new Float32Array([1, 0, 0]);
const b = new Float32Array([0, 1, 0]);
expect(cosineSimilarity(a, b)).toBeCloseTo(0.0, 5);
});
test('opposite vectors return -1.0', () => {
const a = new Float32Array([1, 0, 0]);
const b = new Float32Array([-1, 0, 0]);
expect(cosineSimilarity(a, b)).toBeCloseTo(-1.0, 5);
});
test('zero vector returns 0.0 (no division by zero)', () => {
const zero = new Float32Array([0, 0, 0]);
const v = new Float32Array([1, 2, 3]);
expect(cosineSimilarity(zero, v)).toBe(0);
expect(cosineSimilarity(v, zero)).toBe(0);
expect(cosineSimilarity(zero, zero)).toBe(0);
});
test('works with high-dimensional vectors', () => {
const dim = 1536;
const a = new Float32Array(dim).fill(1);
const b = new Float32Array(dim).fill(1);
expect(cosineSimilarity(a, b)).toBeCloseTo(1.0, 5);
});
test('basis vectors are orthogonal', () => {
const dim = 10;
const a = new Float32Array(dim);
const b = new Float32Array(dim);
a[0] = 1.0;
b[5] = 1.0;
expect(cosineSimilarity(a, b)).toBe(0);
});
});
describe('CJK word count in expansion', () => {
test('CJK characters are counted individually', async () => {
// Import the module to test CJK detection logic
const hasCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test('向量搜索');
expect(hasCJK).toBe(true);
const query = '向量搜索优化';
const wordCount = query.replace(/\s/g, '').length;
expect(wordCount).toBe(6); // 6 CJK chars, not 1 "word"
});
test('non-CJK uses space-delimited counting', () => {
const hasCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test('hello world');
expect(hasCJK).toBe(false);
const query = 'hello world';
const wordCount = (query.match(/\S+/g) || []).length;
expect(wordCount).toBe(2);
});
test('Japanese hiragana detected as CJK', () => {
const hasCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test('こんにちは');
expect(hasCJK).toBe(true);
});
test('Korean hangul detected as CJK', () => {
const hasCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test('안녕하세요');
expect(hasCJK).toBe(true);
});
test('mixed CJK+Latin uses CJK counting', () => {
const query = 'AI 向量搜索';
const hasCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(query);
expect(hasCJK).toBe(true);
const wordCount = query.replace(/\s/g, '').length;
expect(wordCount).toBe(6); // "AI向量搜索" = 6 chars
});
});