feat(retrieval): 청킹/평가 하니스 + 검색 인덱스 개선

- src/retrieval/chunker.ts: 문서 청킹 로직 추가
- src/retrieval/evalHarness.ts + src/extension/evalCommands.ts: 검색 품질 평가 하니스
- brainIndex.ts / retrieval/index.ts / memoryContext.ts: 인덱싱·컨텍스트 빌더 개선
- config.ts / extension.ts / sidebarProvider.ts / package.json 갱신
- ADR-0030~0032 및 개발 기록, .astra 런타임 상태 동기화

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 19:27:10 +09:00
parent b94e6ad1da
commit d39eb27c90
26 changed files with 1471 additions and 208 deletions
+228
View File
@@ -0,0 +1,228 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import { getConfig } from '../config';
import { getActiveBrainProfile, findBrainFiles, logInfo, logError } from '../utils';
import { RetrievalOrchestrator } from '../retrieval';
import { getBrainTokenIndex, backfillBrainEmbeddings, backfillBrainChunkEmbeddings } from '../retrieval/brainIndex';
import { embedQuery, embedTexts } from '../retrieval/embeddings';
import {
loadGoldenSet,
runRetrievalEval,
formatReportMarkdown,
GOLDEN_TEMPLATE,
GOLDEN_REL_JSONL,
} from '../retrieval/evalHarness';
/**
* 검색 평가 명령 묶음 (Phase 1-나).
*
* `g1nation.eval.retrieval` — 활성 두뇌의 골든셋(.astra/eval/golden.jsonl)으로 검색
* recall@k / MRR 를 측정해 마크다운 리포트를 남긴다. 골든셋이 없으면 템플릿을 만들어
* 열어준다. 청킹(Phase 1-가) 도입 전/후를 같은 골든셋으로 돌려 개선을 *숫자로* 증명하는 것이 목적.
*/
export function registerEvalCommands(): vscode.Disposable[] {
return [
vscode.commands.registerCommand('g1nation.eval.retrieval', runRetrievalEvalCommand),
vscode.commands.registerCommand('g1nation.embeddings.backfill', backfillEmbeddingsCommand),
];
}
const EVAL_KS = [1, 3, 5, 10];
async function runRetrievalEvalCommand(): Promise<void> {
try {
const brain = getActiveBrainProfile();
if (!brain?.localBrainPath || !fs.existsSync(brain.localBrainPath)) {
vscode.window.showErrorMessage('활성 두뇌 폴더를 찾을 수 없습니다. 먼저 두뇌를 추가/선택하세요.');
return;
}
// 1) 골든셋 로드 — 없으면 템플릿 스캐폴드 후 열어주고 종료.
const { entries, sourcePath, parseErrors } = loadGoldenSet(brain.localBrainPath);
if (entries.length === 0) {
const goldenPath = path.join(brain.localBrainPath, GOLDEN_REL_JSONL);
const created = await scaffoldGoldenSet(goldenPath, sourcePath, parseErrors);
if (created) {
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(goldenPath));
await vscode.window.showTextDocument(doc);
vscode.window.showInformationMessage(
'골든셋 템플릿을 만들었습니다. 질문→기대문서 쌍을 채운 뒤 다시 "Astra: 검색 평가 실행"을 실행하세요.',
);
}
return;
}
const config = getConfig();
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: 'Astra 검색 평가', cancellable: false },
async (progress) => {
// 2) 인덱스 워밍업 — 전체 brain 파일을 토크나이즈 인덱스에 로드 (backfill 의 전제).
progress.report({ message: '인덱스 로드 중…' });
const allFiles = findBrainFiles(brain.localBrainPath);
getBrainTokenIndex(brain.localBrainPath, allFiles);
// 3) 임베딩 backfill — 설정된 경우 dense 항이 공정하게 평가되도록 모든 파일 벡터를 채운다.
const useEmbeddings = !!config.embeddingModel && (config.embeddingBlendAlpha ?? 0) > 0;
if (useEmbeddings) {
progress.report({ message: `임베딩 채우는 중 (${config.embeddingModel})…` });
const embed = (texts: string[]) => embedTexts(texts, { baseUrl: config.ollamaUrl, model: config.embeddingModel });
try {
if (config.chunkLevelRetrieval === true) {
await backfillBrainChunkEmbeddings(brain.localBrainPath, allFiles, config.embeddingModel, embed, config.chunkTargetChars);
} else {
await backfillBrainEmbeddings(brain.localBrainPath, allFiles, config.embeddingModel, embed);
}
} catch (e: any) {
logInfo('Eval embedding backfill failed — continuing TF-IDF only.', { error: e?.message ?? String(e) });
}
}
// 4) 평가 실행. ranker 는 프로덕션과 동일한 scoring 경로를 쓰되 budget 적용 전 랭킹을 본다.
const orchestrator = new RetrievalOrchestrator();
let done = 0;
const ranker = async (query: string): Promise<string[]> => {
done++;
progress.report({ message: `질의 ${done}/${entries.length} 평가 중…` });
let queryEmbedding: number[] | undefined;
if (useEmbeddings) {
try {
queryEmbedding = await Promise.race([
embedQuery(query, { baseUrl: config.ollamaUrl, model: config.embeddingModel }),
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 4000)),
]);
} catch { queryEmbedding = undefined; }
}
return orchestrator
.rankBrainForEval(query, brain, {
limit: Math.max(...EVAL_KS) + 5,
queryEmbedding,
embeddingModel: config.embeddingModel || undefined,
embeddingBlendAlpha: config.embeddingBlendAlpha,
chunkLevelRetrieval: config.chunkLevelRetrieval === true,
chunkTargetChars: config.chunkTargetChars,
})
.map((r) => r.relativePath);
};
const report = await runRetrievalEval({ entries, ks: EVAL_KS, ranker });
// 5) 리포트 저장 + 열기.
const now = new Date();
const stamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
const mode = config.chunkLevelRetrieval === true
? `섹션 청크 (target=${config.chunkTargetChars}자)`
: '파일 단위 (baseline)';
const noteParts = [`검색 모드: ${mode}`];
if (parseErrors > 0) noteParts.push(`골든셋 파싱 실패 ${parseErrors}줄 (무시됨)`);
const md = formatReportMarkdown(report, {
brainName: brain.name,
dateStr: now.toLocaleString(),
embeddingModel: useEmbeddings ? config.embeddingModel : '',
alpha: config.embeddingBlendAlpha ?? 0,
notes: noteParts.join(' · '),
});
const reportPath = path.join(brain.localBrainPath, '.astra', 'eval', `report-${stamp}.md`);
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, md, 'utf8');
logInfo('Retrieval eval complete.', {
queries: report.total,
recallAt5: report.recallAtK[5],
mrr: report.mrr,
reportPath,
});
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(reportPath));
await vscode.window.showTextDocument(doc, { preview: false });
vscode.window.showInformationMessage(
`검색 평가 완료 · recall@5 ${(report.recallAtK[5] * 100).toFixed(0)}% · MRR ${report.mrr.toFixed(2)} (질의 ${report.total}개)`,
);
},
);
} catch (err: any) {
logError('Retrieval eval command failed.', { error: err?.message || String(err) });
vscode.window.showErrorMessage(`검색 평가 실패: ${err?.message ?? err}`);
}
}
/**
* 두뇌 전체 임베딩 색인 채우기. 평소엔 턴마다 *검색된 파일* 만 lazy backfill 되므로 dense
* 검색이 충분히 효과를 내려면 오래 걸린다 — 이 명령으로 한 번에 채운다. 청크 모드면 청크
* 단위 벡터를, 아니면 파일 단위 벡터를 채운다. 엔진 호출 크기를 제한하려 파일 배치로 처리.
*/
async function backfillEmbeddingsCommand(): Promise<void> {
try {
const brain = getActiveBrainProfile();
if (!brain?.localBrainPath || !fs.existsSync(brain.localBrainPath)) {
vscode.window.showErrorMessage('활성 두뇌 폴더를 찾을 수 없습니다.');
return;
}
const config = getConfig();
if (!config.embeddingModel) {
vscode.window.showWarningMessage(
'임베딩 모델이 설정되지 않았습니다. 엔진(Ollama/LM Studio)에 임베딩 모델을 로드한 뒤 ' +
'g1nation.embeddingModel 에 그 모델명을 입력하세요. (없어도 TF-IDF 검색은 동작합니다.)',
);
return;
}
const chunkMode = config.chunkLevelRetrieval === true;
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: `Astra 임베딩 색인 (${config.embeddingModel})`, cancellable: true },
async (progress, token) => {
const allFiles = findBrainFiles(brain.localBrainPath);
getBrainTokenIndex(brain.localBrainPath, allFiles);
const embed = (texts: string[]) => embedTexts(texts, { baseUrl: config.ollamaUrl, model: config.embeddingModel });
const BATCH = 40;
let embedded = 0;
for (let i = 0; i < allFiles.length; i += BATCH) {
if (token.isCancellationRequested) break;
const slice = allFiles.slice(i, i + BATCH);
progress.report({
message: `${Math.min(i + BATCH, allFiles.length)}/${allFiles.length} 파일 · 임베딩 ${embedded}`,
increment: (BATCH / allFiles.length) * 100,
});
try {
embedded += chunkMode
? await backfillBrainChunkEmbeddings(brain.localBrainPath, slice, config.embeddingModel, embed, config.chunkTargetChars)
: await backfillBrainEmbeddings(brain.localBrainPath, slice, config.embeddingModel, embed);
} catch (e: any) {
logInfo('Embedding batch failed — continuing.', { batchStart: i, error: e?.message ?? String(e) });
}
}
logInfo('Full-brain embedding backfill done.', { mode: chunkMode ? 'chunk' : 'file', files: allFiles.length, embedded });
vscode.window.showInformationMessage(
`임베딩 색인 완료 · ${chunkMode ? '청크' : '파일'} 단위 · 신규 ${embedded}개 (${allFiles.length} 파일 스캔). ` +
`이제 '검색 평가 실행'으로 dense 효과를 측정해 보세요.`,
);
},
);
} catch (err: any) {
logError('Embedding backfill command failed.', { error: err?.message || String(err) });
vscode.window.showErrorMessage(`임베딩 색인 실패: ${err?.message ?? err}`);
}
}
/** 골든셋 파일이 없을 때 템플릿을 만든다. 이미 (깨진/빈) 파일이 있으면 덮어쓰지 않는다. */
async function scaffoldGoldenSet(goldenPath: string, existingSource: string | null, parseErrors: number): Promise<boolean> {
if (existingSource && fs.existsSync(existingSource)) {
// 파일은 있는데 유효 항목이 0개 — 사용자가 작성 중이거나 오타. 덮어쓰지 않고 안내만.
vscode.window.showWarningMessage(
`골든셋(${path.basename(existingSource)})에 유효한 항목이 없습니다${parseErrors ? ` (파싱 실패 ${parseErrors}줄)` : ''}. ` +
'각 줄을 {"query": "...", "expected": ["파일명.md"]} 형식으로 작성하세요.',
);
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(existingSource));
await vscode.window.showTextDocument(doc);
return false;
}
try {
fs.mkdirSync(path.dirname(goldenPath), { recursive: true });
fs.writeFileSync(goldenPath, GOLDEN_TEMPLATE, 'utf8');
return true;
} catch (e: any) {
vscode.window.showErrorMessage(`골든셋 템플릿 생성 실패: ${e?.message ?? e}`);
return false;
}
}