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
+2 -2
View File
@@ -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",
+3 -3
View File
@@ -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",
+40
View File
@@ -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 <brain-index.json 경로>');
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`);
+5
View File
@@ -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);
+80
View File
@@ -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<string | null> {
const root = baseUrl.replace(/\/+$/, '');
const tryFetch = async (url: string): Promise<any | null> => {
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<void> {
try {
const cfg = vscode.workspace.getConfiguration('g1nation');
const current = (cfg.get<string>('embeddingModel', '') || '').trim();
if (current) return; // 이미 설정됨
if (context.globalState.get<boolean>(STATE_KEY)) return; // 사용자가 의도적으로 비움
const baseUrl = (cfg.get<string>('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) });
}
}
+48 -18
View File
@@ -363,11 +363,12 @@ export function getBrainChunkIndex(brainPath: string, files: string[], targetCha
export function getBrainEmbeddings(brainPath: string, filePaths: string[], model: string): Map<string, number[]> {
const out = new Map<string, number[]>();
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<number>(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<string, number[]> {
const out = new Map<string, number[]>();
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<s
/**
* Background fill — 주어진 `files` 의 청크 중 현재 모델 벡터가 없는 것만 임베딩한다.
* 청크 텍스트는 캐시된 토큰에서 재구성(파일 단위 backfill 과 동일 전략 — 파일 재read 회피).
* 벡터가 필요한 파일만 1회 재read 해 원문 슬라이스를 임베딩한다.
* Fire-and-forget 용. 새로 임베딩한 청크 수를 반환.
*/
export async function backfillBrainChunkEmbeddings(
@@ -481,15 +499,27 @@ export async function backfillBrainChunkEmbeddings(
const st = _states.get(brainPath);
if (!st) return 0;
const embKey = embeddingKey(model);
const texts: string[] = [];
const refs: Array<{ fp: string; ci: number }> = [];
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++;
}
+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 });
+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);
}
}
}
+106
View File
@@ -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<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);
});
});