feat(retrieval): 청크 검색 기본 켬(+62.5%p recall@1) + 확신도 전역화 (v2.2.218)

P1 — 섹션 청크 검색 기본 활성화:
- 골든셋 24질의 A/B 측정: 파일 단위 → 섹션 청크에서
  recall@1 12.5%→75.0% · recall@3 33.3%→83.3% · recall@5 37.5%→87.5%
  · MRR 0.217→0.802. 18질의 개선·악화 0건.
- Phase 1-가 구현은 완성돼 있었으나 chunkLevelRetrieval 기본값이 false 라
  실전 채팅이 열등한 파일 모드로 동작 — package.json·config 기본값 true 로.
- tests/retrievalEvalCompare.test.ts: 환경변수(ASTRA_EVAL_BRAIN) 게이트형
  A/B 회귀 측정 도구 (평소 skip — CI/패키징 무영향).

P2 — 확신도 전역화 (/meet 원칙을 모든 대화로):
- memoryContext 에 [GROUNDING] 블록 — 두뇌 근거 강도(강/보통/약)를 점수로
  평가해 답변 정책 주입: 약함 → "⚠️ 두뇌 근거 약함" 표기+단정 금지,
  강함 → 근거 문서 제목 인용, 보통 → 사실/추론 구분 서술.

P3 — 회의 용어집 자동화 + 출력 위생:
- /meet 실행마다 담당자 이름·사용자 메타데이터 용어를 .astra/meet_glossary.json
  에 누적, 다음 실행 때 자동 주입 (STT 보정 용어집 — 반복 회의 표기 일관성).
- selfIdentity 블록에 한·영 혼합 깨진 표기 금지 규칙 (전 대화, 무비용).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 18:13:11 +09:00
parent 92c92da090
commit c42c66a3fc
8 changed files with 174 additions and 7 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "astra",
"version": "2.2.217",
"version": "2.2.218",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "astra",
"version": "2.2.217",
"version": "2.2.218",
"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.217",
"version": "2.2.218",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
@@ -620,8 +620,8 @@
},
"g1nation.chunkLevelRetrieval": {
"type": "boolean",
"default": false,
"description": "섹션 청크 단위 검색 (Phase 1-가). 켜면 두뇌 검색이 파일이 아니라 '##' 헤딩 기준 섹션 청크 단위로 색인·스코어링하고 매치된 섹션을 주입합니다. 긴 다주제 문서의 검색 정밀도를 높입니다. '검색 평가 실행'으로 끄고/켜고 비교해 보세요. 기본 false."
"default": true,
"description": "섹션 청크 단위 검색 (Phase 1-가). 두뇌 검색이 파일이 아니라 '##' 헤딩 기준 섹션 청크 단위로 색인·스코어링하고 매치된 섹션을 주입합니다. 골든셋 측정에서 파일 단위 대비 recall@1 12.5%→75.0% · MRR 0.217→0.802 로 검증되어 기본 켜짐(v2.2.218). 문제 시 끄고 '검색 평가 실행'으로 비교 가능."
},
"g1nation.chunkTargetChars": {
"type": "number",
+1 -1
View File
@@ -488,7 +488,7 @@ export function getConfig(): IAgentConfig {
finalOnlyRetryOnThoughtLeak: cfg.get<boolean>('finalOnlyRetryOnThoughtLeak', true),
embeddingModel: (cfg.get<string>('embeddingModel', '') || '').trim(),
embeddingBlendAlpha: Math.max(0, Math.min(1, cfg.get<number>('embeddingBlendAlpha', 0.5))),
chunkLevelRetrieval: cfg.get<boolean>('chunkLevelRetrieval', false),
chunkLevelRetrieval: cfg.get<boolean>('chunkLevelRetrieval', true),
chunkTargetChars: Math.max(400, Math.min(4000, cfg.get<number>('chunkTargetChars', 1200))),
conflictHighlightingEnabled: cfg.get<boolean>('conflictHighlightingEnabled', true),
conflictSeverityThreshold: (cfg.get<string>('conflictSeverityThreshold', 'medium') as 'low' | 'medium' | 'high') || 'medium',
+15
View File
@@ -30,6 +30,7 @@ import {
transcriptHash, taskKey, loadRegisteredKeys, markRegistered,
savePending, loadPending, clearPending, classifyAction,
registerAction, buildNotes, parseConfirmArgs, renderPendingQuestion,
loadGlossaryTerms, updateGlossary, extractGlossaryCandidates,
type PendingItem, type PendingFile,
} from './scheduling/meetRegistration';
import {
@@ -554,6 +555,15 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
}
// 중복 방지 키 — 동일 녹취 재실행 시 이미 등록된 액션을 건너뛰기 위한 해시 (원본 전체 기준).
const tHash = transcriptHash(transcript);
const userMetadata = metadata; // 용어집 후보 추출용 — 자동 보강 전 원본 보존
// [자동 용어집] 이전 /meet 들에서 누적된 인명·용어를 메타데이터에 보강 —
// meetPrompt 가 메타데이터를 STT 보정 용어집으로 쓰므로 반복 회의의 표기
// 일관성이 자동으로 좋아진다. 사용자 입력 메타데이터가 항상 우선(앞에 배치).
const glossaryTerms = loadGlossaryTerms();
if (glossaryTerms.length) {
metadata = `${metadata ? metadata + '\n' : ''}[자동 용어집 — 이전 회의에서 누적된 인명·용어 표기] ${glossaryTerms.join(', ')}`;
chunk(view, `📚 자동 용어집 ${glossaryTerms.length}개 용어 주입\n`);
}
// v2.2.211: 60K 하드 자르기 폐지 → 세그먼트 추출(Map) + 병합(Reduce).
// 단일샷 60K 는 로컬 32K 컨텍스트에서 잘리거나 lost-in-the-middle 로 중간
// 안건이 증발하던 원인. 12K 조각별 추출은 입력이 짧아 누락·날조 둘 다 준다.
@@ -657,6 +667,11 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
chunk(view, `\nℹ️ 캘린더 자동 등록을 건너뜁니다 — Google Calendar OAuth(쓰기)가 연결되지 않았습니다. (Astra Settings → Google 섹션에서 연결)\n`);
} else {
const tasks = parseActionItems(report);
// [자동 용어집 누적] 이번 회의의 담당자 이름 + 사용자가 입력한 메타데이터
// 용어를 워크스페이스 용어집에 저장 — 다음 /meet 의 STT 보정에 자동 사용.
try {
updateGlossary([...tasks.map(t => t.owner), ...extractGlossaryCandidates(userMetadata)]);
} catch { /* 용어집 실패는 본 흐름에 영향 없음 */ }
if (tasks.length === 0) {
chunk(view, `\nℹ️ 액션 아이템이 없어 캘린더에 등록할 항목이 없습니다.\n`);
} else {
@@ -251,3 +251,38 @@ export function renderPendingQuestion(p: PendingFile): string {
export function logMeetRegistration(event: string, data: Record<string, unknown>): void {
logInfo(`/meet 등록 게이트: ${event}`, data);
}
// ── 회의 용어집 (반복 회의의 STT 보정 정확도용) ─────────────────────────────
// meetPrompt 는 메타데이터를 "용어집 역할"로 쓴다 — 매번 수동 입력하는 대신,
// 이전 /meet 실행에서 나온 인명(담당)·사용자 입력 메타데이터 용어를 워크스페이스에
// 누적하고 다음 실행 때 자동 주입한다. (.astra/meet_glossary.json)
const GLOSSARY_REL = 'meet_glossary.json';
const GLOSSARY_MAX = 120;
type Glossary = { terms: string[]; updatedAt: string };
export function loadGlossaryTerms(): string[] {
return (readJson<Glossary>(GLOSSARY_REL)?.terms || []).filter(t => typeof t === 'string' && t.trim());
}
/** 담당자 이름·메타데이터에서 뽑은 용어를 용어집에 누적 (중복 제거, 상한 유지). */
export function updateGlossary(newTerms: string[]): void {
const cleaned = newTerms
.map(t => (t || '').trim())
.filter(t => t.length >= 2 && t.length <= 30 && !/미지정|없음|확인|불명/.test(t));
if (!cleaned.length) return;
const cur = loadGlossaryTerms();
const set = new Set(cur);
for (const t of cleaned) set.add(t);
// 상한 초과 시 오래된 것부터 제거 (Set 삽입 순서 = 누적 순서)
const all = [...set];
const terms = all.length > GLOSSARY_MAX ? all.slice(all.length - GLOSSARY_MAX) : all;
writeJson(GLOSSARY_REL, { terms, updatedAt: new Date().toISOString() } satisfies Glossary);
}
/** 사용자 메타데이터 입력에서 용어 후보 추출 — 쉼표/슬래시/공백 구분 토큰 중 고유명사형. */
export function extractGlossaryCandidates(metadata: string): string[] {
return (metadata || '')
.split(/[,/·;\n]+/)
.map(t => t.replace(/^[\s:\-–·]+|[\s:\-–·]+$/g, ''))
.filter(t => t.length >= 2 && t.length <= 30 && !/^\d+$/.test(t));
}
+31 -1
View File
@@ -377,5 +377,35 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
// 살아남게.
const lessonBlock = buildLessonChecklistBlock(lessonChunks.map((c) => ({ title: c.title, content: c.content })));
const memoryBlock = deps.retrievalOrchestrator.buildContextString(result);
return [lessonBlock, memoryBlock].filter(Boolean).join('\n\n');
// [확신도 전역화] 검색 근거 강도를 평가해 답변 정책을 함께 주입 — /meet 의
// "확신 없으면 단정 대신 표시" 원칙을 모든 대화로 확장. 근거가 약한데 단정적으로
// 답하는 '그럴듯한 오답'을 구조적으로 줄인다.
const groundingBlock = buildGroundingBlock(result);
return [groundingBlock, lessonBlock, memoryBlock].filter(Boolean).join('\n\n');
}
/**
* 검색 결과의 근거 강도 → 답변 정책 블록.
* - strong (top ≥ 0.5, 두뇌 청크 ≥ 2): 두뇌 근거 기반 — 사용한 문서 제목을 인용하라.
* - moderate: 부분 근거 — 두뇌 사실과 일반 지식 추론을 구분해 서술.
* - weak (top < 0.25 또는 두뇌 청크 0): 답변 첫 줄에 "⚠️ 두뇌 근거 약함" 표기 + 단정 금지.
* 점수는 normalize 된 0~1 — 임계값은 초기치이며 골든셋으로 추후 튜닝 가능.
*/
function buildGroundingBlock(result: { selectedChunks: Array<{ source: string; score: number }> }): string {
const brainChunks = result.selectedChunks.filter((c) => c.source === 'brain-trace' || c.source === 'brain-memory');
const top = brainChunks.length ? Math.max(...brainChunks.map((c) => c.score || 0)) : 0;
let level: 'strong' | 'moderate' | 'weak';
if (brainChunks.length === 0 || top < 0.25) level = 'weak';
else if (top >= 0.5 && brainChunks.length >= 2) level = 'strong';
else level = 'moderate';
const lines = [`[GROUNDING] 이번 질의의 두뇌 근거 강도: ${level === 'strong' ? '강함' : level === 'moderate' ? '보통' : '약함'} (두뇌 청크 ${brainChunks.length}개, 최고 점수 ${top.toFixed(2)})`];
if (level === 'weak') {
lines.push('→ 답변 첫 줄에 "⚠️ 두뇌 근거 약함 — 일반 지식 기반 추정입니다." 를 표기하고, 단정 대신 "가능성/추정" 표현을 사용하라. 확실하지 않은 세부 수치·고유명사는 만들지 말 것.');
} else if (level === 'strong') {
lines.push('→ 두뇌 근거를 우선 사용하고, 답변에서 근거로 삼은 문서 제목을 인용하라 (예: "…문서에 따르면").');
} else {
lines.push('→ 두뇌에서 가져온 사실과 일반 지식 추론을 문장 단위로 구분해 서술하라. 두뇌 근거가 없는 주장에는 단정 표현을 피하라.');
}
return lines.join('\n');
}
+1
View File
@@ -22,5 +22,6 @@ export function buildSelfIdentityBlock(): string {
'- **레슨(경험 기억)**: 작업 경험에서 만들어진 교훈이 `lessons/`에 쌓여 이후 판단에 반영된다.',
'- **평가·성장 루프(Self-Evolving OS)**: 골든셋 기반 검색/업무 평가, 성장 리포트, 학습 큐(Need Engine), 지식 노후 점검(Decay), 승인된 학습의 자동 실행(Research Agent)으로 지식의 질을 측정·개선한다.',
'따라서 "스스로 성장하는가"라는 질문에는 "모델 가중치는 고정이지만, 시스템으로서 저는 위 메커니즘으로 세션을 넘어 지식·기억을 축적하며 성장합니다"가 정확한 답이다. "나는 학습하지 않는 정적 모델"이라는 일반론으로 답하지 말 것. 자신의 기능·구조에 대한 상세 질문은 두뇌의 "ASTRA 자기 아키텍처" 문서를 근거로 답하라.',
'[출력 위생] 자연스러운 한국어로 쓰고, 한 단어 안에 한글과 영문 알파벳을 섞지 마라("응다", "텍록", "결ently" 같은 깨진 합성 표기 금지). 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓴다.',
].join('\n');
}
+86
View File
@@ -0,0 +1,86 @@
/**
* 검색 모드 A/B 측정 — 파일 단위(baseline) vs 섹션 청크(Phase 1-가).
*
* 평소 테스트 런에서는 skip 된다 (실제 두뇌 폴더 + 수천 파일 인덱싱이 필요해
* CI/패키징에 부적합). 수동 실행:
*
* ASTRA_EVAL_BRAIN="E:/Wiki/2nd/10_Wiki/Topics" npx jest tests/retrievalEvalCompare.test.ts --verbose
*
* 골든셋(<brain>/.astra/eval/golden.jsonl) 기준 recall@k / MRR 을 두 모드로 측정해
* 비교표를 콘솔에 출력한다. TF-IDF 경로 기준 (임베딩은 LM 서버 의존이라 제외 —
* 청킹의 효과는 sparse 항과 발췌 품질에 먼저 나타난다).
*/
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 } from '../src/retrieval/brainIndex';
const BRAIN = (process.env.ASTRA_EVAL_BRAIN || '').trim();
const KS = [1, 3, 5];
const maybe = BRAIN && fs.existsSync(BRAIN) ? describe : describe.skip;
maybe('retrieval A/B — file vs chunk', () => {
jest.setTimeout(10 * 60_000);
test('golden set comparison', async () => {
const { entries, parseErrors } = loadGoldenSet(BRAIN);
expect(entries.length).toBeGreaterThan(0);
// 인덱스 워밍업 (양 모드 공통 전제)
const allFiles = findBrainFiles(BRAIN);
getBrainTokenIndex(BRAIN, allFiles);
const brain = { id: 'eval', name: 'EvalBrain', localBrainPath: BRAIN } as any;
const orchestrator = new RetrievalOrchestrator();
const run = (chunkMode: boolean): Promise<EvalReport> =>
runRetrievalEval({
entries,
ks: KS,
ranker: async (query: string) =>
orchestrator
.rankBrainForEval(query, brain, {
limit: Math.max(...KS) + 5,
chunkLevelRetrieval: chunkMode,
chunkTargetChars: 1200,
})
.map(r => r.relativePath),
});
const fileReport = await run(false);
const chunkReport = await run(true);
const pct = (x: number) => (x * 100).toFixed(1) + '%';
const lines: string[] = [];
lines.push('');
lines.push(`══ 검색 A/B (질의 ${entries.length}건, 파싱오류 ${parseErrors}) ══`);
lines.push(`지표 | 파일 단위 | 섹션 청크 | Δ`);
for (const k of KS) {
const a = fileReport.recallAtK[k], b = chunkReport.recallAtK[k];
lines.push(`recall@${k} | ${pct(a).padStart(7)} | ${pct(b).padStart(7)} | ${(b - a >= 0 ? '+' : '')}${pct(b - a)}`);
}
lines.push(`MRR | ${fileReport.mrr.toFixed(3).padStart(7)} | ${chunkReport.mrr.toFixed(3).padStart(7)} | ${(chunkReport.mrr - fileReport.mrr >= 0 ? '+' : '')}${(chunkReport.mrr - fileReport.mrr).toFixed(3)}`);
// 모드별 win/loss 질의
const flips: string[] = [];
fileReport.perQuery.forEach((fq, i) => {
const cq = chunkReport.perQuery[i];
const f = fq.firstHitRank, c = cq.firstHitRank;
if ((f === null) !== (c === null) || (f !== null && c !== null && f !== c)) {
flips.push(` · "${fq.query.slice(0, 38)}" 파일=#${f ?? 'miss'} → 청크=#${c ?? 'miss'}`);
}
});
if (flips.length) { lines.push('순위 변동:'); lines.push(...flips); }
// miss 진단 (청크 모드)
const misses = chunkReport.perQuery.filter(q => q.firstHitRank === null);
if (misses.length) {
lines.push(`청크 모드 miss ${misses.length}건:`);
for (const m of misses) lines.push(` ✗ "${m.query.slice(0, 38)}" → 상위: ${m.topPaths.slice(0, 3).join(' · ')}`);
}
// eslint-disable-next-line no-console
console.log(lines.join('\n'));
expect(chunkReport.total).toBe(fileReport.total);
});
});