UNPKG

@coworker-agency/rag

Version:

Retrieval Augmented Generation (RAG) library for document indexing, vector storage, and AI-powered question answering

408 lines (349 loc) 14 kB
/** * Document Retriever * * This module provides functions for retrieving relevant documents from a vector store * and generating answers using LLM. */ import OpenAI from 'openai'; import { createClient } from '@supabase/supabase-js'; /** * Search vector store and generate FAQ responses * @param {string} supabaseUrl - Supabase project URL * @param {string} supabaseSecretKey - Supabase service role key * @param {string} query - Search query * @param {number} limit - Number of FAQs to generate * @param {string} tableName - Vector store table name * @param {string} openaiApiKey - OpenAI API key * @returns {Promise<{questions: Array<{question: string, answer: string, links: Array<{title: string, link: string}>}>}>} */ async function searchFaq( supabaseUrl, supabaseSecretKey, query, limit = 5, tableName = "vector_documents", openaiApiKey = process.env.OPENAI_API_KEY ) { try { console.log(`Searching for: "${query}" with limit ${limit}`); // Initialize clients const supabase = createClient(supabaseUrl, supabaseSecretKey, { auth: { persistSession: false } }); const openai = new OpenAI({ apiKey: openaiApiKey, }); // Retrieve double the requested limit to get more context for LLM const retrievalLimit = limit * 2; // Generate an embedding for the query const embedResponse = await openai.embeddings.create({ model: "text-embedding-3-small", input: query, encoding_format: "float" }); // Access the embedding data correctly from the OpenAI response const queryEmbedding = embedResponse.data[0].embedding; // Add some dimension logging to verify embedding size console.log(`Query embedding dimensions: ${queryEmbedding.length}`); // Use RPC function but make sure embedding is properly formatted console.log(`Searching vector store using match_documents RPC with threshold: 0.3`); // Use the existing RPC function but send embedding correctly let { data: documents, error } = await supabase.rpc( 'match_documents', { query_embedding: queryEmbedding, match_threshold: 0.1, // Lowered from 0.5 to find more matches match_count: retrievalLimit } ); if (error) { console.error('Error searching vector store with RPC:', error); console.log('Attempting direct query as fallback...'); // Fallback: Try direct query if RPC fails try { const { data: directQueryDocs, error: directQueryError } = await supabase .from(tableName) .select('id, content, metadata') .limit(retrievalLimit); if (directQueryError) { console.error('Fallback query also failed:', directQueryError); throw directQueryError; } if (directQueryDocs && directQueryDocs.length > 0) { console.log(`Fallback found ${directQueryDocs.length} documents`); documents = directQueryDocs; } else { console.log('No documents found with fallback query either'); throw error; // Throw the original error } } catch (fallbackError) { console.error('Error in fallback query:', fallbackError); throw error; // Throw the original error } } // Add calculated similarity score (since we're not using the match_documents function) const documentsWithSimilarity = documents.map((doc, index) => ({ ...doc, // Approximate similarity based on order (higher for first results) similarity: 1 - (index / (documents.length || 1)) * 0.5 })); if (!documentsWithSimilarity || documentsWithSimilarity.length === 0) { console.log('No matching documents found'); return { questions: [] }; } console.log(`Found ${documentsWithSimilarity.length} matching documents`); // Format document content for the LLM using our enhanced documents with similarity scores const documentContent = documentsWithSimilarity.map(doc => { return { content: doc.content, metadata: doc.metadata, similarity: doc.similarity // Using our calculated similarity score }; }); // Generate FAQ using LLM const faqs = await generateFAQs(query, documentContent, openai, limit); return { questions: faqs }; } catch (error) { console.error('Error in search:', error); throw error; } } /** * Get answer for a specific question * @param {string} supabaseUrl - Supabase project URL * @param {string} supabaseSecretKey - Supabase service role key * @param {string} question - Question to answer * @param {string} tableName - Vector store table name * @param {string} openaiApiKey - OpenAI API key * @returns {Promise<{question: string, answer: {text: string, links: Array<{title: string, link: string}>}}>} */ async function getAnswer( supabaseUrl, supabaseSecretKey, question, tableName = "vector_documents", openaiApiKey = process.env.OPENAI_API_KEY ) { try { console.log(`Getting answer for: "${question}"`); // Initialize clients const supabase = createClient(supabaseUrl, supabaseSecretKey, { auth: { persistSession: false } }); const openai = new OpenAI({ apiKey: openaiApiKey, }); // Generate an embedding for the question const embedResponse = await openai.embeddings.create({ model: "text-embedding-3-small", input: question, encoding_format: "float" }); // Access the embedding data correctly from the OpenAI response const queryEmbedding = embedResponse.data[0].embedding; // Use RPC function but make sure embedding is properly formatted console.log(`Searching vector store for answer using match_documents RPC`); // Use the existing RPC function but send embedding correctly const { data: documents, error } = await supabase.rpc( 'match_documents', { query_embedding: queryEmbedding, match_threshold: 0.3, // Lowered from 0.6 to be consistent with search function match_count: 5 // Retrieve top 5 matches } ); if (error) { console.error('Error searching vector store:', error); throw error; } // Add calculated similarity score (since we're not using the match_documents function) const documentsWithSimilarity = documents.map((doc, index) => ({ ...doc, // Approximate similarity based on order (higher for first results) similarity: 1 - (index / (documents.length || 1)) * 0.5 })); if (!documentsWithSimilarity || documentsWithSimilarity.length === 0) { console.log('No matching documents found'); return { question, answer: { text: "I couldn't find any information related to your question.", links: [] } }; } console.log(`Found ${documentsWithSimilarity.length} matching documents`); // Format document content for the LLM using our enhanced documents with similarity scores const documentContent = documentsWithSimilarity.map(doc => { return { content: doc.content, metadata: doc.metadata, similarity: doc.similarity }; }); // Generate an answer using LLM const answer = await generateAnswer(question, documentContent, openai); return { question, answer }; } catch (error) { console.error('Error in getAnswer:', error); return { question, answer: { text: "Sorry, I encountered an error while trying to answer your question.", links: [] } }; } } /** * Generate FAQs using LLM based on retrieved documents * @param {string} query - Original search query * @param {Array} documents - Retrieved documents * @param {OpenAI} openai - OpenAI client * @param {number} limit - Number of FAQs to generate * @returns {Promise<Array<{question: string, answer: string, links: Array<{title: string, link: string}>}>>} */ async function generateFAQs(query, documents, openai, limit) { try { // Prepare context for the LLM const contextText = documents .map(doc => { const { content, metadata, similarity } = doc; const relevanceInfo = `[Relevance: ${(similarity * 100).toFixed(1)}%]`; // Include metadata if available let metadataText = ''; if (metadata) { const { title, sourceUrl, fileName } = metadata; metadataText = [ title ? `Title: ${title}` : '', fileName ? `Source: ${fileName}` : '', sourceUrl ? `URL: ${sourceUrl}` : '' ].filter(Boolean).join(', '); } return `${relevanceInfo} ${metadataText ? `(${metadataText})` : ''}\n${content}\n`; }) .join('\n---\n'); // Prepare the prompt for generating FAQs const prompt = `Original search query: "${query}" Context information from relevant documents: ${contextText} Based on the information provided above, generate ${limit} frequently asked questions with detailed answers. For each FAQ: 1. Create a clear and specific question that someone might ask about this topic 2. Provide a comprehensive answer using only the information from the provided context 3. Include any relevant source references at the end of each answer Format your response as a structured JSON array with one object per FAQ, where each object has: - "question": The FAQ question text - "answer": The comprehensive answer text - "links": An array of objects containing relevant source references with {title, link} properties Only include sources that were explicitly mentioned in the context. Do not make up or invent any information.`; // Generate the FAQs using OpenAI const response = await openai.chat.completions.create({ model: "gpt-4o", messages: [ { role: "system", content: "You are a helpful assistant that generates FAQs based on document content." }, { role: "user", content: prompt } ], temperature: 0.7, max_tokens: 3000, response_format: { type: "json_object" } }); // Parse and format the response try { const responseContent = response.choices[0].message.content; const parsedResponse = JSON.parse(responseContent); if (Array.isArray(parsedResponse.faqs)) { return parsedResponse.faqs.slice(0, limit); } else if (Array.isArray(parsedResponse)) { return parsedResponse.slice(0, limit); } else { console.error('Unexpected response format:', responseContent); return []; } } catch (parseError) { console.error('Error parsing LLM response:', parseError); return []; } } catch (error) { console.error('Error generating FAQs:', error); return []; } } /** * Generate a specific answer for a question using LLM * @param {string} question - Question to answer * @param {Array} documents - Retrieved documents * @param {OpenAI} openai - OpenAI client * @returns {Promise<{text: string, links: Array<{title: string, link: string}>}>} */ async function generateAnswer(question, documents, openai) { try { // Prepare context for the LLM const contextText = documents .map(doc => { const { content, metadata, similarity } = doc; const relevanceInfo = `[Relevance: ${(similarity * 100).toFixed(1)}%]`; // Include metadata if available let metadataText = ''; if (metadata) { const { title, sourceUrl, fileName } = metadata; metadataText = [ title ? `Title: ${title}` : '', fileName ? `Source: ${fileName}` : '', sourceUrl ? `URL: ${sourceUrl}` : '' ].filter(Boolean).join(', '); } return `${relevanceInfo} ${metadataText ? `(${metadataText})` : ''}\n${content}\n`; }) .join('\n---\n'); // Prepare the prompt for generating an answer const prompt = `Question: "${question}" Context information from relevant documents: ${contextText} Based on the information provided above, please answer the question comprehensively and accurately. Only use information present in the provided context. If you can't answer based on the context, say so. Include any relevant source references at the end of your answer. Format your response as a structured JSON object with: - "text": Your complete answer text - "links": An array of objects containing relevant source references with {title, link} properties Only include sources that were explicitly mentioned in the context. Do not make up or invent any information.`; // Generate the answer using OpenAI const response = await openai.chat.completions.create({ model: "gpt-4o", messages: [ { role: "system", content: "You are a helpful assistant that answers questions based on document content." }, { role: "user", content: prompt } ], temperature: 0.3, max_tokens: 2000, response_format: { type: "json_object" } }); // Parse and format the response try { const responseContent = response.choices[0].message.content; const parsedResponse = JSON.parse(responseContent); // Ensure the response has the expected structure return { text: parsedResponse.text || "I couldn't generate an answer from the provided context.", links: Array.isArray(parsedResponse.links) ? parsedResponse.links : [] }; } catch (parseError) { console.error('Error parsing LLM response:', parseError); return { text: "Sorry, I encountered an error while generating your answer.", links: [] }; } } catch (error) { console.error('Error generating answer:', error); return { text: "Sorry, I encountered an error while generating your answer.", links: [] }; } } // Export functions export { searchFaq, getAnswer };