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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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