feat: integrate unified RAG pipeline and bump version to 2.60.0
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Context Budget Manager (컨텍스트 예산 관리)
|
||||
*
|
||||
* 시스템 프롬프트의 토큰 예산을 관리하여
|
||||
* 로컬 모델의 context window를 효율적으로 활용합니다.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import { RetrievalChunk, ContextBudgetConfig } from './types';
|
||||
|
||||
const DEFAULT_BUDGET: ContextBudgetConfig = {
|
||||
totalBudget: 8000, // ~32K context 중 retrieval에 할당
|
||||
retrievalRatio: 0.4, // 40%
|
||||
minChunks: 2,
|
||||
maxChunks: 12
|
||||
};
|
||||
|
||||
/**
|
||||
* 토큰 수를 대략 추정합니다 (문자 수 / 4).
|
||||
* 한국어는 글자당 토큰이 더 많으므로 보정합니다.
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
// 한국어 비율 추정
|
||||
const koreanChars = (text.match(/[가-힣]/g) || []).length;
|
||||
const totalChars = text.length;
|
||||
const koreanRatio = totalChars > 0 ? koreanChars / totalChars : 0;
|
||||
|
||||
// 한국어는 글자당 ~1.5 토큰, 영어는 ~0.25 토큰
|
||||
const koreanTokens = koreanChars * 1.5;
|
||||
const otherTokens = (totalChars - koreanChars) * 0.25;
|
||||
|
||||
return Math.ceil(koreanTokens + otherTokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 결과 청크들을 토큰 예산 내에서 선택합니다.
|
||||
*
|
||||
* 선택 전략:
|
||||
* 1. 스코어 내림차순 정렬
|
||||
* 2. 중복 제거 (같은 filePath를 가진 청크)
|
||||
* 3. 토큰 예산 내에서 순서대로 선택
|
||||
* 4. 최소 청크 수 보장
|
||||
*/
|
||||
export function selectWithinBudget(
|
||||
chunks: RetrievalChunk[],
|
||||
config: Partial<ContextBudgetConfig> = {}
|
||||
): { selected: RetrievalChunk[]; dropped: RetrievalChunk[]; tokensUsed: number } {
|
||||
const cfg = { ...DEFAULT_BUDGET, ...config };
|
||||
const budget = Math.floor(cfg.totalBudget * cfg.retrievalRatio);
|
||||
|
||||
// 1. Sort by score descending
|
||||
const sorted = [...chunks].sort((a, b) => b.score - a.score);
|
||||
|
||||
// 2. Deduplicate by filePath
|
||||
const seen = new Set<string>();
|
||||
const deduped = sorted.filter((chunk) => {
|
||||
const key = chunk.metadata.filePath || chunk.id;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 3. Select within budget
|
||||
const selected: RetrievalChunk[] = [];
|
||||
const dropped: RetrievalChunk[] = [];
|
||||
let tokensUsed = 0;
|
||||
|
||||
for (const chunk of deduped) {
|
||||
const chunkTokens = chunk.tokenEstimate || estimateTokens(chunk.content);
|
||||
|
||||
if (selected.length >= cfg.maxChunks) {
|
||||
dropped.push(chunk);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tokensUsed + chunkTokens > budget && selected.length >= cfg.minChunks) {
|
||||
dropped.push(chunk);
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.push(chunk);
|
||||
tokensUsed += chunkTokens;
|
||||
}
|
||||
|
||||
return { selected, dropped, tokensUsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 청크들을 하나의 컨텍스트 문자열로 조립합니다.
|
||||
* 소스별로 그룹화하여 가독성을 높입니다.
|
||||
*/
|
||||
export function assembleContext(chunks: RetrievalChunk[]): string {
|
||||
if (chunks.length === 0) return '';
|
||||
|
||||
const sourceLabels: Record<string, string> = {
|
||||
'brain-trace': '📚 Second Brain Knowledge',
|
||||
'brain-memory': '📚 Brain Knowledge',
|
||||
'long-term-memory': '🧠 Long-Term Memory (사용자 규칙/결정)',
|
||||
'project-memory': '📂 Project Memory (프로젝트 컨텍스트)',
|
||||
'procedural-memory': '📋 Procedural Memory (반복 절차)',
|
||||
'episodic-memory': '📖 Episodic Memory (과거 대화 흐름)',
|
||||
'project-scan': '🔍 Project Scan',
|
||||
'recent-knowledge': '📄 Recent Project Knowledge'
|
||||
};
|
||||
|
||||
// Group by source
|
||||
const groups = new Map<string, RetrievalChunk[]>();
|
||||
for (const chunk of chunks) {
|
||||
const key = chunk.source;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(chunk);
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
for (const [source, groupChunks] of groups) {
|
||||
const label = sourceLabels[source] || source;
|
||||
const items = groupChunks
|
||||
.map((c) => `- ${c.title}: ${c.content}`)
|
||||
.join('\n');
|
||||
sections.push(`### ${label}\n${items}`);
|
||||
}
|
||||
|
||||
return [
|
||||
'[MEMORY CONTEXT]',
|
||||
'Review this layered memory before preparing the answer. Use it only when relevant, and prefer the current user request when there is conflict.',
|
||||
'',
|
||||
sections.join('\n\n')
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* RetrievalOrchestrator — Unified RAG Pipeline
|
||||
*
|
||||
* Astra의 모든 검색 소스를 통합 관리하는 오케스트레이터입니다.
|
||||
*
|
||||
* 검색 흐름:
|
||||
* ① Query Planning — 의도 분류 + 검색 전략 결정
|
||||
* ② Parallel Search — Brain + Memory + Project + Episode 동시 검색
|
||||
* ③ Result Fusion — 통합 스코어링 + 중복 제거
|
||||
* ④ Context Budget — 토큰 예산 내에서 최종 선택
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { BrainProfile } from '../config';
|
||||
import { findBrainFiles, summarizeText } from '../utils';
|
||||
import { MemoryManager } from '../memory';
|
||||
import { RetrievalChunk, RetrievalResult, ContextBudgetConfig } from './types';
|
||||
import { tokenize, expandQuery, scoreTfIdf, extractBestExcerpt } from './scoring';
|
||||
import { selectWithinBudget, assembleContext, estimateTokens } from './contextBudget';
|
||||
|
||||
export { tokenize, expandQuery, scoreTfIdf, extractBestExcerpt } from './scoring';
|
||||
export { selectWithinBudget, assembleContext, estimateTokens } from './contextBudget';
|
||||
export * from './types';
|
||||
|
||||
interface RetrievalOptions {
|
||||
brain: BrainProfile;
|
||||
memoryManager: MemoryManager;
|
||||
workspacePath?: string;
|
||||
chatHistory?: Array<{ role: string; content: string }>;
|
||||
contextBudget?: Partial<ContextBudgetConfig>;
|
||||
brainFileLimit?: number;
|
||||
includeRawConversations?: boolean;
|
||||
}
|
||||
|
||||
export class RetrievalOrchestrator {
|
||||
/**
|
||||
* 통합 검색을 수행합니다.
|
||||
* 모든 소스에서 검색 → TF-IDF 스코어링 → 중복 제거 → 예산 내 선택
|
||||
*/
|
||||
public retrieve(query: string, options: RetrievalOptions): RetrievalResult {
|
||||
const fusionLog: string[] = [];
|
||||
const allChunks: RetrievalChunk[] = [];
|
||||
const queryTokens = tokenize(query);
|
||||
const expandedTokens = expandQuery(queryTokens);
|
||||
|
||||
fusionLog.push(`Query tokens: [${queryTokens.slice(0, 10).join(', ')}]`);
|
||||
fusionLog.push(`Expanded tokens: [${expandedTokens.slice(0, 15).join(', ')}]`);
|
||||
|
||||
// ── ① Brain File Search (TF-IDF enhanced) ──
|
||||
const brainChunks = this.searchBrainFiles(
|
||||
query,
|
||||
expandedTokens,
|
||||
options.brain,
|
||||
options.brainFileLimit || 8,
|
||||
options.includeRawConversations || false
|
||||
);
|
||||
allChunks.push(...brainChunks);
|
||||
fusionLog.push(`Brain search: ${brainChunks.length} chunks found`);
|
||||
|
||||
// ── ② Memory Layers ──
|
||||
const memoryChunks = this.searchMemoryLayers(
|
||||
query,
|
||||
options.memoryManager,
|
||||
options.chatHistory || [],
|
||||
options.workspacePath
|
||||
);
|
||||
allChunks.push(...memoryChunks);
|
||||
fusionLog.push(`Memory search: ${memoryChunks.length} chunks found`);
|
||||
|
||||
// ── ③ Result Fusion — normalize scores across sources ──
|
||||
this.normalizeScores(allChunks);
|
||||
fusionLog.push(`Total chunks before budget: ${allChunks.length}`);
|
||||
|
||||
// ── ④ Context Budget Selection ──
|
||||
const { selected, dropped, tokensUsed } = selectWithinBudget(
|
||||
allChunks,
|
||||
options.contextBudget
|
||||
);
|
||||
fusionLog.push(`Selected: ${selected.length}, Dropped: ${dropped.length}, Tokens: ${tokensUsed}`);
|
||||
|
||||
return {
|
||||
query,
|
||||
totalChunks: allChunks.length,
|
||||
selectedChunks: selected,
|
||||
droppedChunks: dropped,
|
||||
totalTokensUsed: tokensUsed,
|
||||
contextBudget: options.contextBudget?.totalBudget || 8000,
|
||||
fusionLog
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 결과를 최종 컨텍스트 문자열로 변환합니다.
|
||||
*/
|
||||
public buildContextString(result: RetrievalResult): string {
|
||||
return assembleContext(result.selectedChunks);
|
||||
}
|
||||
|
||||
// ─── Brain File Search ───
|
||||
|
||||
private searchBrainFiles(
|
||||
query: string,
|
||||
expandedTokens: string[],
|
||||
brain: BrainProfile,
|
||||
limit: number,
|
||||
includeRaw: boolean
|
||||
): RetrievalChunk[] {
|
||||
try {
|
||||
const allFiles = findBrainFiles(brain.localBrainPath)
|
||||
.filter((file) => includeRaw || !this.isRawConversation(path.relative(brain.localBrainPath, file)));
|
||||
|
||||
if (allFiles.length === 0) return [];
|
||||
|
||||
// Read all files for TF-IDF
|
||||
const documents = allFiles.map((file) => {
|
||||
let content = '';
|
||||
let lastModified = 0;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf8');
|
||||
lastModified = fs.statSync(file).mtimeMs;
|
||||
} catch { /* skip */ }
|
||||
return {
|
||||
title: path.basename(file, '.md'),
|
||||
content,
|
||||
lastModified,
|
||||
filePath: file,
|
||||
relativePath: path.relative(brain.localBrainPath, file)
|
||||
};
|
||||
});
|
||||
|
||||
// TF-IDF scoring
|
||||
const scored = scoreTfIdf(expandedTokens, documents);
|
||||
|
||||
return scored
|
||||
.filter((s) => s.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map((scored) => {
|
||||
const doc = documents[scored.index];
|
||||
const excerpt = extractBestExcerpt(doc.content, expandedTokens, 400);
|
||||
return {
|
||||
id: `brain-${scored.index}`,
|
||||
source: 'brain-memory' as const,
|
||||
title: doc.relativePath,
|
||||
content: summarizeText(excerpt, 400),
|
||||
score: scored.score,
|
||||
tokenEstimate: estimateTokens(excerpt),
|
||||
metadata: {
|
||||
filePath: doc.filePath,
|
||||
category: this.inferCategory(doc.relativePath),
|
||||
isProjectEvidence: this.isProjectEvidence(doc.relativePath, doc.content),
|
||||
lastUpdated: doc.lastModified
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Memory Layer Search ───
|
||||
|
||||
private searchMemoryLayers(
|
||||
query: string,
|
||||
memoryManager: MemoryManager,
|
||||
chatHistory: Array<{ role: string; content: string }>,
|
||||
workspacePath?: string
|
||||
): RetrievalChunk[] {
|
||||
const chunks: RetrievalChunk[] = [];
|
||||
|
||||
// Long-Term Memory
|
||||
const ltm = memoryManager.getLongTermMemory();
|
||||
const ltmContext = ltm.buildContext(query);
|
||||
if (ltmContext) {
|
||||
chunks.push({
|
||||
id: 'ltm-context',
|
||||
source: 'long-term-memory',
|
||||
title: ltmContext.label,
|
||||
content: ltmContext.content,
|
||||
score: ltmContext.relevance,
|
||||
tokenEstimate: estimateTokens(ltmContext.content),
|
||||
metadata: { category: 'long-term' }
|
||||
});
|
||||
}
|
||||
|
||||
// Project Memory
|
||||
if (workspacePath) {
|
||||
const pm = memoryManager.getProjectMemory(workspacePath);
|
||||
const pmContext = pm.buildContext(query);
|
||||
if (pmContext) {
|
||||
chunks.push({
|
||||
id: 'pm-context',
|
||||
source: 'project-memory',
|
||||
title: pmContext.label,
|
||||
content: pmContext.content,
|
||||
score: pmContext.relevance,
|
||||
tokenEstimate: estimateTokens(pmContext.content),
|
||||
metadata: { category: 'project', isProjectEvidence: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Procedural Memory
|
||||
const proc = memoryManager.getProceduralMemory();
|
||||
const procContext = proc.buildContext(query);
|
||||
if (procContext) {
|
||||
chunks.push({
|
||||
id: 'proc-context',
|
||||
source: 'procedural-memory',
|
||||
title: procContext.label,
|
||||
content: procContext.content,
|
||||
score: procContext.relevance,
|
||||
tokenEstimate: estimateTokens(procContext.content),
|
||||
metadata: { category: 'procedural' }
|
||||
});
|
||||
}
|
||||
|
||||
// Episodic Memory
|
||||
const ep = memoryManager.getEpisodicMemory();
|
||||
const epContext = ep.buildContext(query);
|
||||
if (epContext) {
|
||||
chunks.push({
|
||||
id: 'ep-context',
|
||||
source: 'episodic-memory',
|
||||
title: epContext.label,
|
||||
content: epContext.content,
|
||||
score: epContext.relevance,
|
||||
tokenEstimate: estimateTokens(epContext.content),
|
||||
metadata: { category: 'episodic' }
|
||||
});
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// ─── Score Normalization ───
|
||||
|
||||
/**
|
||||
* 서로 다른 스코어 스케일을 가진 소스들의 점수를 0~1로 정규화합니다.
|
||||
*/
|
||||
private normalizeScores(chunks: RetrievalChunk[]): void {
|
||||
// Group by source
|
||||
const groups = new Map<string, RetrievalChunk[]>();
|
||||
for (const chunk of chunks) {
|
||||
if (!groups.has(chunk.source)) groups.set(chunk.source, []);
|
||||
groups.get(chunk.source)!.push(chunk);
|
||||
}
|
||||
|
||||
// Normalize each group independently
|
||||
for (const [, group] of groups) {
|
||||
const maxScore = Math.max(...group.map((c) => c.score), 0.001);
|
||||
for (const chunk of group) {
|
||||
chunk.score = chunk.score / maxScore;
|
||||
}
|
||||
}
|
||||
|
||||
// Source priority boost (some sources are inherently more valuable for RAG)
|
||||
const sourceBoost: Record<string, number> = {
|
||||
'brain-trace': 1.0,
|
||||
'brain-memory': 0.9,
|
||||
'project-memory': 0.85,
|
||||
'long-term-memory': 0.8,
|
||||
'procedural-memory': 0.95, // Procedural is highly specific
|
||||
'episodic-memory': 0.7,
|
||||
'project-scan': 0.6,
|
||||
'recent-knowledge': 0.75
|
||||
};
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const boost = sourceBoost[chunk.source] || 0.5;
|
||||
chunk.score *= boost;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
private isRawConversation(relativePath: string): boolean {
|
||||
return /(^|[\\/])(00_Raw|raw-data|conversations?|transcripts?)([\\/]|$)/i.test(relativePath);
|
||||
}
|
||||
|
||||
private inferCategory(relativePath: string): string {
|
||||
const normalized = relativePath.toLowerCase();
|
||||
if (/(decisions?|adr|planning)/i.test(normalized)) return 'decision';
|
||||
if (/(records|development|bugs)/i.test(normalized)) return 'project-record';
|
||||
if (/(architecture|design|pattern)/i.test(normalized)) return 'architecture';
|
||||
if (/(knowledge|wiki|topics)/i.test(normalized)) return 'knowledge';
|
||||
return 'general';
|
||||
}
|
||||
|
||||
private isProjectEvidence(relativePath: string, content: string): boolean {
|
||||
const normalized = relativePath.toLowerCase();
|
||||
if (/(records|planning|development|bugs|retrospectives|projectchronicle)/i.test(normalized)) return true;
|
||||
if (/adr-\d+|(^|[\\/])decisions?([\\/]|$)/i.test(normalized)) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Scoring Engine — TF-IDF + Bilingual Tokenizer
|
||||
*
|
||||
* 단순 includes() 키워드 매칭을 넘어서,
|
||||
* TF-IDF 가중치 기반의 문서 스코어링을 제공합니다.
|
||||
* 한국어/영어 양국어 토크나이저를 포함합니다.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
// ─── Bilingual Tokenizer ───
|
||||
|
||||
const STOP_WORDS_EN = new Set([
|
||||
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
||||
'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been',
|
||||
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
|
||||
'should', 'may', 'might', 'can', 'this', 'that', 'these', 'those',
|
||||
'it', 'its', 'not', 'no', 'what', 'how', 'when', 'where', 'which',
|
||||
'who', 'whom', 'why', 'if', 'then', 'than', 'so', 'as', 'just',
|
||||
'about', 'also', 'more', 'some', 'very', 'all', 'each', 'every',
|
||||
'such', 'please', 'write', 'use', 'using', 'used'
|
||||
]);
|
||||
|
||||
const STOP_WORDS_KO = new Set([
|
||||
'그리고', '그런데', '그래서', '하지만', '또한', '또는', '해서', '하는',
|
||||
'있어', '없어', '아래', '위에', '어떻게', '이것', '저것', '그것',
|
||||
'이런', '저런', '그런', '여기', '거기', '필요', '사용', '관련',
|
||||
'대한', '대해', '통해', '따라', '위해', '대로', '만큼'
|
||||
]);
|
||||
|
||||
/**
|
||||
* 한국어/영어 혼합 텍스트를 토큰으로 분리합니다.
|
||||
*/
|
||||
export function tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9가-힣_.-]+/g)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length >= 2)
|
||||
.filter((t) => !STOP_WORDS_EN.has(t) && !STOP_WORDS_KO.has(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* 동의어/관련어 확장을 수행합니다.
|
||||
*/
|
||||
export function expandQuery(tokens: string[]): string[] {
|
||||
const synonymMap: Record<string, string[]> = {
|
||||
'성능': ['performance', 'optimization', '최적화', 'speed'],
|
||||
'performance': ['성능', '최적화', 'optimization', 'speed'],
|
||||
'아키텍처': ['architecture', '구조', 'structure', 'design'],
|
||||
'architecture': ['아키텍처', '구조', 'structure', 'design'],
|
||||
'메모리': ['memory', '기억', 'cache', 'storage'],
|
||||
'memory': ['메모리', '기억', 'cache', 'storage'],
|
||||
'버그': ['bug', 'error', '오류', 'issue', 'defect'],
|
||||
'bug': ['버그', 'error', '오류', 'issue'],
|
||||
'설계': ['design', '아키텍처', 'architecture', 'pattern'],
|
||||
'design': ['설계', '아키텍처', 'architecture', 'pattern'],
|
||||
'배포': ['deploy', 'deployment', 'release', 'ci', 'cd'],
|
||||
'deploy': ['배포', 'deployment', 'release'],
|
||||
'테스트': ['test', 'testing', 'spec', 'jest', 'mocha'],
|
||||
'test': ['테스트', 'testing', 'spec'],
|
||||
'프로젝트': ['project', '프로그램', 'repo', 'repository'],
|
||||
'project': ['프로젝트', '프로그램', 'repo'],
|
||||
'방향': ['direction', '전략', 'strategy', '목표', 'goal'],
|
||||
'direction': ['방향', '전략', 'strategy', '목표']
|
||||
};
|
||||
|
||||
const expanded = new Set(tokens);
|
||||
for (const token of tokens) {
|
||||
const synonyms = synonymMap[token];
|
||||
if (synonyms) {
|
||||
for (const syn of synonyms) {
|
||||
expanded.add(syn);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(expanded);
|
||||
}
|
||||
|
||||
// ─── TF-IDF Scoring ───
|
||||
|
||||
/**
|
||||
* TF (Term Frequency): 문서 내 용어 빈도
|
||||
*/
|
||||
function termFrequency(term: string, documentTokens: string[]): number {
|
||||
if (documentTokens.length === 0) return 0;
|
||||
const count = documentTokens.filter((t) => t === term).length;
|
||||
return count / documentTokens.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* IDF (Inverse Document Frequency): 전체 문서 대비 희소도
|
||||
*/
|
||||
function inverseDocumentFrequency(
|
||||
term: string,
|
||||
allDocumentTokenSets: Array<Set<string>>
|
||||
): number {
|
||||
const containing = allDocumentTokenSets.filter((doc) => doc.has(term)).length;
|
||||
return Math.log((allDocumentTokenSets.length + 1) / (containing + 1)) + 1;
|
||||
}
|
||||
|
||||
export interface ScoredDocument {
|
||||
index: number;
|
||||
score: number;
|
||||
titleBoost: number;
|
||||
recencyBoost: number;
|
||||
matchedTerms: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* TF-IDF 기반으로 문서 집합을 스코어링합니다.
|
||||
*/
|
||||
export function scoreTfIdf(
|
||||
queryTokens: string[],
|
||||
documents: Array<{
|
||||
title: string;
|
||||
content: string;
|
||||
lastModified?: number;
|
||||
}>
|
||||
): ScoredDocument[] {
|
||||
if (documents.length === 0 || queryTokens.length === 0) return [];
|
||||
|
||||
// Pre-tokenize all documents
|
||||
const docTokenArrays = documents.map((doc) =>
|
||||
tokenize(`${doc.title} ${doc.content}`)
|
||||
);
|
||||
const docTokenSets = docTokenArrays.map((tokens) => new Set(tokens));
|
||||
|
||||
// Expand query with synonyms
|
||||
const expandedQuery = expandQuery(queryTokens);
|
||||
|
||||
// Compute IDF for each query term
|
||||
const idfCache = new Map<string, number>();
|
||||
for (const term of expandedQuery) {
|
||||
if (!idfCache.has(term)) {
|
||||
idfCache.set(term, inverseDocumentFrequency(term, docTokenSets));
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
return documents.map((doc, index) => {
|
||||
const docTokens = docTokenArrays[index];
|
||||
const titleTokens = new Set(tokenize(doc.title));
|
||||
let score = 0;
|
||||
const matchedTerms: string[] = [];
|
||||
|
||||
for (const term of expandedQuery) {
|
||||
const tf = termFrequency(term, docTokens);
|
||||
const idf = idfCache.get(term) || 1;
|
||||
const tfidf = tf * idf;
|
||||
|
||||
if (tfidf > 0) {
|
||||
matchedTerms.push(term);
|
||||
}
|
||||
|
||||
// Title match bonus (3x)
|
||||
const titleMultiplier = titleTokens.has(term) ? 3.0 : 1.0;
|
||||
score += tfidf * titleMultiplier;
|
||||
}
|
||||
|
||||
// Recency boost: documents modified recently get a boost
|
||||
let recencyBoost = 0;
|
||||
if (doc.lastModified) {
|
||||
const daysAgo = (now - doc.lastModified) / (1000 * 60 * 60 * 24);
|
||||
if (daysAgo < 1) recencyBoost = 0.3;
|
||||
else if (daysAgo < 7) recencyBoost = 0.2;
|
||||
else if (daysAgo < 30) recencyBoost = 0.1;
|
||||
}
|
||||
|
||||
// Title match bonus for exact query term presence
|
||||
const titleBoost = queryTokens.some((t) => titleTokens.has(t)) ? 0.2 : 0;
|
||||
|
||||
return {
|
||||
index,
|
||||
score: score + recencyBoost + titleBoost,
|
||||
titleBoost,
|
||||
recencyBoost,
|
||||
matchedTerms: [...new Set(matchedTerms)]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트에서 가장 관련성 높은 구간(excerpt)을 추출합니다.
|
||||
* 단순 paragraph 단위가 아니라, 키워드 밀도가 높은 윈도우를 찾습니다.
|
||||
*/
|
||||
export function extractBestExcerpt(
|
||||
content: string,
|
||||
queryTokens: string[],
|
||||
maxLength = 500
|
||||
): string {
|
||||
const expanded = expandQuery(queryTokens);
|
||||
const expandedSet = new Set(expanded);
|
||||
|
||||
// Split into sentences (한국어 + 영어)
|
||||
const sentences = content
|
||||
.split(/(?<=[.!?。!?\n])\s*/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 10);
|
||||
|
||||
if (sentences.length === 0) return content.slice(0, maxLength);
|
||||
|
||||
// Score each sentence
|
||||
const scored = sentences.map((sentence, idx) => {
|
||||
const tokens = tokenize(sentence);
|
||||
const matchCount = tokens.filter((t) => expandedSet.has(t)).length;
|
||||
const density = tokens.length > 0 ? matchCount / tokens.length : 0;
|
||||
return { sentence, idx, matchCount, density };
|
||||
});
|
||||
|
||||
// Find the best window of consecutive sentences
|
||||
let bestStart = 0;
|
||||
let bestScore = -1;
|
||||
let bestLen = 0;
|
||||
|
||||
for (let i = 0; i < scored.length; i++) {
|
||||
let windowText = '';
|
||||
let windowScore = 0;
|
||||
let j = i;
|
||||
|
||||
while (j < scored.length && windowText.length < maxLength) {
|
||||
windowText += scored[j].sentence + ' ';
|
||||
windowScore += scored[j].matchCount + scored[j].density * 2;
|
||||
j++;
|
||||
}
|
||||
|
||||
if (windowScore > bestScore) {
|
||||
bestScore = windowScore;
|
||||
bestStart = i;
|
||||
bestLen = j - i;
|
||||
}
|
||||
}
|
||||
|
||||
const excerptSentences = scored
|
||||
.slice(bestStart, bestStart + bestLen)
|
||||
.map((s) => s.sentence);
|
||||
|
||||
const result = excerptSentences.join(' ');
|
||||
return result.length > maxLength ? result.slice(0, maxLength - 3) + '...' : result;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Retrieval Types (검색 결과 통합 타입)
|
||||
*
|
||||
* 모든 검색 소스(Brain, Memory, Project, Episode)의 결과를
|
||||
* 통합 인터페이스로 정의합니다.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
export type RetrievalSource =
|
||||
| 'brain-trace' // Second Brain Trace
|
||||
| 'brain-memory' // findRelevantBrainMemory (legacy)
|
||||
| 'long-term-memory' // Long-Term Memory
|
||||
| 'project-memory' // Project Memory
|
||||
| 'procedural-memory' // Procedural Memory
|
||||
| 'episodic-memory' // Episodic Memory
|
||||
| 'project-scan' // Local Project Path scan
|
||||
| 'recent-knowledge'; // Recent Project Knowledge record
|
||||
|
||||
export interface RetrievalChunk {
|
||||
id: string;
|
||||
source: RetrievalSource;
|
||||
title: string;
|
||||
content: string;
|
||||
score: number; // 0.0 ~ 1.0 normalized
|
||||
tokenEstimate: number; // rough character / 4
|
||||
metadata: {
|
||||
filePath?: string;
|
||||
category?: string;
|
||||
isProjectEvidence?: boolean;
|
||||
lastUpdated?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RetrievalResult {
|
||||
query: string;
|
||||
totalChunks: number;
|
||||
selectedChunks: RetrievalChunk[];
|
||||
droppedChunks: RetrievalChunk[];
|
||||
totalTokensUsed: number;
|
||||
contextBudget: number;
|
||||
fusionLog: string[]; // 디버그용 융합 로그
|
||||
}
|
||||
|
||||
export interface ContextBudgetConfig {
|
||||
totalBudget: number; // 전체 토큰 예산
|
||||
retrievalRatio: number; // 검색 결과 비율 (0.0~1.0)
|
||||
minChunks: number; // 최소 포함 청크 수
|
||||
maxChunks: number; // 최대 포함 청크 수
|
||||
}
|
||||
Reference in New Issue
Block a user