Files
connectai/tests/retrievalEvalEmbedding.test.ts
T
koriweb 67927b1d4e feat(retrieval): 임베딩 하이브리드 검색 활성화 — 자동 감지 + 측정 기반 수정 (v2.2.222)
골든셋(24질의) 측정으로 기존 하이브리드 구현의 결함 3건을 잡고 기본 활성화.
측정 결과: recall@3 83.3%→87.5%, MRR 0.802→0.806, recall@1 회귀 없음 (α=0.5).

수정 (측정으로 검증):
- 임베딩 입력을 토큰 재조합(tokens.join)→원문 슬라이스로 교체 + nomic/e5
  task prefix (search_query:/search_document:). 토큰 죽 입력은 하이브리드를
  전 지표 하락시켰음 (recall@1 75%→54%). @r2 리비전 키로 구벡터 자동 무효화.
- 블렌드 스케일 버그: 벡터 있는 후보만 정규화돼 벡터 없는 후보의 raw 점수가
  상위 독식 → 전 후보 정규화 + cosine 후보군 내 min-max 정규화.
- 헤딩-only 청크도 헤딩 텍스트로 임베딩 (벡터 공백 제거).

추가:
- embeddingBootstrap: 활성화 시 엔진 모델 목록에서 임베딩 모델 자동 감지 →
  embeddingModel 자동 설정 + "전체 색인" 버튼 알림. 다국어 모델(e5/bge-m3) 우선.
  사용자가 의도적으로 비우면 재설정 안 함 (globalState 가드).
- 벡터 저장 시 소수 4자리 양자화 — 캐시 360MB→~150MB (코사인 순위 영향 없음).
- tests/retrievalEvalEmbedding.test.ts: env-gated 하이브리드 측정 하니스 (alpha sweep).
- scripts/compact_brain_index.mjs: 기존 full-precision 캐시 1회 압축 도구.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:02:56 +09:00

107 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 하이브리드(sparse+dense) 검색 측정 — 청크 TF-IDF vs 청크+임베딩 (alpha sweep).
*
* 평소 테스트 런에서는 skip (실제 두뇌 + 로컬 임베딩 서버 필요). 수동 실행:
*
* ASTRA_EVAL_BRAIN="E:/Wiki/2nd/10_Wiki/Topics" \
* ASTRA_EVAL_EMBED_MODEL="text-embedding-nomic-embed-text-v1.5" \
* npx jest tests/retrievalEvalEmbedding.test.ts --verbose
*
* (서버 URL 은 ASTRA_EVAL_EMBED_URL, 기본 http://127.0.0.1:1234 — LM Studio)
*
* 측정 전에 두뇌 전체 청크 임베딩을 백필한다 — 결과 벡터는 brain-index 캐시에
* 영속되므로 이 테스트 1회 실행이 곧 런타임 초기 색인을 겸한다.
*/
import * as fs from 'fs';
import { RetrievalOrchestrator } from '../src/retrieval';
import { loadGoldenSet, runRetrievalEval, type EvalReport } from '../src/retrieval/evalHarness';
import { findBrainFiles } from '../src/utils';
import { getBrainTokenIndex, backfillBrainChunkEmbeddings } from '../src/retrieval/brainIndex';
import { embedTexts, embedQuery } from '../src/retrieval/embeddings';
const BRAIN = (process.env.ASTRA_EVAL_BRAIN || '').trim();
const EMBED_MODEL = (process.env.ASTRA_EVAL_EMBED_MODEL || '').trim();
const EMBED_URL = (process.env.ASTRA_EVAL_EMBED_URL || 'http://127.0.0.1:1234').trim();
const KS = [1, 3, 5];
const ALPHAS = [0.3, 0.5, 0.7];
const CHUNK_TARGET = 1200;
const maybe = BRAIN && EMBED_MODEL && fs.existsSync(BRAIN) ? describe : describe.skip;
maybe('retrieval A/B — chunk TF-IDF vs chunk+embedding', () => {
jest.setTimeout(40 * 60_000);
test('golden set hybrid comparison', async () => {
const { entries, parseErrors } = loadGoldenSet(BRAIN);
expect(entries.length).toBeGreaterThan(0);
const allFiles = findBrainFiles(BRAIN);
getBrainTokenIndex(BRAIN, allFiles);
// ── 전체 청크 임베딩 백필 (이미 벡터 있는 청크는 건너뜀 → 재실행 저렴) ──
const embed = (texts: string[]) => embedTexts(texts, { baseUrl: EMBED_URL, model: EMBED_MODEL });
const SLICE = 300;
let embedded = 0;
for (let i = 0; i < allFiles.length; i += SLICE) {
embedded += await backfillBrainChunkEmbeddings(BRAIN, allFiles.slice(i, i + SLICE), EMBED_MODEL, embed, CHUNK_TARGET);
// eslint-disable-next-line no-console
console.log(`백필 진행 ${Math.min(i + SLICE, allFiles.length)}/${allFiles.length} 파일 · 신규 벡터 ${embedded}`);
}
const brain = { id: 'eval', name: 'EvalBrain', localBrainPath: BRAIN } as any;
const orchestrator = new RetrievalOrchestrator();
// 질의 임베딩은 alpha 무관하게 동일 — 1회만 계산해 재사용.
const queryVecs = new Map<string, number[] | undefined>();
for (const e of entries) {
queryVecs.set(e.query, await embedQuery(e.query, { baseUrl: EMBED_URL, model: EMBED_MODEL }));
}
const run = (alpha: number): Promise<EvalReport> =>
runRetrievalEval({
entries,
ks: KS,
ranker: async (query: string) =>
orchestrator
.rankBrainForEval(query, brain, {
limit: Math.max(...KS) + 5,
chunkLevelRetrieval: true,
chunkTargetChars: CHUNK_TARGET,
queryEmbedding: alpha > 0 ? queryVecs.get(query) : undefined,
embeddingModel: alpha > 0 ? EMBED_MODEL : undefined,
embeddingBlendAlpha: alpha,
})
.map(r => r.relativePath),
});
const base = await run(0);
const hybrids: Array<{ alpha: number; report: EvalReport }> = [];
for (const a of ALPHAS) hybrids.push({ alpha: a, report: await run(a) });
const pct = (x: number) => (x * 100).toFixed(1) + '%';
const lines: string[] = [];
lines.push('');
lines.push(`══ 하이브리드 검색 측정 (질의 ${entries.length}건, 파싱오류 ${parseErrors}, 신규 벡터 ${embedded}) ══`);
lines.push(`지표 | TF-IDF만 | ${ALPHAS.map(a => `α=${a}`.padStart(7)).join(' | ')}`);
for (const k of KS) {
lines.push(`recall@${k} | ${pct(base.recallAtK[k]).padStart(7)} | ${hybrids.map(h => pct(h.report.recallAtK[k]).padStart(7)).join(' | ')}`);
}
lines.push(`MRR | ${base.mrr.toFixed(3).padStart(7)} | ${hybrids.map(h => h.report.mrr.toFixed(3).padStart(7)).join(' | ')}`);
// 최고 alpha 기준 miss/flip 진단
const best = hybrids.reduce((p, c) => (c.report.mrr > p.report.mrr ? c : p), hybrids[0]);
lines.push(`-- α=${best.alpha} 기준 순위 변동 --`);
base.perQuery.forEach((bq, i) => {
const hq = best.report.perQuery[i];
if ((bq.firstHitRank === null) !== (hq.firstHitRank === null) || bq.firstHitRank !== hq.firstHitRank) {
lines.push(` · "${bq.query.slice(0, 38)}" sparse=#${bq.firstHitRank ?? 'miss'} → hybrid=#${hq.firstHitRank ?? 'miss'}`);
}
});
const misses = best.report.perQuery.filter(q => q.firstHitRank === null);
for (const m of misses) lines.push(` ✗ miss "${m.query.slice(0, 38)}" → 상위: ${m.topPaths.slice(0, 3).join(' · ')}`);
// eslint-disable-next-line no-console
console.log(lines.join('\n'));
expect(base.total).toBe(entries.length);
});
});