mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
* 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>
193 lines
7.1 KiB
TypeScript
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
|
|
});
|
|
});
|