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>
This commit is contained in:
2026-06-11 19:02:56 +09:00
parent c7c84702af
commit 67927b1d4e
9 changed files with 324 additions and 28 deletions
+21 -3
View File
@@ -452,6 +452,12 @@ export class RetrievalOrchestrator {
// Hybrid: 청크 단위 임베딩(`${filePath}#${chunkIndex}`)으로 dense blend. 청크 벡터가
// 아직 없는 항목은 파일 단위 임베딩으로 fallback → 둘 다 없으면 순수 TF-IDF 유지.
//
// 스케일 주의 (측정으로 잡은 버그 2건):
// 1. *모든* 후보를 maxTfidf 로 정규화해야 한다 — 벡터 있는 것만 0..1 로 줄이면
// 벡터 없는 후보의 raw 점수(≫1)가 상위를 독식해 blend 가 무효가 된다.
// 2. cosine 은 후보군 내 min-max 정규화 — 임베딩 모델은 무관 문서끼리도
// cos 0.5~0.7 이 나와, 절대값 가산은 균일 노이즈로 sparse 정밀도를 흐린다.
if (queryEmbedding && embeddingModel && (embeddingBlendAlpha ?? 0) > 0) {
const alpha = Math.max(0, Math.min(1, embeddingBlendAlpha!));
const chunkEmb = getBrainChunkEmbeddings(brain.localBrainPath, embeddingModel);
@@ -459,12 +465,24 @@ export class RetrievalOrchestrator {
const fileEmb = getBrainEmbeddings(brain.localBrainPath, filePaths, embeddingModel);
if (chunkEmb.size > 0 || fileEmb.size > 0) {
const maxTfidf = scored.reduce((m, s) => (s.score > m ? s.score : m), 0) || 1;
for (const s of scored) {
const c = chunks[s.index];
const cosines = new Array<number | null>(scored.length).fill(null);
let minCos = Infinity, maxCos = -Infinity;
for (let i = 0; i < scored.length; i++) {
const c = chunks[scored[i].index];
const vec = chunkEmb.get(`${c.filePath}#${c.chunkIndex}`) || fileEmb.get(c.filePath);
if (!vec) continue;
const cos = cosineSimilarity(queryEmbedding, vec);
s.score = (1 - alpha) * (s.score / maxTfidf) + alpha * Math.max(0, cos);
cosines[i] = cos;
if (cos < minCos) minCos = cos;
if (cos > maxCos) maxCos = cos;
}
const span = maxCos > minCos ? maxCos - minCos : 1;
for (let i = 0; i < scored.length; i++) {
const s = scored[i];
const sparse = s.score / maxTfidf;
const cos = cosines[i];
// 벡터 없는 후보는 sparse 점수 유지 (임베딩 미색인이 검색을 해치지 않게).
s.score = cos === null ? sparse : (1 - alpha) * sparse + alpha * ((cos - minCos) / span);
}
}
}