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:
+21
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user