fh / src /utils /vectorStorage.ts
Varun10000's picture
Upload 57 files
d8635c9 verified
Raw
History Blame Contribute Delete
14.3 kB
// 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<string, number>;
}
export interface SearchResult {
chunk: DocumentChunk;
score: number;
matchedKeywords: string[];
}
class VectorStorage {
private documents: DocumentChunk[] = [];
private idfScores: Map<string, number> = 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<void> {
// 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<string>();
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<string, number> {
const words = this.extractKeywords(text);
const tfidf = new Map<string, number>();
const wordCount = words.length;
// Calculate term frequency
const termFreq = new Map<string, number>();
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<string, number>();
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<string, number>): 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<string, number>, tfidf2: Map<string, number>): 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<string, string[]>([
['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();