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
+19 -2
View File
@@ -40,6 +40,22 @@ export interface EmbeddingCallOptions {
model: string;
/** AbortSignal for cancellation propagation. */
signal?: AbortSignal;
/**
* Task kind for asymmetric embedding models. nomic-embed v1.5 / e5 계열은
* 질의·문서에 서로 다른 prefix 를 *반드시* 붙여야 본래 품질이 나온다.
* embedTexts 기본 'document', embedQuery 는 항상 'query'.
*/
kind?: 'query' | 'document';
}
/**
* 모델별 task prefix. prefix 미적용 시 nomic/e5 는 의미 매칭 품질이 크게 떨어진다
* (모델 카드 명시 요구사항). 알 수 없는 모델은 prefix 없음.
*/
export function taskPrefix(model: string, kind: 'query' | 'document'): string {
if (/nomic/i.test(model)) return kind === 'query' ? 'search_query: ' : 'search_document: ';
if (/(^|[^a-z0-9])e5([^a-z0-9]|$)/i.test(model)) return kind === 'query' ? 'query: ' : 'passage: ';
return '';
}
/**
@@ -54,9 +70,10 @@ export async function embedTexts(texts: string[], opts: EmbeddingCallOptions): P
if (!texts || texts.length === 0) return [];
const engine = resolveEngine(opts.baseUrl);
const url = buildApiUrl(opts.baseUrl, engine, 'embeddings');
const prefix = taskPrefix(opts.model, opts.kind ?? 'document');
const out: number[][] = [];
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const batch = texts.slice(i, i + BATCH_SIZE).map((t) => clipForEmbedding(t));
const batch = texts.slice(i, i + BATCH_SIZE).map((t) => prefix + clipForEmbedding(t));
const body = engine === 'lmstudio'
? { model: opts.model, input: batch }
: { model: opts.model, input: batch }; // Ollama 0.1.30+ also accepts array input
@@ -154,7 +171,7 @@ export async function embedQuery(text: string, opts: EmbeddingCallOptions): Prom
const cached = getCachedQueryEmbedding(opts.model, text);
if (cached) return cached;
try {
const [vec] = await embedTexts([text], opts);
const [vec] = await embedTexts([text], { ...opts, kind: 'query' });
if (vec && vec.length > 0) {
setCachedQueryEmbedding(opts.model, text, vec);
logInfo('Query embedding computed.', { model: opts.model, dim: vec.length });