// Enhanced Vector Storage with TF-IDF and Semantic Similarity // Provides better document retrieval and similarity matching export interface DocumentChunk { id: string; content: string; metadata: { source: string; pageNumber?: number; category?: string; chunkIndex: number; keywords: string[]; }; embedding: number[]; tfidf: Map; } export interface SearchResult { chunk: DocumentChunk; score: number; matchedKeywords: string[]; } class VectorStorage { private documents: DocumentChunk[] = []; private idfScores: Map = new Map(); private vocabularySize: number = 0; /** * Add document to the vector storage with chunking */ async addDocument( content: string, metadata: { source: string; pageNumber?: number; category?: string; } ): Promise { // Split document into chunks for better retrieval const chunks = this.chunkDocument(content); chunks.forEach((chunkText, index) => { const keywords = this.extractKeywords(chunkText); const tfidf = this.calculateTFIDF(chunkText); const embedding = this.createEmbedding(chunkText, tfidf); const chunk: DocumentChunk = { id: `${metadata.source}_chunk_${index}`, content: chunkText, metadata: { ...metadata, chunkIndex: index, keywords }, embedding, tfidf }; this.documents.push(chunk); }); // Recalculate IDF scores after adding documents this.calculateIDF(); } /** * Search for relevant documents using enhanced similarity */ search(query: string, options?: { topK?: number; categoryFilter?: string; minScore?: number; }): SearchResult[] { const topK = options?.topK || 10; const minScore = options?.minScore || 0.05; // Lowered from 0.1 to catch more results // Extract query keywords and create embedding const queryKeywords = this.extractKeywords(query); const queryTFIDF = this.calculateTFIDF(query); const queryEmbedding = this.createEmbedding(query, queryTFIDF); // Calculate similarity scores for all documents const results: SearchResult[] = []; for (const doc of this.documents) { // Apply category filter if specified if (options?.categoryFilter && doc.metadata.category !== options.categoryFilter) { continue; } // Calculate multiple similarity scores const cosineSim = this.cosineSimilarity(queryEmbedding, doc.embedding); const keywordSim = this.keywordSimilarity(queryKeywords, doc.metadata.keywords); const tfidfSim = this.tfidfSimilarity(queryTFIDF, doc.tfidf); // Weighted combination - boosted keyword matching from 0.3 to 0.4 const finalScore = ( cosineSim * 0.35 + keywordSim * 0.40 + // Increased weight for keyword matches tfidfSim * 0.25 ); if (finalScore >= minScore) { const matchedKeywords = queryKeywords.filter(kw => doc.metadata.keywords.some(dk => dk.includes(kw) || kw.includes(dk) ) ); results.push({ chunk: doc, score: finalScore, matchedKeywords }); } } // Sort by score and return top K return results .sort((a, b) => b.score - a.score) .slice(0, topK); } /** * Semantic search with query expansion */ semanticSearch(query: string, options?: { topK?: number; categoryFilter?: string; expandQuery?: boolean; }): SearchResult[] { const topK = options?.topK || 10; let searchQuery = query; // Expand query with synonyms and related terms if (options?.expandQuery !== false) { searchQuery = this.expandQuery(query); } // Perform regular search with expanded query const results = this.search(searchQuery, { topK: topK * 2, // Get more results initially categoryFilter: options?.categoryFilter, minScore: 0.05 // Lower threshold for semantic search }); // Re-rank results based on semantic relevance const rerankedResults = this.rerankBySemanticRelevance(query, results); return rerankedResults.slice(0, topK); } /** * Get documents by category */ getDocumentsByCategory(category: string): DocumentChunk[] { return this.documents.filter(doc => doc.metadata.category === category); } /** * Get all unique categories */ getCategories(): string[] { const categories = new Set(); this.documents.forEach(doc => { if (doc.metadata.category) { categories.add(doc.metadata.category); } }); return Array.from(categories); } /** * Clear all documents */ clear(): void { this.documents = []; this.idfScores.clear(); this.vocabularySize = 0; } /** * Get storage statistics */ getStats(): { totalDocuments: number; totalChunks: number; vocabularySize: number; categories: string[]; } { return { totalDocuments: new Set(this.documents.map(d => d.metadata.source)).size, totalChunks: this.documents.length, vocabularySize: this.vocabularySize, categories: this.getCategories() }; } // Private helper methods private chunkDocument(content: string, chunkSize: number = 1000): string[] { const chunks: string[] = []; // Try to split by paragraphs first const paragraphs = content.split(/\n\s*\n/); let currentChunk = ''; for (const para of paragraphs) { if ((currentChunk + para).length < chunkSize) { currentChunk += para + '\n\n'; } else { if (currentChunk) { chunks.push(currentChunk.trim()); } currentChunk = para + '\n\n'; } } if (currentChunk) { chunks.push(currentChunk.trim()); } return chunks.length > 0 ? chunks : [content]; } private extractKeywords(text: string): string[] { // Tokenize and clean const words = text.toLowerCase() .replace(/[^\w\s]/g, ' ') .split(/\s+/) .filter(w => w.length > 3); // Remove short words // Remove common stop words const stopWords = new Set([ 'the', 'this', 'that', 'these', 'those', 'with', 'from', 'have', 'been', 'were', 'was', 'will', 'would', 'could', 'should', 'about', 'what', 'when', 'where', 'which', 'their', 'there', 'them', 'they', 'than', 'then', 'your' ]); const keywords = words.filter(w => !stopWords.has(w)); // Return unique keywords return Array.from(new Set(keywords)); } private calculateTFIDF(text: string): Map { const words = this.extractKeywords(text); const tfidf = new Map(); const wordCount = words.length; // Calculate term frequency const termFreq = new Map(); words.forEach(word => { termFreq.set(word, (termFreq.get(word) || 0) + 1); }); // Calculate TF-IDF termFreq.forEach((freq, term) => { const tf = freq / wordCount; const idf = this.idfScores.get(term) || 0; tfidf.set(term, tf * idf); }); return tfidf; } private calculateIDF(): void { // Count document frequency for each term const docFreq = new Map(); const totalDocs = this.documents.length; this.documents.forEach(doc => { const uniqueWords = new Set(doc.metadata.keywords); uniqueWords.forEach(word => { docFreq.set(word, (docFreq.get(word) || 0) + 1); }); }); // Calculate IDF scores this.idfScores.clear(); docFreq.forEach((freq, term) => { const idf = Math.log((totalDocs + 1) / (freq + 1)) + 1; this.idfScores.set(term, idf); }); this.vocabularySize = docFreq.size; } private createEmbedding(text: string, tfidf: Map): number[] { // Create a simple but effective embedding using TF-IDF scores // In a production system, you'd use a pre-trained model like Sentence-BERT const embedding: number[] = new Array(100).fill(0); const words = this.extractKeywords(text); words.forEach((word, index) => { const score = tfidf.get(word) || 0; const hashIndex = this.hashToIndex(word, 100); embedding[hashIndex] += score; }); // Normalize the embedding const magnitude = Math.sqrt(embedding.reduce((sum, val) => sum + val * val, 0)); if (magnitude > 0) { for (let i = 0; i < embedding.length; i++) { embedding[i] /= magnitude; } } return embedding; } private hashToIndex(str: string, size: number): number { let hash = 0; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) - hash) + str.charCodeAt(i); hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash) % size; } private cosineSimilarity(vec1: number[], vec2: number[]): number { if (vec1.length !== vec2.length) return 0; let dotProduct = 0; let mag1 = 0; let mag2 = 0; for (let i = 0; i < vec1.length; i++) { dotProduct += vec1[i] * vec2[i]; mag1 += vec1[i] * vec1[i]; mag2 += vec2[i] * vec2[i]; } mag1 = Math.sqrt(mag1); mag2 = Math.sqrt(mag2); if (mag1 === 0 || mag2 === 0) return 0; return dotProduct / (mag1 * mag2); } private keywordSimilarity(keywords1: string[], keywords2: string[]): number { if (keywords1.length === 0 || keywords2.length === 0) return 0; const set1 = new Set(keywords1); const set2 = new Set(keywords2); let matches = 0; set1.forEach(kw1 => { set2.forEach(kw2 => { // Partial matching if (kw1.includes(kw2) || kw2.includes(kw1)) { matches++; } }); }); // Jaccard similarity with partial matching bonus const union = new Set([...keywords1, ...keywords2]).size; return matches / Math.max(keywords1.length, keywords2.length); } private tfidfSimilarity(tfidf1: Map, tfidf2: Map): number { if (tfidf1.size === 0 || tfidf2.size === 0) return 0; let dotProduct = 0; let mag1 = 0; let mag2 = 0; // Calculate dot product and magnitudes const allTerms = new Set([...tfidf1.keys(), ...tfidf2.keys()]); allTerms.forEach(term => { const val1 = tfidf1.get(term) || 0; const val2 = tfidf2.get(term) || 0; dotProduct += val1 * val2; mag1 += val1 * val1; mag2 += val2 * val2; }); mag1 = Math.sqrt(mag1); mag2 = Math.sqrt(mag2); if (mag1 === 0 || mag2 === 0) return 0; return dotProduct / (mag1 * mag2); } private expandQuery(query: string): string { const expansions = new Map([ ['experience', ['background', 'history', 'track record', 'portfolio', 'projects', 'expertise']], ['approach', ['methodology', 'process', 'framework', 'strategy', 'method', 'technique']], ['team', ['staff', 'personnel', 'resources', 'people', 'workforce', 'employees']], ['cost', ['price', 'budget', 'pricing', 'rate', 'fee', 'expense']], ['timeline', ['schedule', 'duration', 'timeframe', 'deadline', 'delivery']], ['quality', ['excellence', 'standards', 'assurance', 'control', 'reliability']], ['services', ['offerings', 'solutions', 'capabilities', 'products', 'deliverables']], ['financial', ['fiscal', 'monetary', 'economic', 'revenue', 'budget']], ['implementation', ['deployment', 'installation', 'rollout', 'execution', 'delivery']], ['support', ['maintenance', 'assistance', 'help', 'service', 'backup']] ]); let expandedQuery = query; const queryWords = query.toLowerCase().split(/\s+/); queryWords.forEach(word => { if (expansions.has(word)) { const synonyms = expansions.get(word) || []; // Add a couple of most relevant synonyms expandedQuery += ' ' + synonyms.slice(0, 2).join(' '); } }); return expandedQuery; } private rerankBySemanticRelevance(originalQuery: string, results: SearchResult[]): SearchResult[] { // Additional semantic analysis for reranking const queryLower = originalQuery.toLowerCase(); return results.map(result => { let semanticBoost = 0; // Boost if the chunk contains question-answer patterns if (result.chunk.content.toLowerCase().includes('answer:') || result.chunk.content.toLowerCase().includes('response:')) { semanticBoost += 0.1; } // Boost if chunk is near the beginning (often contains important info) if (result.chunk.metadata.chunkIndex < 3) { semanticBoost += 0.05; } // Boost if query words appear in close proximity in the chunk const queryWords = this.extractKeywords(queryLower); const contentLower = result.chunk.content.toLowerCase(); let proximityBoost = 0; for (let i = 0; i < queryWords.length - 1; i++) { const word1Pos = contentLower.indexOf(queryWords[i]); const word2Pos = contentLower.indexOf(queryWords[i + 1]); if (word1Pos !== -1 && word2Pos !== -1) { const distance = Math.abs(word2Pos - word1Pos); if (distance < 50) { // Words within 50 characters proximityBoost += 0.05; } } } semanticBoost += Math.min(proximityBoost, 0.15); return { ...result, score: result.score + semanticBoost }; }).sort((a, b) => b.score - a.score); } } // Export singleton instance export const vectorStorage = new VectorStorage();