UNPKG

@restnfeel/agentc-starter-kit

Version:

한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템

292 lines (249 loc) 8.66 kB
import { SearchResult, VectorStore, EmbeddingModel } from "../types"; import { QueryProcessor, ProcessedQuery } from "./query-processor"; export interface HybridSearchConfig { vectorWeight: number; // 0-1, weight for vector search keywordWeight: number; // 0-1, weight for keyword search maxResults: number; minScore: number; enableReranking?: boolean; } export interface KeywordSearchResult { chunkId: string; score: number; matches: string[]; } export class HybridRetriever { private vectorStore: VectorStore; private embeddingModel: EmbeddingModel; private queryProcessor: QueryProcessor; private config: HybridSearchConfig; private documentIndex: Map<string, string> = new Map(); // chunkId -> content constructor( vectorStore: VectorStore, embeddingModel: EmbeddingModel, config: Partial<HybridSearchConfig> = {} ) { this.vectorStore = vectorStore; this.embeddingModel = embeddingModel; this.queryProcessor = new QueryProcessor(); this.config = { vectorWeight: 0.7, keywordWeight: 0.3, maxResults: 10, minScore: 0.1, enableReranking: true, ...config, }; // Ensure weights sum to 1 const totalWeight = this.config.vectorWeight + this.config.keywordWeight; if (Math.abs(totalWeight - 1.0) > 0.01) { this.config.vectorWeight = this.config.vectorWeight / totalWeight; this.config.keywordWeight = this.config.keywordWeight / totalWeight; } } async search(query: string, k?: number): Promise<SearchResult[]> { const maxResults = k || this.config.maxResults; // Step 1: Process the query const processedQuery = await this.queryProcessor.processQuery(query); // Step 2: Perform vector search const vectorResults = await this.performVectorSearch( processedQuery, maxResults * 2 ); // Step 3: Perform keyword search const keywordResults = await this.performKeywordSearch( processedQuery, maxResults * 2 ); // Step 4: Combine and score results const combinedResults = this.combineResults( vectorResults, keywordResults, processedQuery ); // Step 5: Re-rank if enabled let finalResults = combinedResults; if (this.config.enableReranking) { finalResults = await this.rerankResults(combinedResults, processedQuery); } // Step 6: Filter by minimum score and limit results return finalResults .filter((result) => result.score >= this.config.minScore) .slice(0, maxResults); } private async performVectorSearch( processedQuery: ProcessedQuery, k: number ): Promise<SearchResult[]> { try { // Use the processed query for better semantic matching const searchQuery = processedQuery.processed || processedQuery.original; return await this.vectorStore.search(searchQuery, k); } catch (error) { console.warn("Vector search failed:", error); return []; } } private async performKeywordSearch( processedQuery: ProcessedQuery, k: number ): Promise<KeywordSearchResult[]> { try { const keywords = processedQuery.keywords; if (keywords.length === 0) { return []; } const results: KeywordSearchResult[] = []; // Simple keyword matching - in production, you'd use a proper search engine for (const [chunkId, content] of this.documentIndex) { const matches: string[] = []; let score = 0; const lowerContent = content.toLowerCase(); for (const keyword of keywords) { const regex = new RegExp(`\\b${keyword}\\b`, "gi"); const matchCount = (content.match(regex) || []).length; if (matchCount > 0) { matches.push(keyword); // TF-like scoring score += matchCount * Math.log(content.length / (matchCount + 1)); } } if (matches.length > 0) { // Normalize score by content length score = score / Math.sqrt(content.length); results.push({ chunkId, score, matches }); } } return results.sort((a, b) => b.score - a.score).slice(0, k); } catch (error) { console.warn("Keyword search failed:", error); return []; } } private combineResults( vectorResults: SearchResult[], keywordResults: KeywordSearchResult[], processedQuery: ProcessedQuery ): SearchResult[] { const combinedMap = new Map<string, SearchResult>(); // Add vector search results for (const result of vectorResults) { const key = result.chunk.id; const weightedScore = result.score * this.config.vectorWeight; combinedMap.set(key, { ...result, score: weightedScore, }); } // Add or update with keyword search results for (const keywordResult of keywordResults) { const key = keywordResult.chunkId; const weightedScore = keywordResult.score * this.config.keywordWeight; if (combinedMap.has(key)) { // Combine scores const existing = combinedMap.get(key)!; existing.score += weightedScore; } else { // Find the corresponding vector result or create a minimal one const vectorResult = vectorResults.find((vr) => vr.chunk.id === key); if (vectorResult) { combinedMap.set(key, { ...vectorResult, score: weightedScore, }); } } } return Array.from(combinedMap.values()).sort((a, b) => b.score - a.score); } private async rerankResults( results: SearchResult[], processedQuery: ProcessedQuery ): Promise<SearchResult[]> { // Simple re-ranking based on multiple factors return results .map((result) => { let bonusScore = 0; // Bonus for exact phrase matches if ( result.chunk.content .toLowerCase() .includes(processedQuery.original.toLowerCase()) ) { bonusScore += 0.1; } // Bonus for keyword density const keywordCount = processedQuery.keywords.reduce( (count, keyword) => { const regex = new RegExp(`\\b${keyword}\\b`, "gi"); return count + (result.chunk.content.match(regex) || []).length; }, 0 ); const keywordDensity = keywordCount / result.chunk.content.split(" ").length; bonusScore += Math.min(keywordDensity * 0.5, 0.2); // Bonus for recent documents (if timestamp available) if (result.document.metadata.updatedAt) { try { // Ensure updatedAt is a Date object const updatedAt = result.document.metadata.updatedAt instanceof Date ? result.document.metadata.updatedAt : new Date(result.document.metadata.updatedAt); const daysSinceUpdate = (Date.now() - updatedAt.getTime()) / (1000 * 60 * 60 * 24); if (daysSinceUpdate < 30) { bonusScore += 0.05; } } catch (dateError) { console.warn( "Failed to process updatedAt for bonus scoring:", dateError ); } } // Bonus for language match if (processedQuery.language === result.document.metadata.language) { bonusScore += 0.05; } return { ...result, score: Math.min(result.score + bonusScore, 1.0), }; }) .sort((a, b) => b.score - a.score); } // Method to update the document index for keyword search updateDocumentIndex(chunkId: string, content: string): void { this.documentIndex.set(chunkId, content); } // Method to remove from document index removeFromIndex(chunkId: string): void { this.documentIndex.delete(chunkId); } // Method to update search configuration updateConfig(config: Partial<HybridSearchConfig>): void { this.config = { ...this.config, ...config }; // Ensure weights sum to 1 const totalWeight = this.config.vectorWeight + this.config.keywordWeight; if (Math.abs(totalWeight - 1.0) > 0.01) { this.config.vectorWeight = this.config.vectorWeight / totalWeight; this.config.keywordWeight = this.config.keywordWeight / totalWeight; } } // Method to get search statistics getSearchStats(): { indexSize: number; config: HybridSearchConfig; } { return { indexSize: this.documentIndex.size, config: this.config, }; } // Method to clear the index clearIndex(): void { this.documentIndex.clear(); } }