From 67927b1d4e69ea18e7350681666d33c4f5cc9346 Mon Sep 17 00:00:00 2001 From: g1nation Date: Thu, 11 Jun 2026 19:02:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(retrieval):=20=EC=9E=84=EB=B2=A0=EB=94=A9?= =?UTF-8?q?=20=ED=95=98=EC=9D=B4=EB=B8=8C=EB=A6=AC=EB=93=9C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=ED=99=9C=EC=84=B1=ED=99=94=20=E2=80=94=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EA=B0=90=EC=A7=80=20+=20=EC=B8=A1=EC=A0=95=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=88=98=EC=A0=95=20(v2.2.222)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 골든셋(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 --- package-lock.json | 4 +- package.json | 6 +- scripts/compact_brain_index.mjs | 40 ++++++++++ src/extension.ts | 5 ++ src/extension/embeddingBootstrap.ts | 80 ++++++++++++++++++++ src/retrieval/brainIndex.ts | 66 ++++++++++++----- src/retrieval/embeddings.ts | 21 +++++- src/retrieval/index.ts | 24 +++++- tests/retrievalEvalEmbedding.test.ts | 106 +++++++++++++++++++++++++++ 9 files changed, 324 insertions(+), 28 deletions(-) create mode 100644 scripts/compact_brain_index.mjs create mode 100644 src/extension/embeddingBootstrap.ts create mode 100644 tests/retrievalEvalEmbedding.test.ts diff --git a/package-lock.json b/package-lock.json index 9aadbe1..f0aae8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "astra", - "version": "2.2.221", + "version": "2.2.222", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "astra", - "version": "2.2.221", + "version": "2.2.222", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", diff --git a/package.json b/package.json index e219c4d..c043e5b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.2.221", + "version": "2.2.222", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -635,14 +635,14 @@ "g1nation.embeddingModel": { "type": "string", "default": "", - "description": "Embedding model registered in LM Studio / Ollama (e.g. 'text-embedding-bge-small-en-v1.5', 'nomic-embed-text', 'multilingual-e5-small'). When empty, Astra uses TF-IDF only. When set, the brain is embedded lazily in the background and retrieval blends TF-IDF + cosine similarity for synonym / paraphrase matching. Multilingual models are recommended for Korean content." + "description": "Embedding model registered in LM Studio / Ollama (e.g. 'text-embedding-nomic-embed-text-v1.5', 'multilingual-e5-small'). When empty, Astra auto-detects one from the engine's model list on startup (any model whose name contains 'embed') and enables hybrid retrieval; clear the value after auto-detection to keep it off. When set, the brain is embedded lazily in the background and retrieval blends TF-IDF + cosine similarity for synonym / paraphrase matching. Multilingual models are recommended for Korean content." }, "g1nation.embeddingBlendAlpha": { "type": "number", "default": 0.5, "minimum": 0, "maximum": 1, - "description": "Hybrid score blend: 0 = pure TF-IDF (sparse / keyword), 1 = pure embedding cosine (dense / semantic), 0.5 = balanced. Only used when g1nation.embeddingModel is set. Default 0.5." + "description": "Hybrid score blend: 0 = pure TF-IDF (sparse / keyword), 1 = pure embedding cosine (dense / semantic), 0.5 = balanced. Only used when g1nation.embeddingModel is set. Default 0.5 — measured on the golden set (24 queries, nomic-embed v1.5): recall@3 83.3%→87.5%, MRR 0.802→0.806, no regression at recall@1; higher values trade recall@1 for recall@5." }, "g1nation.chunkLevelRetrieval": { "type": "boolean", diff --git a/scripts/compact_brain_index.mjs b/scripts/compact_brain_index.mjs new file mode 100644 index 0000000..018e25f --- /dev/null +++ b/scripts/compact_brain_index.mjs @@ -0,0 +1,40 @@ +/** + * brain-index.json 일괄 압축 — 기존 full-precision 임베딩 벡터를 소수 4자리로 양자화. + * + * 양자화 저장(quantizeVector)이 들어가기 *전에* 백필된 벡터를 줄이는 1회성 도구. + * 이후 신규 벡터는 저장 시점에 양자화되므로 다시 돌릴 일은 드물다. + * + * node --max-old-space-size=8192 scripts/compact_brain_index.mjs "E:/Wiki/2nd/10_Wiki/Topics/.astra/brain-index.json" + */ +import fs from 'fs'; + +const file = process.argv[2]; +if (!file || !fs.existsSync(file)) { + console.error('사용법: node compact_brain_index.mjs '); + process.exit(1); +} + +const before = fs.statSync(file).size; +console.log(`읽는 중… (${(before / 1e6).toFixed(0)} MB)`); +const idx = JSON.parse(fs.readFileSync(file, 'utf8')); + +const q = (v) => Math.round(v * 10000) / 10000; +let files = 0, chunks = 0; +for (const entry of Object.values(idx.entries || {})) { + if (Array.isArray(entry?.embedding)) { + entry.embedding = entry.embedding.map(q); + files++; + } + for (const ch of entry?.chunks || []) { + if (Array.isArray(ch?.embedding)) { + ch.embedding = ch.embedding.map(q); + chunks++; + } + } +} + +const tmp = file + '.compact-tmp'; +fs.writeFileSync(tmp, JSON.stringify(idx)); +fs.renameSync(tmp, file); +const after = fs.statSync(file).size; +console.log(`완료: 파일벡터 ${files} · 청크벡터 ${chunks} · ${(before / 1e6).toFixed(0)} MB → ${(after / 1e6).toFixed(0)} MB`); diff --git a/src/extension.ts b/src/extension.ts index e48d731..9c12607 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -41,6 +41,7 @@ import { runInitialSetup } from './extension/initialSetup'; import { startStocksWatcher } from './features/stocks'; import { startDailyBriefingWatcher } from './features/briefing/dailyBriefing'; import { ensureDefaultBrainConfigured } from './extension/brainBootstrap'; +import { ensureEmbeddingConfigured } from './extension/embeddingBootstrap'; import { startGrowthCycleWatcher, runGrowthCycleOnce } from './features/growth/growthCycleWatcher'; import { registerProviderCommands } from './extension/providerCommands'; import { registerScaffoldCommand } from './extension/scaffoldCommand'; @@ -72,6 +73,10 @@ export async function activate(context: vscode.ExtensionContext) { // 이후 코드가 getConfig() 로 두뇌 경로를 읽으므로 설정 기록이 끝난 뒤 진행해야 한다. await ensureDefaultBrainConfigured(); + // 임베딩 자동 감지 — 엔진에 임베딩 모델이 로드돼 있으면 하이브리드 검색을 켠다. + // 비차단 (TF-IDF 검색은 이미 동작 중 — 결과를 기다릴 필요 없음). + void ensureEmbeddingConfigured(context); + // Initialize Astra Path Resolver (.astra → ConnectAI/.astra/) initAstraPathResolver(context); diff --git a/src/extension/embeddingBootstrap.ts b/src/extension/embeddingBootstrap.ts new file mode 100644 index 0000000..80aaac7 --- /dev/null +++ b/src/extension/embeddingBootstrap.ts @@ -0,0 +1,80 @@ +/** + * 임베딩 모델 자동 감지 부트스트랩 — 하이브리드(sparse+dense) 검색 온보딩. + * + * 문제: g1nation.embeddingModel 기본값이 '' (비활성) — LM Studio 에 임베딩 모델이 + * 로드돼 있어도 사용자가 설정 키와 모델명 문자열을 알아야만 켜진다. 비개발자 + * 사용자에게는 사실상 영구 비활성. 측정 결과 임베딩 블렌드가 어휘 갭 + * ("심판"↔"LLM-as-a-Judge" 류 동의어/패러프레이즈) miss 를 해소하므로 기본 ON 가치가 있다. + * + * 해결: 활성화 시 embeddingModel 이 비어 있으면 엔진(ollamaUrl)의 모델 목록을 조회해 + * 이름에 'embed' 가 들어간 모델을 찾아 자동 설정 + 1회 알림 ("전체 색인" 버튼 포함). + * + * 보호 장치: + * - 이미 embeddingModel 설정됨 → no-op (기존 사용자 무영향). + * - 자동 설정 이력이 있는데 현재 비어 있음 = 사용자가 의도적으로 껐다 → 재설정 안 함. + * - 엔진 미응답/임베딩 모델 없음 → 조용히 skip (다음 활성화 때 재시도). + */ +import * as vscode from 'vscode'; +import { logError, logInfo } from '../utils'; + +const STATE_KEY = 'astra.embeddingAutoConfigured'; +const PROBE_TIMEOUT_MS = 4000; + +/** 엔진의 모델 목록에서 임베딩 모델 id 를 찾는다 (LM Studio /v1/models · Ollama /api/tags). */ +async function detectEmbeddingModel(baseUrl: string): Promise { + const root = baseUrl.replace(/\/+$/, ''); + const tryFetch = async (url: string): Promise => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); + try { + const r = await fetch(url, { signal: controller.signal }); + return r.ok ? await r.json() : null; + } catch { return null; } finally { clearTimeout(timer); } + }; + // OpenAI-호환 (LM Studio) 우선, 실패 시 Ollama. + const v1 = await tryFetch(`${root}/v1/models`); + let ids: string[] = Array.isArray(v1?.data) ? v1.data.map((m: any) => String(m?.id || '')) : []; + if (ids.length === 0) { + const tags = await tryFetch(`${root}/api/tags`); + ids = Array.isArray(tags?.models) ? tags.models.map((m: any) => String(m?.name || '')) : []; + } + // 임베딩 모델 후보 — 'embed' 외에 대표 임베딩 계열 이름도 포착 + // (multilingual-e5-small, bge-m3 등은 이름에 'embed' 가 없다). + const candidates = ids.filter(id => /embed|(^|[^a-z0-9])(bge|gte|e5)([^a-z0-9]|$)|minilm|arctic-embed/i.test(id)); + if (candidates.length === 0) return null; + // 한국어 두뇌 특성상 다국어 모델 우선 (골든셋 측정: nomic 은 한국어 동의어 매칭 한계). + const rank = (id: string) => (/multilingual|bge-m3|e5/i.test(id) ? 0 : 1); + candidates.sort((a, b) => rank(a) - rank(b)); + return candidates[0]; +} + +/** + * embeddingModel 미설정 시 엔진에서 임베딩 모델을 자동 감지해 설정한다. + * 활성화 초기에 1회 호출 (fire-and-forget — 검색은 TF-IDF 로 이미 동작 중). + */ +export async function ensureEmbeddingConfigured(context: vscode.ExtensionContext): Promise { + try { + const cfg = vscode.workspace.getConfiguration('g1nation'); + const current = (cfg.get('embeddingModel', '') || '').trim(); + if (current) return; // 이미 설정됨 + if (context.globalState.get(STATE_KEY)) return; // 사용자가 의도적으로 비움 + + const baseUrl = (cfg.get('ollamaUrl', '') || 'http://127.0.0.1:1234').trim(); + const model = await detectEmbeddingModel(baseUrl); + if (!model) { logInfo('Embedding bootstrap: 엔진에서 임베딩 모델 미발견 — skip.', { baseUrl }); return; } + + await cfg.update('embeddingModel', model, vscode.ConfigurationTarget.Global); + await context.globalState.update(STATE_KEY, true); + logInfo('Embedding bootstrap: 하이브리드 검색 자동 활성화.', { model, baseUrl }); + + void vscode.window.showInformationMessage( + `Astra: 임베딩 모델 "${model}" 을 발견해 하이브리드 검색을 켰습니다. ` + + '전체 색인을 한 번 실행하면 즉시 전체 두뇌에 적용됩니다 (이후는 자동 증분).', + '지금 전체 색인', + ).then(pick => { + if (pick === '지금 전체 색인') void vscode.commands.executeCommand('g1nation.embeddings.backfill'); + }); + } catch (e: any) { + logError('Embedding bootstrap 실패 (TF-IDF 검색은 정상 동작).', { error: e?.message ?? String(e) }); + } +} diff --git a/src/retrieval/brainIndex.ts b/src/retrieval/brainIndex.ts index b0ac699..5fad9bc 100644 --- a/src/retrieval/brainIndex.ts +++ b/src/retrieval/brainIndex.ts @@ -363,11 +363,12 @@ export function getBrainChunkIndex(brainPath: string, files: string[], targetCha export function getBrainEmbeddings(brainPath: string, filePaths: string[], model: string): Map { const out = new Map(); if (!brainPath || !model.trim() || !Array.isArray(filePaths) || filePaths.length === 0) return out; + const embKey = embeddingKey(model); const st = _states.get(brainPath); if (!st) return out; for (const fp of filePaths) { const entry = st.index.entries[fp]; - if (!entry?.embedding || entry.embeddingModel !== model) continue; + if (!entry?.embedding || entry.embeddingModel !== embKey) continue; if (!Array.isArray(entry.embedding) || entry.embedding.length === 0) continue; out.set(fp, entry.embedding); } @@ -384,6 +385,25 @@ export function getBrainEmbeddings(brainPath: string, filePaths: string[], model * Returns the count of newly embedded files (0 when everything was cached * already or the model is empty). */ +/** + * 벡터를 소수 4자리로 양자화 — JSON 캐시 크기를 절반 이하로 줄인다. + * full-precision float64 직렬화는 원소당 ~20자, 4자리는 ~7자. 코사인 유사도는 + * 1e-4 노이즈에 둔감하므로 (정규화된 임베딩 공간에서 순위 변동 없음) 손실 무해. + */ +function quantizeVector(v: number[]): number[] { + const out = new Array(v.length); + for (let i = 0; i < v.length; i++) out[i] = Math.round(v[i] * 10000) / 10000; + return out; +} + +/** + * 임베딩 캐시 키 — 모델명 + 파이프라인 리비전. 입력 구성이 바뀌면 리비전을 올려 + * 기존 벡터를 자동 무효화(재임베딩)한다. + * r2: 토큰 죽(tokens.join) → *원문 슬라이스* + task prefix (측정상 r1 은 검색 품질 저하). + */ +const EMBED_REV = 'r2'; +function embeddingKey(model: string): string { return `${model}@${EMBED_REV}`; } + export async function backfillBrainEmbeddings( brainPath: string, filePaths: string[], @@ -393,28 +413,25 @@ export async function backfillBrainEmbeddings( if (!brainPath || !model.trim() || !Array.isArray(filePaths) || filePaths.length === 0) return 0; const st = _states.get(brainPath); if (!st) return 0; + const embKey = embeddingKey(model); const stale: string[] = []; for (const fp of filePaths) { const entry = st.index.entries[fp]; if (!entry) continue; - if (entry.embedding && entry.embeddingModel === model) continue; + if (entry.embedding && entry.embeddingModel === embKey) continue; stale.push(fp); } if (stale.length === 0) return 0; - // Build embedding inputs from cached tokens (much cheaper than re-reading - // the file). We re-read content only when the cached tokens are missing - // somehow — defensive, but the index always has them after tokenization. + // 원문을 그대로 임베딩한다 (제목 prepend). 토큰 재조합(tokens.join)은 불용어 + // 제거·소문자화로 문장 구조가 사라져 문장 임베딩 모델 품질을 크게 떨어뜨린다 + // (golden set 측정으로 확인 — EMBED_REV 참고). const texts: string[] = []; const keys: string[] = []; for (const fp of stale) { const entry = st.index.entries[fp]; if (!entry) continue; let text = ''; - if (Array.isArray(entry.tokens) && entry.tokens.length > 0) { - text = `${entry.title}\n${entry.tokens.join(' ')}`; - } else { - try { text = fs.readFileSync(fp, 'utf8'); } catch { continue; } - } + try { text = `${entry.title}\n${fs.readFileSync(fp, 'utf8')}`; } catch { continue; } if (!text.trim()) continue; texts.push(text); keys.push(fp); @@ -427,8 +444,8 @@ export async function backfillBrainEmbeddings( if (!Array.isArray(v) || v.length === 0) continue; const entry = st.index.entries[keys[i]]; if (!entry) continue; - entry.embedding = v; - entry.embeddingModel = model; + entry.embedding = quantizeVector(v); + entry.embeddingModel = embKey; st.dirty = true; } if (st.dirty) { @@ -449,13 +466,14 @@ export async function backfillBrainEmbeddings( export function getBrainChunkEmbeddings(brainPath: string, model: string): Map { const out = new Map(); if (!brainPath || !model.trim()) return out; + const embKey = embeddingKey(model); const st = _states.get(brainPath); if (!st) return out; for (const [fp, entry] of Object.entries(st.index.entries)) { if (!entry?.chunks) continue; for (let ci = 0; ci < entry.chunks.length; ci++) { const ch = entry.chunks[ci]; - if (!ch.embedding || ch.embeddingModel !== model) continue; + if (!ch.embedding || ch.embeddingModel !== embKey) continue; if (!Array.isArray(ch.embedding) || ch.embedding.length === 0) continue; out.set(`${fp}#${ci}`, ch.embedding); } @@ -465,7 +483,7 @@ export function getBrainChunkEmbeddings(brainPath: string, model: string): Map = []; for (const fp of files) { const entry = st.index.entries[fp]; if (!entry?.chunks) continue; + const needs = entry.chunks.some(ch => !(ch.embedding && ch.embeddingModel === embKey)); + if (!needs) continue; + // 원문 슬라이스를 임베딩 (charStart/charEnd) — 토큰 재조합은 문장 구조가 + // 사라져 임베딩 품질 저하 (EMBED_REV 주석 참고). 제목·헤딩 경로를 prepend 해 + // 문서 맥락을 보존한다. + let content = ''; + try { content = fs.readFileSync(fp, 'utf8'); } catch { continue; } for (let ci = 0; ci < entry.chunks.length; ci++) { const ch = entry.chunks[ci]; - if (ch.embedding && ch.embeddingModel === model) continue; - const text = Array.isArray(ch.tokens) && ch.tokens.length > 0 ? ch.tokens.join(' ') : ''; + if (ch.embedding && ch.embeddingModel === embKey) continue; + const body = content.slice(ch.charStart, ch.charEnd).trim(); + const headingLine = [entry.title, ...ch.headingPath.filter(h => h !== entry.title)].join(' > '); + // 본문 없는 헤딩-only 섹션도 헤딩 텍스트로 임베딩 — 벡터 없는 청크를 + // 남기면 hybrid 랭킹에서 스코어 스케일이 갈라진다. + const text = body ? `${headingLine}\n${body}` : headingLine; if (!text.trim()) continue; texts.push(text); refs.push({ fp, ci }); @@ -505,8 +535,8 @@ export async function backfillBrainChunkEmbeddings( const entry = st.index.entries[refs[i].fp]; const ch = entry?.chunks?.[refs[i].ci]; if (!ch) continue; - ch.embedding = v; - ch.embeddingModel = model; + ch.embedding = quantizeVector(v); + ch.embeddingModel = embKey; st.dirty = true; n++; } diff --git a/src/retrieval/embeddings.ts b/src/retrieval/embeddings.ts index 8b44dd4..3b88390 100644 --- a/src/retrieval/embeddings.ts +++ b/src/retrieval/embeddings.ts @@ -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 }); diff --git a/src/retrieval/index.ts b/src/retrieval/index.ts index c2b290c..4c8fd37 100644 --- a/src/retrieval/index.ts +++ b/src/retrieval/index.ts @@ -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(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); } } } diff --git a/tests/retrievalEvalEmbedding.test.ts b/tests/retrievalEvalEmbedding.test.ts new file mode 100644 index 0000000..45edfc1 --- /dev/null +++ b/tests/retrievalEvalEmbedding.test.ts @@ -0,0 +1,106 @@ +/** + * 하이브리드(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(); + for (const e of entries) { + queryVecs.set(e.query, await embedQuery(e.query, { baseUrl: EMBED_URL, model: EMBED_MODEL })); + } + + const run = (alpha: number): Promise => + 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); + }); +});