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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user