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:
+215
-4
@@ -15,12 +15,13 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { tokenize, countConflictIndicators } from './scoring';
|
||||
import { detectLessonKind } from './lessonHelpers';
|
||||
import { splitIntoSections } from './chunker';
|
||||
import { logInfo } from '../utils';
|
||||
|
||||
// v4 adds optional per-file `embedding` for hybrid (sparse+dense) retrieval.
|
||||
// Older v3 indexes are auto-rebuilt on first load — no migration needed because
|
||||
// the cache is derivable from the brain itself.
|
||||
const INDEX_VERSION = 4;
|
||||
// v5 adds optional per-file `chunks` (section-level index, Phase 1-가) alongside the
|
||||
// v4 per-file `embedding`. Older indexes are auto-rebuilt on first load — no migration
|
||||
// needed because the cache is fully derivable from the brain itself.
|
||||
const INDEX_VERSION = 5;
|
||||
const INDEX_DIR = '.astra';
|
||||
const INDEX_FILE = 'brain-index.json';
|
||||
/** 인덱스가 이 개수를 넘으면 이번 스캔에서 못 본 항목을 정리합니다 (삭제된 파일 누적 방지). */
|
||||
@@ -45,11 +46,32 @@ interface IndexEntry {
|
||||
embedding?: number[];
|
||||
/** Embedding model the vector was produced with — invalidates the vector when the user switches models. */
|
||||
embeddingModel?: string;
|
||||
/**
|
||||
* Section-level chunks (Phase 1-가). 지연 계산 — chunk 모드 검색이 처음 요청할 때
|
||||
* `getBrainChunkIndex` 가 채운다. 파일이 바뀌면 (재색인 시 entry 가 새로 만들어져)
|
||||
* 자동으로 사라지므로 stale chunk 가 남지 않는다.
|
||||
*/
|
||||
chunks?: ChunkEntry[];
|
||||
}
|
||||
|
||||
interface ChunkEntry {
|
||||
heading: string;
|
||||
headingPath: string[];
|
||||
tokens: string[]; // tokenize(`${title} ${headingPath} ${sectionText}`) — 문서 제목이 모든 청크에 기여
|
||||
headingTokens: string[]; // tokenize(`${title} ${headingPath}`)
|
||||
charStart: number;
|
||||
charEnd: number;
|
||||
/** 청크 단위 dense 벡터 (Phase 1-가 후속). 파일 단위보다 정밀. 지연 backfill. */
|
||||
embedding?: number[];
|
||||
/** 이 벡터를 만든 임베딩 모델 — 모델 변경 시 무효화. */
|
||||
embeddingModel?: string;
|
||||
}
|
||||
|
||||
interface PersistedIndex {
|
||||
version: number;
|
||||
entries: Record<string, IndexEntry>; // keyed by absolute file path
|
||||
/** chunks 를 어떤 targetChars 로 만들었는지 — 설정이 바뀌면 chunk 층을 재생성. */
|
||||
chunkTargetChars?: number;
|
||||
}
|
||||
|
||||
export interface IndexedBrainDoc {
|
||||
@@ -64,6 +86,23 @@ export interface IndexedBrainDoc {
|
||||
kind: string;
|
||||
}
|
||||
|
||||
/** Flat chunk view returned by `getBrainChunkIndex` — 한 파일이 여러 청크로 펼쳐진다. */
|
||||
export interface IndexedChunk {
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
title: string;
|
||||
/** 파일 내 청크 순번 (0-based). */
|
||||
chunkIndex: number;
|
||||
heading: string;
|
||||
headingPath: string[];
|
||||
tokens: string[];
|
||||
headingTokens: string[];
|
||||
charStart: number;
|
||||
charEnd: number;
|
||||
mtimeMs: number;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
interface BrainState {
|
||||
index: PersistedIndex;
|
||||
dirty: boolean;
|
||||
@@ -223,6 +262,99 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* lesson/playbook/qa-finding 카드는 통째로 한 청크 — 섹션 분할이 essence 추출을
|
||||
* 깨뜨리지 않도록. 일반 노트는 `splitIntoSections` 로 섹션 청크화. 문서 제목을 모든
|
||||
* 청크 토큰에 prepend 해 제목 매치 강도(파일 모드의 titleTokens)를 보존한다.
|
||||
*/
|
||||
function buildChunkEntries(entry: IndexEntry, content: string, targetChars: number): ChunkEntry[] {
|
||||
const whole = (): ChunkEntry[] => [{
|
||||
heading: entry.title,
|
||||
headingPath: [entry.title],
|
||||
tokens: entry.tokens,
|
||||
headingTokens: entry.titleTokens,
|
||||
charStart: 0,
|
||||
charEnd: content.length,
|
||||
}];
|
||||
if (entry.kind && entry.kind !== '') return whole();
|
||||
|
||||
const sections = splitIntoSections(content, {
|
||||
targetChars,
|
||||
minChars: Math.min(200, Math.floor(targetChars / 4)),
|
||||
maxChars: targetChars * 2,
|
||||
});
|
||||
if (sections.length === 0) return whole();
|
||||
|
||||
return sections.map((s) => {
|
||||
const headingText = [entry.title, ...s.headingPath].join(' ');
|
||||
return {
|
||||
heading: s.heading || entry.title,
|
||||
headingPath: s.headingPath.length ? s.headingPath : [entry.title],
|
||||
tokens: tokenize(`${headingText} ${s.text}`),
|
||||
headingTokens: tokenize(headingText),
|
||||
charStart: s.charStart,
|
||||
charEnd: s.charEnd,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Section-level chunk view (Phase 1-가). 먼저 `getBrainTokenIndex` 로 파일 entry 를
|
||||
* 최신화한 뒤, 각 파일의 chunk 층을 (없으면) 계산·캐시해서 flat 하게 펼쳐 반환한다.
|
||||
* `targetChars` 가 직전 빌드값과 다르면 전체 chunk 캐시를 버리고 재생성한다.
|
||||
* Steady-state(변경 없음 + 같은 target)에서는 디스크/CPU 작업 0.
|
||||
*/
|
||||
export function getBrainChunkIndex(brainPath: string, files: string[], targetChars: number): IndexedChunk[] {
|
||||
if (!brainPath || !Array.isArray(files) || files.length === 0) return [];
|
||||
// 1) 파일 entry 최신화 (토큰/메타/prune 까지 여기서 처리).
|
||||
getBrainTokenIndex(brainPath, files);
|
||||
const st = loadState(brainPath);
|
||||
|
||||
// 2) targetChars 변경 시 chunk 층 전체 무효화.
|
||||
if (st.index.chunkTargetChars !== targetChars) {
|
||||
for (const key of Object.keys(st.index.entries)) {
|
||||
const e = st.index.entries[key];
|
||||
if (e) e.chunks = undefined;
|
||||
}
|
||||
st.index.chunkTargetChars = targetChars;
|
||||
st.dirty = true;
|
||||
}
|
||||
|
||||
const out: IndexedChunk[] = [];
|
||||
let built = 0;
|
||||
for (const file of files) {
|
||||
const entry = st.index.entries[file];
|
||||
if (!entry) continue;
|
||||
if (!entry.chunks) {
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
||||
entry.chunks = buildChunkEntries(entry, content, targetChars);
|
||||
st.dirty = true;
|
||||
built++;
|
||||
}
|
||||
for (let ci = 0; ci < entry.chunks.length; ci++) {
|
||||
const ch = entry.chunks[ci];
|
||||
out.push({
|
||||
filePath: file,
|
||||
relativePath: entry.relativePath,
|
||||
title: entry.title,
|
||||
chunkIndex: ci,
|
||||
heading: ch.heading,
|
||||
headingPath: ch.headingPath,
|
||||
tokens: ch.tokens,
|
||||
headingTokens: ch.headingTokens,
|
||||
charStart: ch.charStart,
|
||||
charEnd: ch.charEnd,
|
||||
mtimeMs: entry.mtimeMs,
|
||||
kind: entry.kind || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (built > 0) logInfo('Brain chunk index built.', { brainPath, files: files.length, filesChunked: built, totalChunks: out.length, targetChars });
|
||||
if (st.dirty) scheduleWrite(st, brainPath);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull (filePath, embedding) for every file in `filePaths` that has a current
|
||||
* cached vector under `model`. Caller uses this to rank top TF-IDF candidates
|
||||
@@ -310,6 +442,85 @@ export async function backfillBrainEmbeddings(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 청크 단위 임베딩 조회. `${filePath}#${chunkIndex}` → vector. 모델 불일치/미존재 청크는 생략.
|
||||
* searchBrainChunks 가 dense blend 에 사용 (파일 단위 공유보다 정밀).
|
||||
*/
|
||||
export function getBrainChunkEmbeddings(brainPath: string, model: string): Map<string, number[]> {
|
||||
const out = new Map<string, number[]>();
|
||||
if (!brainPath || !model.trim()) return out;
|
||||
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 (!Array.isArray(ch.embedding) || ch.embedding.length === 0) continue;
|
||||
out.set(`${fp}#${ci}`, ch.embedding);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Background fill — 주어진 `files` 의 청크 중 현재 모델 벡터가 없는 것만 임베딩한다.
|
||||
* 청크 텍스트는 캐시된 토큰에서 재구성(파일 단위 backfill 과 동일 전략 — 파일 재read 회피).
|
||||
* Fire-and-forget 용. 새로 임베딩한 청크 수를 반환.
|
||||
*/
|
||||
export async function backfillBrainChunkEmbeddings(
|
||||
brainPath: string,
|
||||
files: string[],
|
||||
model: string,
|
||||
embedFn: (texts: string[]) => Promise<number[][]>,
|
||||
targetChars: number,
|
||||
): Promise<number> {
|
||||
if (!brainPath || !model.trim() || !Array.isArray(files) || files.length === 0) return 0;
|
||||
// 청크 층 보장 (없으면 생성).
|
||||
getBrainChunkIndex(brainPath, files, targetChars);
|
||||
const st = _states.get(brainPath);
|
||||
if (!st) return 0;
|
||||
|
||||
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;
|
||||
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 (!text.trim()) continue;
|
||||
texts.push(text);
|
||||
refs.push({ fp, ci });
|
||||
}
|
||||
}
|
||||
if (texts.length === 0) return 0;
|
||||
try {
|
||||
const vectors = await embedFn(texts);
|
||||
let n = 0;
|
||||
for (let i = 0; i < vectors.length && i < refs.length; i++) {
|
||||
const v = vectors[i];
|
||||
if (!Array.isArray(v) || v.length === 0) continue;
|
||||
const entry = st.index.entries[refs[i].fp];
|
||||
const ch = entry?.chunks?.[refs[i].ci];
|
||||
if (!ch) continue;
|
||||
ch.embedding = v;
|
||||
ch.embeddingModel = model;
|
||||
st.dirty = true;
|
||||
n++;
|
||||
}
|
||||
if (n > 0) {
|
||||
logInfo('Brain chunk embeddings backfilled.', { brainPath, model, embedded: n });
|
||||
scheduleWrite(st, brainPath);
|
||||
}
|
||||
return n;
|
||||
} catch (e: any) {
|
||||
logInfo('Brain chunk embedding backfill failed (TF-IDF still works).', { brainPath, model, error: e?.message ?? String(e) });
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the in-memory index (and pending write) for one brain, or all brains. The disk file is left as-is. */
|
||||
export function clearBrainTokenIndex(brainPath?: string): void {
|
||||
if (brainPath === undefined) {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Markdown Section Chunker (Phase 1-가)
|
||||
*
|
||||
* 긴 노트를 `#`~`######` 헤딩 경계로 *섹션 청크* 로 나눈다. 파일 단위 색인은 5000자
|
||||
* 다주제 문서를 하나의 흐릿한 단위로 만들어 검색 정밀도를 떨어뜨린다 — 섹션 단위로
|
||||
* 쪼개면 질의가 정확히 해당 섹션에 매치된다 (제2뇌의 "문서 청킹 전략" 지식 그대로).
|
||||
*
|
||||
* 규칙:
|
||||
* - 각 헤딩 ~ 다음 헤딩 직전까지가 raw 섹션. 첫 헤딩 이전 본문(preamble)도 한 섹션.
|
||||
* - 헤딩 breadcrumb(상위 헤딩 경로)을 함께 보존 → 청크가 문맥을 잃지 않음.
|
||||
* - minChars 미만의 짧은 섹션은 다음 섹션과 병합(헤딩만 있고 본문 적은 경우 흔함).
|
||||
* - targetChars 초과 누적 시 청크 확정. maxChars 초과 단일 섹션은 문단 경계로 재분할.
|
||||
*
|
||||
* 순수 함수 (fs/네트워크 의존 없음) — 단위 테스트·재현 용이.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
export interface Section {
|
||||
/** 이 섹션의 헤딩 텍스트 ('' = preamble). */
|
||||
heading: string;
|
||||
/** 루트→자기까지 헤딩 경로 (문맥용 breadcrumb). preamble 이면 []. */
|
||||
headingPath: string[];
|
||||
/** 섹션 본문(헤딩 라인 포함, 원문 그대로). */
|
||||
text: string;
|
||||
/** 원문 내 시작/끝 문자 오프셋 (디버그/추적용). */
|
||||
charStart: number;
|
||||
charEnd: number;
|
||||
}
|
||||
|
||||
export interface ChunkOptions {
|
||||
/** 청크 목표 길이. 누적이 이 값을 넘으면 확정. 기본 1200. */
|
||||
targetChars: number;
|
||||
/** 이보다 짧은 섹션은 다음과 병합. 기본 200. */
|
||||
minChars: number;
|
||||
/** 단일 청크가 이보다 길면 문단 경계로 재분할. 기본 = targetChars * 2. */
|
||||
maxChars: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: ChunkOptions = { targetChars: 1200, minChars: 200, maxChars: 2400 };
|
||||
|
||||
interface RawSection {
|
||||
heading: string;
|
||||
headingPath: string[];
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const HEADING_RE = /^(#{1,6})[ \t]+(.+?)[ \t]*#*$/;
|
||||
|
||||
/**
|
||||
* 원문을 헤딩 경계 raw 섹션으로 분해. fenced code block(```) 안의 `#` 라인은
|
||||
* 헤딩으로 보지 않는다 (코드 주석이 섹션을 깨는 것 방지).
|
||||
*/
|
||||
function parseRawSections(content: string): RawSection[] {
|
||||
const lines = content.split('\n');
|
||||
const sections: RawSection[] = [];
|
||||
const stack: Array<{ level: number; title: string }> = [];
|
||||
|
||||
let offset = 0;
|
||||
let curStart = 0;
|
||||
let curHeading = '';
|
||||
let curPath: string[] = [];
|
||||
let inFence = false;
|
||||
let started = false;
|
||||
|
||||
const pushCurrent = (end: number) => {
|
||||
if (!started) return;
|
||||
sections.push({ heading: curHeading, headingPath: [...curPath], start: curStart, end });
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineStart = offset;
|
||||
offset += line.length + 1; // +1 for the '\n' we split on
|
||||
|
||||
const fence = line.trimStart().startsWith('```');
|
||||
if (fence) { inFence = !inFence; }
|
||||
|
||||
const m = !inFence ? line.match(HEADING_RE) : null;
|
||||
if (m) {
|
||||
// close previous section at this heading's start
|
||||
pushCurrent(lineStart);
|
||||
const level = m[1].length;
|
||||
const title = m[2].trim();
|
||||
// maintain breadcrumb stack by level
|
||||
while (stack.length && stack[stack.length - 1].level >= level) stack.pop();
|
||||
stack.push({ level, title });
|
||||
curStart = lineStart;
|
||||
curHeading = title;
|
||||
curPath = stack.map((s) => s.title);
|
||||
started = true;
|
||||
} else if (!started) {
|
||||
// preamble before the first heading
|
||||
started = true;
|
||||
curStart = 0;
|
||||
curHeading = '';
|
||||
curPath = [];
|
||||
}
|
||||
}
|
||||
pushCurrent(content.length);
|
||||
return sections.filter((s) => s.end > s.start);
|
||||
}
|
||||
|
||||
/** 긴 텍스트를 문단(\n\n) 경계로 target 길이 이하 조각으로. 단일 문단이 maxChars 초과면 하드 컷. */
|
||||
function splitLongText(text: string, target: number, maxChars: number): string[] {
|
||||
if (text.length <= maxChars) return [text];
|
||||
const paras = text.split(/\n{2,}/);
|
||||
const pieces: string[] = [];
|
||||
let buf = '';
|
||||
const flush = () => { if (buf.trim()) pieces.push(buf); buf = ''; };
|
||||
for (const para of paras) {
|
||||
if (para.length > maxChars) {
|
||||
flush();
|
||||
// hard slice a giant paragraph
|
||||
for (let i = 0; i < para.length; i += target) pieces.push(para.slice(i, i + target));
|
||||
continue;
|
||||
}
|
||||
if (buf && (buf.length + para.length + 2) > target) flush();
|
||||
buf = buf ? `${buf}\n\n${para}` : para;
|
||||
}
|
||||
flush();
|
||||
return pieces.length ? pieces : [text];
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문을 섹션 청크로 분해. 짧은 섹션 병합 + 긴 섹션 재분할 적용.
|
||||
* 결과가 비면(빈 파일 등) 전체를 한 청크로 반환.
|
||||
*/
|
||||
export function splitIntoSections(content: string, opts?: Partial<ChunkOptions>): Section[] {
|
||||
const o: ChunkOptions = { ...DEFAULTS, ...(opts || {}) };
|
||||
if (o.maxChars < o.targetChars) o.maxChars = o.targetChars * 2;
|
||||
|
||||
const raw = parseRawSections(content);
|
||||
if (raw.length === 0) {
|
||||
const t = content.trim();
|
||||
return t ? [{ heading: '', headingPath: [], text: content, charStart: 0, charEnd: content.length }] : [];
|
||||
}
|
||||
|
||||
// 1) 짧은 섹션 병합 — 연속이므로 [firstStart, lastEnd] 로 span 유지.
|
||||
const merged: RawSection[] = [];
|
||||
let buf: RawSection | null = null;
|
||||
for (const s of raw) {
|
||||
if (!buf) { buf = { ...s }; continue; }
|
||||
const bufLen = buf.end - buf.start;
|
||||
if (bufLen < o.minChars) {
|
||||
buf = { heading: buf.heading, headingPath: buf.headingPath, start: buf.start, end: s.end };
|
||||
} else {
|
||||
merged.push(buf);
|
||||
buf = { ...s };
|
||||
}
|
||||
if ((buf.end - buf.start) >= o.targetChars) { merged.push(buf); buf = null; }
|
||||
}
|
||||
if (buf) merged.push(buf);
|
||||
|
||||
// 2) 긴 섹션 재분할 + Section 객체화.
|
||||
const out: Section[] = [];
|
||||
for (const s of merged) {
|
||||
const text = content.slice(s.start, s.end);
|
||||
if (text.length <= o.maxChars) {
|
||||
out.push({ heading: s.heading, headingPath: s.headingPath, text, charStart: s.start, charEnd: s.end });
|
||||
continue;
|
||||
}
|
||||
let cursor = s.start;
|
||||
for (const piece of splitLongText(text, o.targetChars, o.maxChars)) {
|
||||
const idx = content.indexOf(piece, cursor);
|
||||
const start = idx >= 0 ? idx : cursor;
|
||||
const end = start + piece.length;
|
||||
out.push({ heading: s.heading, headingPath: s.headingPath, text: piece, charStart: start, charEnd: end });
|
||||
cursor = end;
|
||||
}
|
||||
}
|
||||
return out.filter((s) => s.text.trim().length > 0);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Retrieval Evaluation Harness
|
||||
*
|
||||
* 골든셋(질문 → 기대 문서)으로 brain 검색 품질을 recall@k / MRR 로 *결정적으로* 측정한다.
|
||||
* 청킹·re-rank·embedding alpha 등 어떤 변경이 실제로 recall 을 올렸는지 숫자로 증명하기
|
||||
* 위한 토대 — 이게 있어야 RAG 개선이 "감(感)" 이 아니라 무결성 있는 엔지니어링이 된다.
|
||||
*
|
||||
* 의도적으로 LLM 을 쓰지 않는다 (재현 가능 + 무료 + CI 가능). LLM-as-Judge 기반의
|
||||
* faithfulness/answer-relevance 평가는 후속 단계에서 별도 하니스로 추가한다.
|
||||
*
|
||||
* 골든셋 위치: <brain>/.astra/eval/golden.jsonl (한 줄당 JSON 1개)
|
||||
* { "query": "RAG 청킹 전략 비교", "expected": ["문서 청킹 전략.md"], "note": "선택" }
|
||||
* `expected` 매칭은 대소문자 무시 + 경로 suffix 매칭이라 사용자가 파일명만 적어도 된다
|
||||
* (예: "문서 청킹 전략.md" 가 "10_Wiki/Topics/Topics_Rag/문서 청킹 전략.md" 에 매치).
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export const GOLDEN_REL_JSONL = path.join('.astra', 'eval', 'golden.jsonl');
|
||||
export const GOLDEN_REL_JSON = path.join('.astra', 'eval', 'golden.json');
|
||||
|
||||
export interface GoldenEntry {
|
||||
query: string;
|
||||
/** 기대 문서 — 상대 경로 또는 파일명. 하나라도 top-k 에 들면 hit. */
|
||||
expected: string[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface PerQueryResult {
|
||||
query: string;
|
||||
expected: string[];
|
||||
/** 1-based rank of the first expected doc, or null if not in the returned ranking. */
|
||||
firstHitRank: number | null;
|
||||
/** k → 기대 문서가 top-k 안에 하나라도 있었는지. */
|
||||
hitAtK: Record<number, boolean>;
|
||||
/** 디버그용 — 검색이 실제로 반환한 상위 경로들. */
|
||||
topPaths: string[];
|
||||
}
|
||||
|
||||
export interface EvalReport {
|
||||
ks: number[];
|
||||
total: number;
|
||||
/** k → recall@k (= hit-rate, 기대 문서가 top-k 에 든 질의 비율). */
|
||||
recallAtK: Record<number, number>;
|
||||
/** Mean Reciprocal Rank — 첫 hit 의 1/rank 평균. miss 는 0. */
|
||||
mrr: number;
|
||||
perQuery: PerQueryResult[];
|
||||
}
|
||||
|
||||
/** 골든셋 작성 안내가 포함된 스캐폴드 템플릿 (jsonl — 주석 줄은 로더가 무시). */
|
||||
export const GOLDEN_TEMPLATE = [
|
||||
'// Astra 검색 평가 골든셋. 한 줄당 JSON 1개. `//` 로 시작하는 줄과 빈 줄은 무시됩니다.',
|
||||
'// query: 실제로 던질 질문. expected: 그 질문에 떠야 하는 문서(상대경로 또는 파일명) 목록.',
|
||||
'// 20~30개를 채우면 신뢰할 만한 baseline 이 됩니다. 예시 두 줄을 지우고 본인 두뇌에 맞게 작성하세요.',
|
||||
'{"query": "RAG 청킹 전략은 어떤 게 있나", "expected": ["문서 청킹 전략.md"]}',
|
||||
'{"query": "벡터 데이터베이스 어떤 걸 골라야 하나", "expected": ["벡터 데이터베이스 비교.md"]}',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
function normRel(p: string): string {
|
||||
return (p || '').replace(/\\/g, '/').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** ranked 의 한 경로가 expected 항목과 매치되는지: 정확히 같거나, suffix(파일명만 적은 경우)거나. */
|
||||
function pathMatches(rankedRel: string, expected: string): boolean {
|
||||
const a = normRel(rankedRel);
|
||||
const b = normRel(expected);
|
||||
if (!a || !b) return false;
|
||||
if (a === b) return true;
|
||||
// expected 가 파일명/부분 경로면 ranked 의 끝과 매치 (구분자 경계 존중).
|
||||
return a === b || a.endsWith('/' + b) || a.endsWith(b) && (a.length === b.length || a[a.length - b.length - 1] === '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 골든셋 로드. jsonl 우선, 없으면 json 배열. 파일이 없으면 [] 반환 (호출자가 스캐폴드 안내).
|
||||
* 깨진 줄은 건너뛴다 (한 줄 오타가 전체 평가를 막지 않도록).
|
||||
*/
|
||||
export function loadGoldenSet(brainPath: string): { entries: GoldenEntry[]; sourcePath: string | null; parseErrors: number } {
|
||||
const jsonlPath = path.join(brainPath, GOLDEN_REL_JSONL);
|
||||
const jsonPath = path.join(brainPath, GOLDEN_REL_JSON);
|
||||
|
||||
let raw = '';
|
||||
let sourcePath: string | null = null;
|
||||
if (fs.existsSync(jsonlPath)) {
|
||||
try { raw = fs.readFileSync(jsonlPath, 'utf8'); sourcePath = jsonlPath; } catch { /* fall through */ }
|
||||
}
|
||||
if (!sourcePath && fs.existsSync(jsonPath)) {
|
||||
try {
|
||||
const arr = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||
const entries = Array.isArray(arr) ? arr.filter(isValidEntry) : [];
|
||||
return { entries, sourcePath: jsonPath, parseErrors: 0 };
|
||||
} catch {
|
||||
return { entries: [], sourcePath: jsonPath, parseErrors: 1 };
|
||||
}
|
||||
}
|
||||
if (!sourcePath) return { entries: [], sourcePath: null, parseErrors: 0 };
|
||||
|
||||
const entries: GoldenEntry[] = [];
|
||||
let parseErrors = 0;
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('//') || t.startsWith('#')) continue;
|
||||
try {
|
||||
const obj = JSON.parse(t);
|
||||
if (isValidEntry(obj)) entries.push(obj);
|
||||
else parseErrors++;
|
||||
} catch {
|
||||
parseErrors++;
|
||||
}
|
||||
}
|
||||
return { entries, sourcePath, parseErrors };
|
||||
}
|
||||
|
||||
function isValidEntry(o: any): o is GoldenEntry {
|
||||
return o && typeof o.query === 'string' && o.query.trim().length > 0
|
||||
&& Array.isArray(o.expected) && o.expected.length > 0
|
||||
&& o.expected.every((e: any) => typeof e === 'string');
|
||||
}
|
||||
|
||||
/**
|
||||
* 평가 실행. `ranker` 는 한 질의에 대해 검색이 반환한 *상대 경로 랭킹(점수 내림차순)* 을
|
||||
* 돌려주는 함수다 (임베딩 배선은 호출자가 책임 → 이 모듈은 LLM/네트워크 의존 없이 순수).
|
||||
*/
|
||||
export async function runRetrievalEval(params: {
|
||||
entries: GoldenEntry[];
|
||||
ks: number[];
|
||||
ranker: (query: string) => Promise<string[]>;
|
||||
}): Promise<EvalReport> {
|
||||
const ks = [...params.ks].sort((a, b) => a - b);
|
||||
const perQuery: PerQueryResult[] = [];
|
||||
|
||||
for (const entry of params.entries) {
|
||||
let ranked: string[] = [];
|
||||
try {
|
||||
ranked = await params.ranker(entry.query);
|
||||
} catch {
|
||||
ranked = [];
|
||||
}
|
||||
let firstHitRank: number | null = null;
|
||||
for (let i = 0; i < ranked.length; i++) {
|
||||
if (entry.expected.some((exp) => pathMatches(ranked[i], exp))) {
|
||||
firstHitRank = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const hitAtK: Record<number, boolean> = {};
|
||||
for (const k of ks) hitAtK[k] = firstHitRank !== null && firstHitRank <= k;
|
||||
|
||||
perQuery.push({
|
||||
query: entry.query,
|
||||
expected: entry.expected,
|
||||
firstHitRank,
|
||||
hitAtK,
|
||||
topPaths: ranked.slice(0, Math.max(...ks, 5)),
|
||||
});
|
||||
}
|
||||
|
||||
const total = perQuery.length || 1;
|
||||
const recallAtK: Record<number, number> = {};
|
||||
for (const k of ks) {
|
||||
const hits = perQuery.filter((q) => q.hitAtK[k]).length;
|
||||
recallAtK[k] = hits / total;
|
||||
}
|
||||
const mrr = perQuery.reduce((sum, q) => sum + (q.firstHitRank ? 1 / q.firstHitRank : 0), 0) / total;
|
||||
|
||||
return { ks, total: perQuery.length, recallAtK, mrr, perQuery };
|
||||
}
|
||||
|
||||
/** 사람이 읽는 마크다운 리포트. baseline 비교를 위해 표 형태로. */
|
||||
export function formatReportMarkdown(report: EvalReport, meta: { brainName: string; dateStr: string; embeddingModel: string; alpha: number; notes?: string }): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`# Astra 검색 평가 리포트`);
|
||||
lines.push('');
|
||||
lines.push(`- 두뇌: **${meta.brainName}**`);
|
||||
lines.push(`- 일시: ${meta.dateStr}`);
|
||||
lines.push(`- 임베딩: ${meta.embeddingModel || '(없음 — TF-IDF only)'}${meta.embeddingModel ? ` · alpha=${meta.alpha}` : ''}`);
|
||||
lines.push(`- 질의 수: ${report.total}`);
|
||||
if (meta.notes) lines.push(`- 메모: ${meta.notes}`);
|
||||
lines.push('');
|
||||
lines.push(`## 종합 지표`);
|
||||
lines.push('');
|
||||
lines.push(`| 지표 | 값 |`);
|
||||
lines.push(`|---|---|`);
|
||||
for (const k of report.ks) lines.push(`| recall@${k} | ${(report.recallAtK[k] * 100).toFixed(1)}% |`);
|
||||
lines.push(`| MRR | ${report.mrr.toFixed(3)} |`);
|
||||
lines.push('');
|
||||
lines.push(`> recall@k = 기대 문서가 상위 k개 안에 든 질의 비율. MRR = 첫 정답의 1/순위 평균 (1에 가까울수록 좋음).`);
|
||||
lines.push('');
|
||||
lines.push(`## 질의별 상세`);
|
||||
lines.push('');
|
||||
lines.push(`| # | 질의 | 첫 정답 순위 | top-k hit | 기대 문서 |`);
|
||||
lines.push(`|---|---|---|---|---|`);
|
||||
report.perQuery.forEach((q, i) => {
|
||||
const rank = q.firstHitRank ? `#${q.firstHitRank}` : '**miss**';
|
||||
const kHits = report.ks.map((k) => q.hitAtK[k] ? `@${k}✓` : `@${k}✗`).join(' ');
|
||||
const exp = q.expected.join(', ').replace(/\|/g, '\\|');
|
||||
const query = q.query.replace(/\|/g, '\\|').slice(0, 60);
|
||||
lines.push(`| ${i + 1} | ${query} | ${rank} | ${kHits} | ${exp} |`);
|
||||
});
|
||||
lines.push('');
|
||||
|
||||
// miss 한 질의는 무엇이 떴는지 별도로 — 골든셋 수정 vs 엔진 개선을 가르는 진단.
|
||||
const misses = report.perQuery.filter((q) => q.firstHitRank === null);
|
||||
if (misses.length > 0) {
|
||||
lines.push(`## Miss 진단 (top 결과가 기대와 어긋난 질의)`);
|
||||
lines.push('');
|
||||
for (const q of misses) {
|
||||
lines.push(`- **${q.query}**`);
|
||||
lines.push(` - 기대: ${q.expected.join(', ')}`);
|
||||
lines.push(` - 실제 상위: ${q.topPaths.length ? q.topPaths.slice(0, 5).join(' · ') : '(검색 결과 없음)'}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
+201
-3
@@ -21,7 +21,7 @@ import { MemoryManager } from '../memory';
|
||||
import { RetrievalChunk, RetrievalResult, ContextBudgetConfig } from './types';
|
||||
import { tokenize, expandQuery, scoreTfIdfPreTokenized, extractBestExcerpt, extractBestSection } from './scoring';
|
||||
import { selectWithinBudget, assembleContext, estimateTokens } from './contextBudget';
|
||||
import { getBrainTokenIndex, getBrainEmbeddings } from './brainIndex';
|
||||
import { getBrainTokenIndex, getBrainEmbeddings, getBrainChunkIndex, getBrainChunkEmbeddings } from './brainIndex';
|
||||
import { extractLessonEssence } from './lessonHelpers';
|
||||
import { cosineSimilarity } from './embeddings';
|
||||
import { applyActionabilityBoost, WorkStateSignals, ActionabilityWeights } from './actionabilityScoring';
|
||||
@@ -97,6 +97,14 @@ interface RetrievalOptions {
|
||||
hierarchicalReweightEnabled?: boolean;
|
||||
/** Hierarchical 가중치 override. undefined 면 default. */
|
||||
hierarchicalWeights?: HierarchicalWeights;
|
||||
/**
|
||||
* Section-level chunking (Phase 1-가). true 면 brain 검색이 파일이 아니라 섹션 청크
|
||||
* 단위로 색인·스코어링하고, 매치된 *섹션* 을 그대로 주입한다. false/undefined 면 기존
|
||||
* 파일 단위 동작.
|
||||
*/
|
||||
chunkLevelRetrieval?: boolean;
|
||||
/** 섹션 청크 목표 길이(문자). 기본 1200. chunkLevelRetrieval 일 때만 사용. */
|
||||
chunkTargetChars?: number;
|
||||
}
|
||||
|
||||
export class RetrievalOrchestrator {
|
||||
@@ -129,7 +137,9 @@ export class RetrievalOrchestrator {
|
||||
scopeFolders,
|
||||
options.queryEmbedding,
|
||||
options.embeddingModel,
|
||||
options.embeddingBlendAlpha
|
||||
options.embeddingBlendAlpha,
|
||||
options.chunkLevelRetrieval || false,
|
||||
options.chunkTargetChars ?? 1200,
|
||||
)
|
||||
: [];
|
||||
allChunks.push(...brainChunks);
|
||||
@@ -213,6 +223,58 @@ export class RetrievalOrchestrator {
|
||||
return assembleContext(result.selectedChunks);
|
||||
}
|
||||
|
||||
/**
|
||||
* 평가 전용 — 한 질의에 대한 brain 파일 랭킹(점수 내림차순)을 *context budget 적용 전*
|
||||
* 으로 반환한다. recall@k / MRR 계산용. 프로덕션 `retrieve()` 와 동일한 scoring 경로
|
||||
* (`searchBrainFiles`) 를 그대로 재사용하므로, 측정값이 실제 검색 동작을 반영한다 (무결성).
|
||||
*/
|
||||
public rankBrainForEval(
|
||||
query: string,
|
||||
brain: BrainProfile,
|
||||
opts: {
|
||||
limit?: number;
|
||||
scopeFolders?: string[];
|
||||
includeRawConversations?: boolean;
|
||||
queryEmbedding?: number[];
|
||||
embeddingModel?: string;
|
||||
embeddingBlendAlpha?: number;
|
||||
chunkLevelRetrieval?: boolean;
|
||||
chunkTargetChars?: number;
|
||||
} = {},
|
||||
): Array<{ relativePath: string; filePath: string; score: number }> {
|
||||
const limit = opts.limit ?? 20;
|
||||
const expandedTokens = expandQuery(tokenize(query));
|
||||
// chunk 모드는 파일당 여러 청크를 반환하므로, recall 을 *파일 단위* 로 측정하려면
|
||||
// 넉넉히 받아 dedup 한다 (limit 개의 고유 파일 확보).
|
||||
const internalLimit = opts.chunkLevelRetrieval ? limit * 3 : limit;
|
||||
const chunks = this.searchBrainFiles(
|
||||
query,
|
||||
expandedTokens,
|
||||
brain,
|
||||
internalLimit,
|
||||
opts.includeRawConversations ?? false,
|
||||
opts.scopeFolders ?? [],
|
||||
opts.queryEmbedding,
|
||||
opts.embeddingModel,
|
||||
opts.embeddingBlendAlpha,
|
||||
opts.chunkLevelRetrieval || false,
|
||||
opts.chunkTargetChars ?? 1200,
|
||||
);
|
||||
// dedup by file, 점수 내림차순 순서 유지 → 파일 단위 랭킹.
|
||||
const out: Array<{ relativePath: string; filePath: string; score: number }> = [];
|
||||
const seen = new Set<string>();
|
||||
const brainRoot = brain.localBrainPath;
|
||||
for (const c of chunks) {
|
||||
const filePath = (c.metadata.filePath as string) || '';
|
||||
if (!filePath || seen.has(filePath)) continue;
|
||||
seen.add(filePath);
|
||||
const relativePath = filePath ? (path.relative(brainRoot, filePath) || c.title) : c.title;
|
||||
out.push({ relativePath, filePath, score: c.score });
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Brain File Search ───
|
||||
|
||||
private searchBrainFiles(
|
||||
@@ -225,16 +287,29 @@ export class RetrievalOrchestrator {
|
||||
queryEmbedding?: number[],
|
||||
embeddingModel?: string,
|
||||
embeddingBlendAlpha?: number,
|
||||
chunkLevel: boolean = false,
|
||||
chunkTargetChars: number = 1200,
|
||||
): RetrievalChunk[] {
|
||||
try {
|
||||
const scoped = (file: string) => scopeFolders.length === 0
|
||||
|| scopeFolders.some((folder) => isInside(folder, file));
|
||||
const allFiles = findBrainFiles(brain.localBrainPath)
|
||||
.filter(scoped)
|
||||
.filter((file) => includeRaw || !this.isRawConversation(path.relative(brain.localBrainPath, file)));
|
||||
.filter((file) => {
|
||||
const rel = path.relative(brain.localBrainPath, file);
|
||||
return (includeRaw || !this.isRawConversation(rel)) && !this.isOperationalPath(rel);
|
||||
});
|
||||
|
||||
if (allFiles.length === 0) return [];
|
||||
|
||||
// Phase 1-가: 섹션 청크 단위 검색 경로. 파일 단위와 분리해 회귀 위험 격리.
|
||||
if (chunkLevel) {
|
||||
return this.searchBrainChunks(
|
||||
expandedTokens, brain, allFiles, limit, chunkTargetChars,
|
||||
queryEmbedding, embeddingModel, embeddingBlendAlpha,
|
||||
);
|
||||
}
|
||||
|
||||
// Tokenized docs from the persistent mtime-keyed index — unchanged files are not re-read
|
||||
// or re-tokenized, so per-query work over a large brain drops from O(total content) to O(files) stats.
|
||||
const indexed = getBrainTokenIndex(brain.localBrainPath, allFiles);
|
||||
@@ -343,6 +418,118 @@ export class RetrievalOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Brain Chunk Search (Phase 1-가) ───
|
||||
|
||||
/**
|
||||
* 섹션 청크 단위 검색. 파일 단위 `searchBrainFiles` 와 동일한 TF-IDF scoring 을
|
||||
* *청크* 에 적용하고, 매치된 섹션 본문을 그대로 발췌(파일 모드의 read-time
|
||||
* extractBestSection 불필요). dense blend 는 v1 에서 파일 단위 임베딩을 그 파일의
|
||||
* 모든 청크에 공유 적용한다(청크별 임베딩은 후속 단계). 한 파일이 결과를 독식하지
|
||||
* 않도록 파일당 청크 수를 제한한다.
|
||||
*/
|
||||
private searchBrainChunks(
|
||||
expandedTokens: string[],
|
||||
brain: BrainProfile,
|
||||
allFiles: string[],
|
||||
limit: number,
|
||||
chunkTargetChars: number,
|
||||
queryEmbedding?: number[],
|
||||
embeddingModel?: string,
|
||||
embeddingBlendAlpha?: number,
|
||||
): RetrievalChunk[] {
|
||||
const chunks = getBrainChunkIndex(brain.localBrainPath, allFiles, chunkTargetChars);
|
||||
if (chunks.length === 0) return [];
|
||||
|
||||
const scored = scoreTfIdfPreTokenized(
|
||||
expandedTokens,
|
||||
chunks.map((c) => ({
|
||||
tokens: c.tokens,
|
||||
titleTokens: c.headingTokens,
|
||||
lastModified: c.mtimeMs,
|
||||
conflictCount: 0,
|
||||
})),
|
||||
);
|
||||
|
||||
// Hybrid: 청크 단위 임베딩(`${filePath}#${chunkIndex}`)으로 dense blend. 청크 벡터가
|
||||
// 아직 없는 항목은 파일 단위 임베딩으로 fallback → 둘 다 없으면 순수 TF-IDF 유지.
|
||||
if (queryEmbedding && embeddingModel && (embeddingBlendAlpha ?? 0) > 0) {
|
||||
const alpha = Math.max(0, Math.min(1, embeddingBlendAlpha!));
|
||||
const chunkEmb = getBrainChunkEmbeddings(brain.localBrainPath, embeddingModel);
|
||||
const filePaths = Array.from(new Set(chunks.map((c) => c.filePath)));
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ranked = scored.filter((x) => x.score > 0).sort((a, b) => b.score - a.score);
|
||||
|
||||
// 파일당 청크 상한 — 한 문서가 top 슬롯을 독식하지 않게.
|
||||
const PER_FILE_CAP = 3;
|
||||
const perFile = new Map<string, number>();
|
||||
const chosen: typeof ranked = [];
|
||||
for (const s of ranked) {
|
||||
const fp = chunks[s.index].filePath;
|
||||
const n = perFile.get(fp) || 0;
|
||||
if (n >= PER_FILE_CAP) continue;
|
||||
perFile.set(fp, n + 1);
|
||||
chosen.push(s);
|
||||
if (chosen.length >= limit) break;
|
||||
}
|
||||
|
||||
const fileContentCache = new Map<string, string>();
|
||||
const readFile = (fp: string): string => {
|
||||
let c = fileContentCache.get(fp);
|
||||
if (c === undefined) {
|
||||
try { c = fs.readFileSync(fp, 'utf8'); } catch { c = ''; }
|
||||
fileContentCache.set(fp, c);
|
||||
}
|
||||
return c;
|
||||
};
|
||||
|
||||
const topResults: RetrievalChunk[] = [];
|
||||
for (const s of chosen) {
|
||||
const c = chunks[s.index];
|
||||
const content = readFile(c.filePath);
|
||||
if (!content) continue;
|
||||
const isLesson = (c.kind || '') !== '';
|
||||
// 일반 노트: 매치된 섹션 본문 그대로. lesson 카드: 통째 청크라 essence 추출 유지.
|
||||
let body = isLesson
|
||||
? (extractLessonEssence(content, 1200) || content.slice(c.charStart, c.charEnd))
|
||||
: content.slice(c.charStart, c.charEnd);
|
||||
const cap = isLesson ? 1200 : 700;
|
||||
// 섹션 breadcrumb 을 본문 맨 앞에 — 모델이 어느 맥락의 섹션인지 알도록.
|
||||
const crumb = !isLesson && c.headingPath.length ? `〔${c.headingPath.join(' › ')}〕\n` : '';
|
||||
body = crumb + body.trim();
|
||||
topResults.push({
|
||||
id: `brain-chunk-${s.index}`,
|
||||
source: 'brain-memory' as const,
|
||||
title: c.relativePath,
|
||||
content: summarizeText(body, cap + crumb.length),
|
||||
score: s.score,
|
||||
tokenEstimate: estimateTokens(body),
|
||||
metadata: {
|
||||
filePath: c.filePath,
|
||||
category: this.inferCategory(c.relativePath),
|
||||
isProjectEvidence: this.isProjectEvidence(c.relativePath, content),
|
||||
lastUpdated: c.mtimeMs,
|
||||
conflictDetected: s.conflictDetected,
|
||||
conflictSeverity: s.conflictSeverity,
|
||||
queryCoverage: s.queryCoverage,
|
||||
...(isLesson ? { isLesson: true, lessonKind: c.kind } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
return topResults;
|
||||
}
|
||||
|
||||
// ─── Memory Layer Search ───
|
||||
|
||||
private searchMemoryLayers(
|
||||
@@ -531,6 +718,17 @@ export class RetrievalOrchestrator {
|
||||
return /(^|[\\/])(00_Raw|raw-data|conversations?|transcripts?)([\\/]|$)/i.test(relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영(operational) 로그 — 지식이 아니라 세션/메모리/프로젝트 로그. 사용자 wiki taxonomy
|
||||
* 에 정의된 폴더 fragment 들. 지식 검색에서 제외한다 (= raw 대화와 동일 취급). recall 지표를
|
||||
* 올리진 않지만, 로그를 "지식"으로 끌어오는 의미적 오류와 인덱스/토큰 낭비를 막는다.
|
||||
*/
|
||||
private isOperationalPath(relativePath: string): boolean {
|
||||
return /(^|[\\/])(sessions|_agents|_company|memory|Project_Logs|_Archive_Orphans|Post_Drafts|UX_Scenarios)([\\/])/i.test(relativePath)
|
||||
|| /docs[\\/]records([\\/]|$)/i.test(relativePath)
|
||||
|| /Harness_Research_/i.test(relativePath);
|
||||
}
|
||||
|
||||
private inferCategory(relativePath: string): string {
|
||||
const normalized = relativePath.toLowerCase();
|
||||
if (/(decisions?|adr|planning)/i.test(normalized)) return 'decision';
|
||||
|
||||
Reference in New Issue
Block a user