UNPKG

rag-system-pgvector

Version:

A complete Retrieval-Augmented Generation system using pgvector, LangChain, and LangGraph for Node.js applications with dynamic embedding and model providers, structured data queries, and chat history - supports OpenAI, Anthropic, HuggingFace, Azure, Goog

877 lines (739 loc) • 32.1 kB
import { StateGraph, END } from "@langchain/langgraph"; import { BaseMessage, HumanMessage, AIMessage, SystemMessage } from "@langchain/core/messages"; import { ChatOpenAI } from "@langchain/openai"; import DocumentProcessor from '../utils/documentProcessor.js'; import DocumentStoreLangChain from '../services/documentStoreLangChain.js'; import { ragState } from "./state.js"; class RAGWorkflow { constructor(documentStore, config = {}) { if (!config.llm) { throw new Error('Language model (LLM) instance is required in config.llm'); } this.documentProcessor = new DocumentProcessor(config); this.documentStore = documentStore; // Use the passed documentStore this.llm = config.llm; // Track if database is available this.hasDatabase = documentStore && documentStore.hasDatabase; if (!this.hasDatabase) { console.log('āš ļø RAGWorkflow initialized without database - some features will be limited'); } // History management configuration this.historyConfig = { maxMessages: config.chatHistory?.maxMessages || 20, maxTokens: config.chatHistory?.maxTokens || 3000, summarizeThreshold: config.chatHistory?.summarizeThreshold || 30, keepRecentCount: config.chatHistory?.keepRecentCount || 10, alwaysKeepFirst: config.chatHistory?.alwaysKeepFirst ?? true }; this.workflow = this.createWorkflow(); } createWorkflow() { const workflow = new StateGraph({ channels: ragState }); // Add nodes workflow.addNode("retrieve", this.retrieveNode.bind(this)); workflow.addNode("rerank", this.rerankNode.bind(this)); workflow.addNode("generate", this.generateNode.bind(this)); // Add edges workflow.addEdge("retrieve", "rerank"); workflow.addEdge("rerank", "generate"); workflow.addEdge("generate", END); // Set entry point workflow.setEntryPoint("retrieve"); return workflow.compile(); } async retrieveNode(state) { console.log("šŸ” Retrieving relevant chunks..."); try { // Check if documentStore has database capabilities if (!this.documentStore || !this.documentStore.hasDatabase) { console.log("āš ļø No database available - using chat history, structured data, and direct context"); // DEBUG: Check what we received console.log("šŸ” DEBUG - State contents:"); console.log(" - directContext:", state.directContext ? `${Array.isArray(state.directContext) ? state.directContext.length + ' items' : 'single item'}` : 'null'); console.log(" - structuredData:", state.structuredData ? 'present' : 'null'); console.log(" - context:", state.context ? (Array.isArray(state.context) ? state.context.length + ' items' : 'present') : 'null'); // Build context from direct context array (highest priority) let directContextStr = ''; if (state.directContext) { console.log("šŸ“‹ Building context from direct context data"); if (Array.isArray(state.directContext)) { console.log(`šŸ“Š Processing ${state.directContext.length} direct context items`); directContextStr = state.directContext.map((item, index) => { if (typeof item === 'object' && item !== null) { // Format structured data objects nicely const formatted = Object.entries(item) .filter(([key, value]) => key !== '_id' && key !== '__v') // Filter MongoDB fields .map(([key, value]) => { if (typeof value === 'object' && value !== null && !Array.isArray(value)) { // Handle nested objects (like vitals) const nestedFormatted = Object.entries(value) .map(([nestedKey, nestedValue]) => ` ${nestedKey}: ${nestedValue}`) .join('\n'); return `${key}:\n${nestedFormatted}`; } else if (Array.isArray(value)) { return `${key}: [${value.join(', ')}]`; } else { return `${key}: ${value}`; } }) .join('\n'); return `Record ${index + 1}:\n${formatted}`; } return `Item ${index + 1}: ${String(item)}`; }).join('\n\n---\n\n'); } else if (typeof state.directContext === 'object') { // Handle single object directContextStr = Object.entries(state.directContext) .filter(([key, value]) => key !== '_id' && key !== '__v') .map(([key, value]) => { if (typeof value === 'object' && value !== null) { return `${key}: ${JSON.stringify(value, null, 2)}`; } return `${key}: ${value}`; }) .join('\n'); } else { directContextStr = String(state.directContext); } } // Build context from chat history let contextFromHistory = ''; // Extract relevant information from chat history if (state.chatHistory && state.chatHistory.length > 0) { console.log(`šŸ“š Building context from ${state.chatHistory.length} chat history messages`); // Get recent conversation context (last few exchanges) const recentHistory = state.chatHistory.slice(-6); // Last 3 exchanges (user + assistant) contextFromHistory = recentHistory .map((msg, index) => { const speaker = msg.role === 'user' ? 'Previous User Question' : 'Previous Assistant Response'; return `${speaker}: ${msg.content}`; }) .join('\n\n'); } // Build context from structured data let structuredContext = ''; if (state.structuredData) { console.log("šŸ—ļø Building context from structured data"); const structured = state.structuredData; let contextParts = []; if (structured.intent) { contextParts.push(`Query Intent: ${structured.intent}`); } if (structured.entities && Object.keys(structured.entities).length > 0) { const entityInfo = Object.entries(structured.entities) .filter(([_, value]) => value) .map(([key, value]) => `${key}: ${value}`) .join(', '); contextParts.push(`Key Entities: ${entityInfo}`); } if (structured.constraints && structured.constraints.length > 0) { contextParts.push(`Requirements: ${structured.constraints.join(', ')}`); } if (structured.context && Object.keys(structured.context).length > 0) { const additionalContext = Object.entries(structured.context) .map(([key, value]) => `${key}: ${value}`) .join(', '); contextParts.push(`Additional Context: ${additionalContext}`); } structuredContext = contextParts.join('\n'); } // Combine all context sources in priority order const combinedContext = [ directContextStr, // Highest priority - direct structured data contextFromHistory, // Chat history context structuredContext, // Structured data context // Also include any explicitly provided context Array.isArray(state.context) ? state.context.join('\n\n') : String(state.context || '') ].filter(Boolean).join('\n\n─────\n\n'); console.log(`šŸ“ Combined context length: ${combinedContext.length} characters`); if (state.directContext) { console.log(`āœ… Direct context included: ${Array.isArray(state.directContext) ? state.directContext.length + ' items' : '1 item'}`); } return { ...state, retrievedChunks: [], searchResults: [], context: combinedContext, metadata: { ...state.metadata, retrievalTimestamp: new Date().toISOString(), chunksRetrieved: 0, retrievalMethod: 'chat_history_and_structured_data', historyMessages: state.chatHistory?.length || 0, hasStructuredData: !!state.structuredData, note: 'No database configured - using chat history and structured data for context' }, }; } // Extract filter options from state metadata const filterOptions = state.metadata?.filter || {}; const userId = state.metadata?.userId; const knowledgebotId = state.metadata?.knowledgebotId; // Build comprehensive filter const searchFilter = { ...filterOptions }; if (userId) searchFilter.userId = userId; if (knowledgebotId) searchFilter.knowledgebotId = knowledgebotId; // Enhance search query with structured data if available let enhancedQuery = state.query; if (state.structuredData) { enhancedQuery = this.enhanceQueryWithStructuredData(state.query, state.structuredData); // Add entity-based filters if present if (state.structuredData.entities) { Object.entries(state.structuredData.entities).forEach(([entityType, entityValue]) => { if (entityValue && typeof entityValue === 'string') { searchFilter[entityType] = entityValue; } }); } } // Use retriever with dynamic filtering and enhanced query const retrievedChunks = await this.documentStore.retrieveDocuments( enhancedQuery, { k: state.metadata?.limit || 10, // Number of documents to retrieve searchType: 'similarity', // Use similarity search scoreThreshold: state.metadata?.threshold || 0.1, // Minimum relevance score filter: searchFilter // Dynamic metadata filtering } ); console.log(`āœ“ Retrieved ${retrievedChunks.length} relevant chunks using retriever`); if (Object.keys(searchFilter).length > 0) { console.log(`āœ“ Applied filters:`, searchFilter); } return { ...state, retrievedChunks, searchResults: retrievedChunks, metadata: { ...state.metadata, retrievalTimestamp: new Date().toISOString(), chunksRetrieved: retrievedChunks.length, retrievalMethod: 'retriever', // Track that we used retriever appliedFilters: searchFilter, }, }; } catch (error) { console.error("Error in retrieve node:", error); // Fallback to using provided context console.log("āš ļø Retrieval failed - falling back to provided context"); const providedContext = state.context || []; const contextString = Array.isArray(providedContext) ? providedContext.join('\n\n') : String(providedContext); return { ...state, retrievedChunks: [], searchResults: [], context: contextString, metadata: { ...state.metadata, retrievalTimestamp: new Date().toISOString(), chunksRetrieved: 0, retrievalMethod: 'fallback', retrievalError: error.message }, }; } } async rerankNode(state) { console.log("šŸ“Š Reranking chunks..."); try { // Check if we have retrieved chunks to rerank if (!state.retrievedChunks || state.retrievedChunks.length === 0) { console.log(`āœ“ No chunks to rerank - preserving existing context`); return { ...state, metadata: { ...state.metadata, rerankingTimestamp: new Date().toISOString(), finalChunkCount: 0, note: 'No chunks to rerank - context preserved from retrieval' }, }; } // Simple reranking based on similarity score and chunk relevance const rerankedChunks = state.retrievedChunks .sort((a, b) => { // Primary sort by similarity if (b.similarity !== a.similarity) { return b.similarity - a.similarity; } // Secondary sort by chunk index (prefer earlier chunks) return a.chunk_index - b.chunk_index; }) .slice(0, 5); // Take top 5 chunks // Create context from reranked chunks const context = rerankedChunks .map((chunk, index) => { return `[Source ${index + 1}: ${chunk.title} - Part ${chunk.chunk_index + 1}]\n${chunk.content}`; }) .join('\n\n'); console.log(`āœ“ Reranked to top ${rerankedChunks.length} chunks`); return { ...state, retrievedChunks: rerankedChunks, context, metadata: { ...state.metadata, rerankingTimestamp: new Date().toISOString(), finalChunkCount: rerankedChunks.length, }, }; } catch (error) { console.error("Error in rerank node:", error); throw error; } } // Helper function to format chat history formatChatHistory(history, maxLength = 10) { if (!history || history.length === 0) return ''; // Take only recent messages const recentHistory = history.slice(-maxLength); // Format for LLM return recentHistory.map(msg => `${msg.role === 'user' ? 'Human' : 'Assistant'}: ${msg.content}` ).join('\n'); } // Helper: Summarize old history async summarizeHistory(history, options = {}) { const { summarizeThreshold = 20, keepRecentCount = 10, maxSummaryLength = 500 } = options; if (history.length < summarizeThreshold) { return history; } const oldHistory = history.slice(0, -keepRecentCount); const recentHistory = history.slice(-keepRecentCount); const conversationText = oldHistory.map(msg => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${msg.content}` ).join('\n'); try { const summary = await this.llm.invoke([ new SystemMessage(`Summarize this conversation in ${maxSummaryLength} tokens or less. Focus on key topics, decisions, and important context:`), new HumanMessage(conversationText) ]); return [ { role: 'system', content: `[Conversation Summary]: ${summary.content}`, timestamp: new Date().toISOString(), type: 'summary' }, ...recentHistory ]; } catch (error) { console.warn('Failed to summarize history, returning original:', error.message); return history; } } // Helper: Truncate by token count async truncateByTokens(history, maxTokens = 2000) { // Simple approximation: ~4 chars per token const charsPerToken = 4; const maxChars = maxTokens * charsPerToken; let totalChars = 0; const truncated = []; // Process from newest to oldest for (let i = history.length - 1; i >= 0; i--) { const message = history[i]; const messageChars = message.content.length; if (totalChars + messageChars <= maxChars) { truncated.unshift(message); totalChars += messageChars; } else { break; } } return truncated; } // Main: Hybrid history management async manageHistory(history, options = {}) { const { maxMessages = this.historyConfig.maxMessages, maxTokens = this.historyConfig.maxTokens, summarizeThreshold = this.historyConfig.summarizeThreshold, keepRecentCount = this.historyConfig.keepRecentCount, alwaysKeepFirst = this.historyConfig.alwaysKeepFirst } = options; // Step 1: If under threshold, return as-is if (history.length <= maxMessages) { return history; } console.log(`šŸ“Š Managing chat history: ${history.length} messages (threshold: ${maxMessages})`); // Step 2: If way over threshold, summarize if (history.length > summarizeThreshold) { console.log(`šŸ“ Summarizing history (threshold: ${summarizeThreshold})`); const summarized = await this.summarizeHistory(history, { summarizeThreshold, keepRecentCount }); history = summarized; console.log(`āœ“ Summarized to ${history.length} messages`); } // Step 3: Always keep first message (conversation starter) const firstMessage = alwaysKeepFirst && history.length > 0 ? [history[0]] : []; const remainingHistory = alwaysKeepFirst && history.length > 0 ? history.slice(1) : history; // Step 4: Truncate by tokens const tokenLimited = await this.truncateByTokens( remainingHistory, maxTokens ); // Step 5: Combine const managedHistory = [...firstMessage, ...tokenLimited]; console.log(`āœ“ Final managed history: ${managedHistory.length} messages`); return managedHistory; } async generateNode(state) { console.log("šŸ¤– Generating response..."); try { // Manage history before using it (applies summarization if needed) const managedHistory = await this.manageHistory( state.chatHistory || [], { maxMessages: this.historyConfig.maxMessages, maxTokens: this.historyConfig.maxTokens, summarizeThreshold: this.historyConfig.summarizeThreshold, keepRecentCount: this.historyConfig.keepRecentCount, alwaysKeepFirst: this.historyConfig.alwaysKeepFirst } ); // Format managed history for prompt const formattedHistory = this.formatChatHistory(managedHistory); // Build conversation context section const conversationSection = formattedHistory ? ` Previous Conversation: ${formattedHistory} Continue the conversation naturally, referencing previous context when relevant. ` : ''; // Build structured data section const structuredDataSection = this.formatStructuredDataForPrompt(state.structuredData); // Handle different context scenarios let contextSection = ''; let guidelines = ''; // Check if we have database context vs chat history context const retrievalMethod = state.metadata?.retrievalMethod; const hasDbContext = retrievalMethod === 'retriever' && state.context && state.context.trim(); const hasChatHistoryContext = retrievalMethod === 'chat_history_and_structured_data' && state.context && state.context.trim(); if (hasDbContext) { // We have context from database contextSection = `Use the following context from the knowledge base to answer the question: ${state.context} `; guidelines = `Guidelines: - If continuing a conversation, acknowledge previous context when relevant - Be conversational and natural - If the question references "it", "that", "them" etc., use chat history for context - If structured data is provided, use it to guide your response format and focus - Cite sources when using information from the knowledge base by referencing [Source X] - If the context doesn't contain enough information, say so clearly - Be accurate and concise`; } else if (hasChatHistoryContext) { // We have context from chat history and structured data const hasDirectContext = state.directContext && Array.isArray(state.directContext) && state.directContext.length > 0; if (hasDirectContext) { contextSection = `The user has provided the following data for analysis: ${state.context} IMPORTANT: This data has been explicitly provided by the user for analysis. You should analyze and work with this data as requested. `; guidelines = `Guidelines: - The user has provided specific data (above) that they want you to analyze - Provide comprehensive analysis of the provided data as requested - If structured data is provided, use it to guide your response format and focus - Answer based on the data provided by the user - Be thorough and detailed in your analysis - Reference specific data points from the provided information`; } else { contextSection = `Use the following conversation context and structured information to answer the question: ${state.context} `; guidelines = `Guidelines: - Build upon the previous conversation naturally - Reference earlier parts of the conversation when relevant - If structured data is provided, use it to guide your response format and focus - If the question references "it", "that", "them" etc., use the conversation history for context - Maintain conversation continuity and acknowledge context shifts appropriately - Provide helpful responses based on the conversation flow and structured guidance - Be conversational and engaging`; } } else { // No specific context available contextSection = `Note: No specific knowledge base context is available for this query. Provide a helpful response based on your general knowledge. `; guidelines = `Guidelines: - If continuing a conversation, acknowledge previous context when relevant - Be conversational and natural - If the question references "it", "that", "them" etc., use chat history for context - If structured data is provided, use it to guide your response format and focus - Use your general knowledge to provide helpful, accurate information - Be clear that you're responding based on general knowledge rather than specific documents - Be accurate and concise`; } const systemPrompt = `You are a helpful AI assistant${hasDbContext ? ' with access to a knowledge base' : hasChatHistoryContext ? ' with access to conversation history' : ''}. ${conversationSection}${structuredDataSection}${contextSection}${guidelines} Question: ${state.query}`; const messages = [ new HumanMessage(systemPrompt), ]; const response = await this.llm.invoke(messages); // Add current interaction to history const updatedHistory = [ ...(state.chatHistory || []), { role: 'user', content: state.query, timestamp: new Date().toISOString() }, { role: 'assistant', content: response.content, timestamp: new Date().toISOString(), sources: state.searchResults?.map(chunk => ({ title: chunk.title, similarity: chunk.similarity })) } ]; // Apply history management to updated history const finalManagedHistory = await this.manageHistory(updatedHistory); console.log(`āœ“ Generated response with chat history (${state.chatHistory?.length || 0} previous messages)`); return { ...state, response: response.content, chatHistory: finalManagedHistory, messages: [...(state.messages || []), state.query, response.content], metadata: { ...state.metadata, generationTimestamp: new Date().toISOString(), conversationLength: finalManagedHistory.length, historyManaged: finalManagedHistory.length !== updatedHistory.length, }, }; } catch (error) { console.error("Error in generate node:", error); throw error; } } async processQuery(query) { try { console.log(`\nšŸš€ Processing query: "${query}"`); // Ensure all required fields are initialized const initialState = { query, chatHistory: [], retrievedChunks: [], context: '', response: '', searchResults: [], metadata: {}, messages: [], structuredData: null }; console.log("šŸ“Š Initial state:", JSON.stringify(initialState, null, 2)); const result = await this.workflow.invoke(initialState); // Save search session only if database is available if (this.documentStore && this.documentStore.hasDatabase) { try { await this.documentStore.saveSearchSession(query, { retrievedChunks: result.retrievedChunks, response: result.response, metadata: result.metadata, }); } catch (error) { console.warn('Failed to save search session:', error.message); } } return result; } catch (error) { console.error("Error processing query:", error); throw error; } } // Method to add conversational context async processConversationalQuery(query, conversationHistory = []) { try { console.log(`\nšŸ’¬ Processing conversational query: "${query}"`); // Enhance query with conversation context if needed let enhancedQuery = query; if (conversationHistory.length > 0) { const recentHistory = conversationHistory.slice(-4); // Last 2 exchanges const contextualPrompt = `Based on our previous conversation: ${recentHistory.map(msg => `${msg.type}: ${msg.content}`).join(', ')}\n\nCurrent question: ${query}`; enhancedQuery = contextualPrompt; } // Ensure all required fields are initialized const initialState = { query: enhancedQuery, chatHistory: conversationHistory, retrievedChunks: [], context: '', response: '', searchResults: [], metadata: {}, messages: conversationHistory, structuredData: null }; const result = await this.workflow.invoke(initialState); return result; } catch (error) { console.error("Error processing conversational query:", error); throw error; } } /** * Process query with structured data */ async processStructuredQuery(query, structuredData = null, chatHistory = []) { try { console.log(`\nšŸ—ļø Processing structured query: "${query}"`); if (structuredData) { console.log("šŸ“Š Structured data:", JSON.stringify(structuredData, null, 2)); } // Ensure all required fields are initialized const initialState = { query, chatHistory: chatHistory || [], retrievedChunks: [], context: '', response: '', searchResults: [], metadata: {}, messages: chatHistory || [], structuredData: structuredData }; const result = await this.workflow.invoke(initialState); // Save search session only if database is available if (this.documentStore && this.documentStore.hasDatabase) { try { await this.documentStore.saveSearchSession(query, { retrievedChunks: result.retrievedChunks, response: result.response, metadata: result.metadata, structuredData: structuredData }); } catch (error) { console.warn('Failed to save search session:', error.message); } } return result; } catch (error) { console.error("Error processing structured query:", error); throw error; } } // Comparison method to test retriever vs similarity search async compareRetrievalMethods(query) { try { console.log(`\nšŸ” Comparing retrieval methods for: "${query}"`); // Check if database is available if (!this.documentStore || !this.documentStore.hasDatabase) { console.log('āŒ Cannot compare retrieval methods - no database configured'); return { error: 'Database not configured', message: 'Retrieval method comparison requires database configuration' }; } // Method 1: Retriever console.log("\n--- Using Retriever ---"); const retrieverStart = Date.now(); const retrieverResults = await this.documentStore.retrieveDocuments(query, { k: 5, scoreThreshold: 0.1 }); const retrieverTime = Date.now() - retrieverStart; // Method 2: Similarity Search console.log("\n--- Using Similarity Search ---"); const simSearchStart = Date.now(); const simSearchResults = await this.documentStore.searchSimilarChunksByText(query, 5, 0.1); const simSearchTime = Date.now() - simSearchStart; // Compare results console.log("\nšŸ“Š Comparison Results:"); console.log(`Retriever: ${retrieverResults.length} results in ${retrieverTime}ms`); console.log(`Similarity Search: ${simSearchResults.length} results in ${simSearchTime}ms`); return { retriever: { results: retrieverResults, time: retrieverTime, count: retrieverResults.length }, similaritySearch: { results: simSearchResults, time: simSearchTime, count: simSearchResults.length } }; } catch (error) { console.error("Error comparing retrieval methods:", error); throw error; } } /** * Enhance query with structured data for better retrieval */ enhanceQueryWithStructuredData(originalQuery, structuredData) { if (!structuredData) return originalQuery; let enhancedQuery = originalQuery; // Add intent context if (structuredData.intent) { enhancedQuery += ` [Intent: ${structuredData.intent}]`; } // Add entity information if (structuredData.entities && Object.keys(structuredData.entities).length > 0) { const entityStrings = Object.entries(structuredData.entities) .filter(([_, value]) => value) .map(([key, value]) => `${key}: ${value}`); if (entityStrings.length > 0) { enhancedQuery += ` [Entities: ${entityStrings.join(', ')}]`; } } // Add constraints if (structuredData.constraints && structuredData.constraints.length > 0) { enhancedQuery += ` [Requirements: ${structuredData.constraints.join(', ')}]`; } console.log(`šŸ” Enhanced query: "${enhancedQuery}"`); return enhancedQuery; } /** * Format structured data for inclusion in the prompt */ formatStructuredDataForPrompt(structuredData) { if (!structuredData) return ''; let section = '\nStructured Query Information:\n'; // Add intent if (structuredData.intent) { section += `Intent/Category: ${structuredData.intent}\n`; } // Add entities if (structuredData.entities && Object.keys(structuredData.entities).length > 0) { section += 'Key Entities:\n'; Object.entries(structuredData.entities).forEach(([key, value]) => { if (value) { section += ` - ${key}: ${value}\n`; } }); } // Add constraints if (structuredData.constraints && structuredData.constraints.length > 0) { section += 'Requirements/Constraints:\n'; structuredData.constraints.forEach(constraint => { section += ` - ${constraint}\n`; }); } // Add context if (structuredData.context && Object.keys(structuredData.context).length > 0) { section += 'Additional Context:\n'; Object.entries(structuredData.context).forEach(([key, value]) => { section += ` - ${key}: ${value}\n`; }); } // Add response format if (structuredData.responseFormat) { section += `Desired Response Format: ${structuredData.responseFormat}\n`; } section += '\n'; return section; } } export { RAGWorkflow }; export default RAGWorkflow;