diff --git a/apps/server/src/data/migrations/00018-create-node-embeddings-table.ts b/apps/server/src/data/migrations/00018-create-node-embeddings-table.ts index a9664792..fd142a21 100644 --- a/apps/server/src/data/migrations/00018-create-node-embeddings-table.ts +++ b/apps/server/src/data/migrations/00018-create-node-embeddings-table.ts @@ -10,10 +10,14 @@ export const createNodeEmbeddingsTable: Migration = { .addColumn('root_id', 'varchar(30)', (col) => col.notNull()) .addColumn('workspace_id', 'varchar(30)', (col) => col.notNull()) .addColumn('text', 'text', (col) => col.notNull()) + .addColumn('summary', 'text') .addColumn('embedding_vector', sql`vector(2000)`, (col) => col.notNull()) .addColumn( 'search_vector', - sql`tsvector GENERATED ALWAYS AS (to_tsvector('english', text)) STORED` + sql`tsvector GENERATED ALWAYS AS ( + setweight(to_tsvector('english', COALESCE(text, '')), 'A') || + setweight(to_tsvector('english', COALESCE(summary, '')), 'B') + ) STORED` ) .addColumn('created_at', 'timestamptz', (col) => col.notNull()) .addColumn('updated_at', 'timestamptz') diff --git a/apps/server/src/data/migrations/00019-create-document-embeddings-table.ts b/apps/server/src/data/migrations/00019-create-document-embeddings-table.ts index 612cd5c3..771751db 100644 --- a/apps/server/src/data/migrations/00019-create-document-embeddings-table.ts +++ b/apps/server/src/data/migrations/00019-create-document-embeddings-table.ts @@ -8,10 +8,14 @@ export const createDocumentEmbeddingsTable: Migration = { .addColumn('chunk', 'integer', (col) => col.notNull()) .addColumn('workspace_id', 'varchar(30)', (col) => col.notNull()) .addColumn('text', 'text', (col) => col.notNull()) + .addColumn('summary', 'text') .addColumn('embedding_vector', sql`vector(2000)`, (col) => col.notNull()) .addColumn( 'search_vector', - sql`tsvector GENERATED ALWAYS AS (to_tsvector('english', text)) STORED` + sql`tsvector GENERATED ALWAYS AS ( + setweight(to_tsvector('english', COALESCE(text, '')), 'A') || + setweight(to_tsvector('english', COALESCE(summary, '')), 'B') + ) STORED` ) .addColumn('created_at', 'timestamptz', (col) => col.notNull()) .addColumn('updated_at', 'timestamptz') diff --git a/apps/server/src/data/schema.ts b/apps/server/src/data/schema.ts index 8c162a7f..1767b871 100644 --- a/apps/server/src/data/schema.ts +++ b/apps/server/src/data/schema.ts @@ -265,6 +265,7 @@ interface NodeEmbeddingTable { root_id: ColumnType; workspace_id: ColumnType; text: ColumnType; + summary: ColumnType; embedding_vector: ColumnType; search_vector: ColumnType; created_at: ColumnType; @@ -280,6 +281,7 @@ interface DocumentEmbeddingTable { chunk: ColumnType; workspace_id: ColumnType; text: ColumnType; + summary: ColumnType; embedding_vector: ColumnType; search_vector: ColumnType; created_at: ColumnType; diff --git a/apps/server/src/jobs/assistant-response.ts b/apps/server/src/jobs/assistant-response.ts index d70d65bb..8af33d67 100644 --- a/apps/server/src/jobs/assistant-response.ts +++ b/apps/server/src/jobs/assistant-response.ts @@ -81,7 +81,7 @@ export const assistantResponseHandler: JobHandler< userId: user.id, userDetails: { name: user.name || 'User', - email: user.email || 'unknown@example.com', + email: user.email || '', }, parentMessageId: message.parent_id || message.id, currentMessageId: message.id, diff --git a/apps/server/src/jobs/embed-document.ts b/apps/server/src/jobs/embed-document.ts index fe54a9f9..4f0ce2e8 100644 --- a/apps/server/src/jobs/embed-document.ts +++ b/apps/server/src/jobs/embed-document.ts @@ -59,14 +59,6 @@ export const embedDocumentHandler = async (input: { return; } - const textChunks = await chunkText( - text, - { - type: 'document', - node: node, - }, - enrichChunk - ); const embeddings = new OpenAIEmbeddings({ apiKey: configuration.ai.embedding.apiKey, modelName: configuration.ai.embedding.modelName, @@ -75,21 +67,38 @@ export const embedDocumentHandler = async (input: { const existingEmbeddings = await database .selectFrom('document_embeddings') - .select(['chunk', 'text']) + .select(['chunk', 'text', 'summary']) .where('document_id', '=', documentId) .execute(); + const textChunks = await chunkText( + text, + existingEmbeddings.map((e) => ({ + text: e.text, + summary: e.summary ?? undefined, + })), + { type: 'document', node: node }, + enrichChunk + ); + const embeddingsToCreateOrUpdate: CreateDocumentEmbedding[] = []; for (let i = 0; i < textChunks.length; i++) { - const textChunk = textChunks[i]; - if (!textChunk) continue; + const chunk = textChunks[i]; + if (!chunk) { + continue; + } + const existing = existingEmbeddings.find((e) => e.chunk === i); - if (existing && existing.text === textChunk) continue; + if (existing && existing.text === chunk.text) { + continue; + } + embeddingsToCreateOrUpdate.push({ document_id: documentId, chunk: i, workspace_id: document.workspace_id, - text: textChunk, + text: chunk.text, + summary: chunk.summary, embedding_vector: [], created_at: new Date(), }); @@ -98,7 +107,9 @@ export const embedDocumentHandler = async (input: { const batchSize = configuration.ai.embedding.batchSize; for (let i = 0; i < embeddingsToCreateOrUpdate.length; i += batchSize) { const batch = embeddingsToCreateOrUpdate.slice(i, i + batchSize); - const textsToEmbed = batch.map((item) => item.text); + const textsToEmbed = batch.map((item) => + item.summary ? `${item.summary}\n\n${item.text}` : item.text + ); const embeddingVectors = await embeddings.embedDocuments(textsToEmbed); for (let j = 0; j < batch.length; j++) { const vector = embeddingVectors[j]; @@ -121,6 +132,7 @@ export const embedDocumentHandler = async (input: { chunk: embedding.chunk, workspace_id: embedding.workspace_id, text: embedding.text, + summary: embedding.summary, embedding_vector: sql.raw( `'[${embedding.embedding_vector.join(',')}]'::vector` ), @@ -129,6 +141,7 @@ export const embedDocumentHandler = async (input: { .onConflict((oc) => oc.columns(['document_id', 'chunk']).doUpdateSet({ text: sql.ref('excluded.text'), + summary: sql.ref('excluded.summary'), embedding_vector: sql.ref('excluded.embedding_vector'), updated_at: new Date(), }) diff --git a/apps/server/src/jobs/embed-node.ts b/apps/server/src/jobs/embed-node.ts index d634677b..768d51f5 100644 --- a/apps/server/src/jobs/embed-node.ts +++ b/apps/server/src/jobs/embed-node.ts @@ -154,12 +154,6 @@ export const embedNodeHandler = async (input: { return; } - const textChunks = await chunkText( - text, - { type: 'node', node: node }, - enrichChunk - ); - const embeddings = new OpenAIEmbeddings({ apiKey: configuration.ai.embedding.apiKey, modelName: configuration.ai.embedding.modelName, @@ -168,19 +162,29 @@ export const embedNodeHandler = async (input: { const existingEmbeddings = await database .selectFrom('node_embeddings') - .select(['chunk', 'text']) + .select(['chunk', 'text', 'summary']) .where('node_id', '=', nodeId) .execute(); + const textChunks = await chunkText( + text, + existingEmbeddings.map((e) => ({ + text: e.text, + summary: e.summary ?? undefined, + })), + { type: 'node', node: node }, + enrichChunk + ); + const embeddingsToCreateOrUpdate: CreateNodeEmbedding[] = []; for (let i = 0; i < textChunks.length; i++) { - const textChunk = textChunks[i]; - if (!textChunk) { + const chunk = textChunks[i]; + if (!chunk) { continue; } const existing = existingEmbeddings.find((e) => e.chunk === i); - if (existing && existing.text === textChunk) { + if (existing && existing.text === chunk.text) { continue; } @@ -190,7 +194,8 @@ export const embedNodeHandler = async (input: { parent_id: node.parent_id, root_id: node.root_id, workspace_id: node.workspace_id, - text: textChunk, + text: chunk.text, + summary: chunk.summary, embedding_vector: [], created_at: new Date(), }); @@ -199,7 +204,9 @@ export const embedNodeHandler = async (input: { const batchSize = configuration.ai.embedding.batchSize; for (let i = 0; i < embeddingsToCreateOrUpdate.length; i += batchSize) { const batch = embeddingsToCreateOrUpdate.slice(i, i + batchSize); - const textsToEmbed = batch.map((item) => item.text); + const textsToEmbed = batch.map((item) => + item.summary ? `${item.summary}\n\n${item.text}` : item.text + ); const embeddingVectors = await embeddings.embedDocuments(textsToEmbed); for (let j = 0; j < batch.length; j++) { const vector = embeddingVectors[j]; @@ -224,6 +231,7 @@ export const embedNodeHandler = async (input: { root_id: embedding.root_id, workspace_id: embedding.workspace_id, text: embedding.text, + summary: embedding.summary, embedding_vector: sql.raw( `'[${embedding.embedding_vector.join(',')}]'::vector` ), @@ -232,6 +240,7 @@ export const embedNodeHandler = async (input: { .onConflict((oc) => oc.columns(['node_id', 'chunk']).doUpdateSet({ text: sql.ref('excluded.text'), + summary: sql.ref('excluded.summary'), embedding_vector: sql.ref('excluded.embedding_vector'), updated_at: new Date(), }) diff --git a/apps/server/src/lib/assistant.ts b/apps/server/src/lib/assistant.ts index 8e0c7db0..7cbca447 100644 --- a/apps/server/src/lib/assistant.ts +++ b/apps/server/src/lib/assistant.ts @@ -166,7 +166,7 @@ async function rerankContextDocuments(state: AssistantChainState) { })); const rerankedContext = await rerankDocuments( docsForRerank, - state.rewrittenQuery + state.rewrittenQuery.semanticQuery ); return { rerankedContext }; diff --git a/apps/server/src/lib/chunking.ts b/apps/server/src/lib/chunking.ts index fa8a66d0..1650edfd 100644 --- a/apps/server/src/lib/chunking.ts +++ b/apps/server/src/lib/chunking.ts @@ -224,12 +224,13 @@ async function fetchMetadata(metadata?: { export async function chunkText( text: string, + existingChunks: Array<{ text: string; summary?: string }>, metadata?: { type: 'node' | 'document'; node: SelectNode }, enrichFn?: ( prompt: string, baseVars: Record ) => Promise -): Promise { +): Promise> { const chunkSize = configuration.ai.chunking.defaultChunkSize; const chunkOverlap = configuration.ai.chunking.defaultOverlap; const splitter = new RecursiveCharacterTextSplitter({ @@ -238,26 +239,38 @@ export async function chunkText( }); const docs = await splitter.createDocuments([text]); let chunks = docs - .map((doc) => doc.pageContent) - .filter((c) => c.trim().length > 5); + .map((doc) => ({ text: doc.pageContent })) + .filter((c) => c.text.trim().length > 5); if (configuration.ai.chunking.enhanceWithContext && enrichFn && metadata) { - const enriched: string[] = []; const enrichedMetadata = await fetchMetadata(metadata); if (!enrichedMetadata) { return chunks; } + + const enrichedChunks: Array<{ text: string; summary: string }> = []; + for (const chunk of chunks) { + const existingChunk = existingChunks.find((ec) => ec.text === chunk.text); + if (existingChunk?.summary) { + enrichedChunks.push({ + text: chunk.text, + summary: existingChunk.summary, + }); + + continue; + } + const { prompt, baseVars } = prepareEnrichmentPrompt( - chunk, + chunk.text, text, enrichedMetadata ); - const enrichment = await enrichFn(prompt, baseVars); - enriched.push(enrichment + '\n\n' + chunk); + const summary = await enrichFn(prompt, baseVars); + enrichedChunks.push({ text: chunk.text, summary }); } - return enriched; + return enrichedChunks; } return chunks; } diff --git a/apps/server/src/lib/llm-prompts.ts b/apps/server/src/lib/llm-prompts.ts index 061bf9d4..b112a3ef 100644 --- a/apps/server/src/lib/llm-prompts.ts +++ b/apps/server/src/lib/llm-prompts.ts @@ -2,94 +2,244 @@ import { PromptTemplate, ChatPromptTemplate } from '@langchain/core/prompts'; import { NodeMetadata, DocumentMetadata } from '@/types/chunking'; export const queryRewritePrompt = PromptTemplate.fromTemplate( - `You are an expert at rewriting queries for information retrieval within Colanode. + ` + You are an expert at rewriting search queries to optimize for both semantic similarity and keyword-based search in a document retrieval system. + Your task is to generate two separate optimized queries: + 1. A semantic search query optimized for vector embeddings and semantic similarity + 2. A keyword search query optimized for full-text search using PostgreSQL's tsquery + + + + For semantic search query: + 1. Focus on conceptual meaning and intent + 2. Include context-indicating terms + 3. Preserve relationship words between concepts + 4. Expand concepts with related terms + 5. Remove noise words and syntax-specific terms -Guidelines: -1. Extract the core information need. -2. Remove filler words. -3. Preserve key technical terms and dates. - -Original query: -{query} - -Rewrite the query and return only the rewritten version.` + For keyword search query: + 1. Focus on specific technical terms and exact matches + 2. Include variations of key terms + 3. Keep proper nouns and domain-specific vocabulary + 4. Optimize for PostgreSQL's websearch_to_tsquery syntax + 5. Include essential filters and constraints + + + + Original query: {query} + + + + Return a JSON object with: + {{ + "semanticQuery": "optimized query for semantic search", + "keywordQuery": "optimized query for keyword search" +}} +` ); export const summarizationPrompt = PromptTemplate.fromTemplate( - `Summarize the following text focusing on key points relevant to the user's query. -If the text is short (<100 characters), return it as is. - -Text: {text} -User Query: {query}` + ` + Summarize the following text focusing on key points relevant to the user's query. + If the text is short (<100 characters), return it as is. + + + + Text: {text} + User Query: {query} +` ); export const rerankPrompt = PromptTemplate.fromTemplate( - `Re-rank the following list of documents by their relevance to the query. -For each document, provide: -- Original index (from input) -- A relevance score between 0 and 1 -- Document type (node or document) -- Source ID + ` + You are the final relevance judge in a hybrid search system. Your task is to re-rank search results by analyzing their true relevance to the user's query. + These documents have already passed through: + 1. Semantic search (vector similarity) + 2. Keyword-based search (full-text search) -User query: -{query} + Your ranking will determine the final order and which documents are shown to the user. + + + + Each document contains: + - Main content text + - Optional summary/context + - Metadata (type, creation info) + The documents can be: + - Workspace nodes (various content types) + - Documents (files, notes) + - Database records + + + + Evaluate relevance based on: + 1. Direct answer presence (highest priority) + - Does the content directly answer the query? + - Are key details or facts present? -Documents: -{context} + 2. Contextual relevance + - How well does the content relate to the query topic? + - Is the context/summary relevant? + - Does it provide important background information? -Return an array of rankings in JSON format.` + 3. Information freshness + - For time-sensitive queries, prefer recent content + - For conceptual queries, recency matters less + + 4. Content completeness + - Does it provide comprehensive information? + - Are related concepts explained? + + 5. Source appropriateness + - Is the document type appropriate for the query? + - Does the source authority match the information need? + + + + Score from 0 to 1, where: + 1.0: Perfect match, directly answers query + 0.8-0.9: Highly relevant, contains most key information + 0.5-0.7: Moderately relevant, contains some useful information + 0.2-0.4: Tangentially relevant, minimal useful information + 0.0-0.1: Not relevant or useful for the query + + + + {context} + + + + {query} + + + + Return a JSON array of objects, each containing: + - "index": original position (integer) + - "score": relevance score (0-1 float) + - "type": document type (string) + - "sourceId": original source ID (string) + + Example: + [ + {{"index": 2, "score": 0.95, "type": "document", "sourceId": "doc123"}}, + {{"index": 0, "score": 0.7, "type": "node", "sourceId": "node456"}} + ] +` ); export const answerPrompt = ChatPromptTemplate.fromTemplate( - `You are Colanode's AI assistant. + ` + You are an AI assistant in a collaboration workspace app called Colanode. -CURRENT TIME: {currentTimestamp} -WORKSPACE: {workspaceName} -USER: {userName} ({userEmail}) + CURRENT TIME: {currentTimestamp} + WORKSPACE: {workspaceName} + USER: {userName} ({userEmail}) + -CONVERSATION HISTORY: -{formattedChatHistory} + + {formattedChatHistory} + -RELEVANT CONTEXT: -{formattedDocuments} + + {formattedDocuments} + -USER QUERY: -{question} + + {question} + -Based solely on the conversation history and the relevant context above, provide a clear and professional answer to the user's query. In your answer, include exact quotes from the provided context that support your answer. -If the relevant context does not contain any information that answers the user's query, respond with "No relevant information found." + + Based solely on the current conversation history and the relevant context above, provide a clear and professional answer to the user's query. In your answer, include exact quotes from the provided context that support your answer. + If the relevant context does not contain any information that answers the user's query, respond with "No relevant information found." This is a critical step to ensure correct answers. + -Return your response as a JSON object with the following structure: -{{ - "answer": , - "citations": [ - {{ "sourceId": , "quote": }}, - ... - ] -}}` + + Return your response as a JSON object with the following structure: + {{ + "answer": , + "citations": [ + {{ "sourceId": , "quote": }}, + ... + ] + }} +` ); export const intentRecognitionPrompt = PromptTemplate.fromTemplate( - `Determine if the following user query requires retrieving additional context. -Return exactly one value: "retrieve" or "no_context". + ` + Determine if the following user query requires retrieving context from the workspace's knowledge base. + You are a crucial decision point in an AI assistant system that must decide between: + 1. Retrieving and using specific context from the workspace ("retrieve") + 2. Answering directly from general knowledge ("no_context") + -Conversation History: -{formattedChatHistory} + + This system has access to: + - Documents and their embeddings + - Node content (various types of workspace items) + - Database records and their fields + - Previous conversation history + -User Query: -{question}` + + Return "retrieve" when the query: + - Asks about specific workspace content, documents, or data + - References previous conversations or shared content + - Mentions specific projects, tasks, or workspace items + - Requires up-to-date information from the workspace + - Contains temporal references to workspace activity + - Asks about specific people or collaborators + - Needs details about database records or fields + + Return "no_context" when the query: + - Asks for general knowledge or common facts + - Requests simple calculations or conversions + - Asks about general concepts without workspace specifics + - Makes small talk + - Requests explanations of universal concepts + - Can be answered correctly without workspace-specific information + + + + "retrieve" examples: + - "What did John say about the API design yesterday?" + - "Show me the latest documentation about user authentication" + - "Find records in the Projects database where status is completed" + - "What were the key points from our last meeting?" + + "no_context" examples: + - "What is REST API?" + - "How do I write a good commit message?" + - "Convert 42 kilometers to miles" + - "What's your name?" + - "Explain what is Docker in simple terms" + + + + {formattedChatHistory} + + + + {question} + + + + Return exactly one value: "retrieve" or "no_context" +` ); export const noContextPrompt = PromptTemplate.fromTemplate( - `Answer the following query concisely using general knowledge, without retrieving additional context. + ` + Answer the following query concisely using general knowledge, without retrieving additional context. Return only the answer. + -Conversation History: -{formattedChatHistory} + + {formattedChatHistory} + -User Query: -{question} - -Return only the answer.` + + {question} +` ); export const databaseFilterPrompt = ChatPromptTemplate.fromTemplate( diff --git a/apps/server/src/services/document-retrieval-service.ts b/apps/server/src/services/document-retrieval-service.ts index 9e2923ba..2593e87b 100644 --- a/apps/server/src/services/document-retrieval-service.ts +++ b/apps/server/src/services/document-retrieval-service.ts @@ -4,6 +4,8 @@ import { database } from '@/data/database'; import { configuration } from '@/lib/configuration'; import { sql } from 'kysely'; import { SearchResult } from '@/types/retrieval'; +import { RewrittenQuery } from '@/types/llm'; + export class DocumentRetrievalService { private embeddings = new OpenAIEmbeddings({ apiKey: configuration.ai.embedding.apiKey, @@ -12,13 +14,15 @@ export class DocumentRetrievalService { }); public async retrieve( - query: string, + rewrittenQuery: RewrittenQuery, workspaceId: string, userId: string, limit = configuration.ai.retrieval.hybridSearch.maxResults, contextNodeIds?: string[] ): Promise { - const embedding = await this.embeddings.embedQuery(query); + const embedding = await this.embeddings.embedQuery( + rewrittenQuery.semanticQuery + ); if (!embedding) { return []; } @@ -31,7 +35,13 @@ export class DocumentRetrievalService { limit, contextNodeIds ), - this.keywordSearch(query, workspaceId, userId, limit, contextNodeIds), + this.keywordSearch( + rewrittenQuery.keywordQuery, + workspaceId, + userId, + limit, + contextNodeIds + ), ]); return this.combineSearchResults(semanticResults, keywordResults); @@ -57,6 +67,7 @@ export class DocumentRetrievalService { .select((eb) => [ 'document_embeddings.document_id as id', 'document_embeddings.text', + 'document_embeddings.summary', 'documents.created_at', 'documents.created_by', 'document_embeddings.chunk as chunk_index', @@ -78,6 +89,7 @@ export class DocumentRetrievalService { .groupBy([ 'document_embeddings.document_id', 'document_embeddings.text', + 'document_embeddings.summary', 'documents.created_at', 'documents.created_by', 'document_embeddings.chunk', @@ -89,6 +101,7 @@ export class DocumentRetrievalService { return results.map((result) => ({ id: result.id, text: result.text, + summary: result.summary, score: result.similarity, type: 'semantic', createdAt: result.created_at, @@ -117,6 +130,7 @@ export class DocumentRetrievalService { .select((eb) => [ 'document_embeddings.document_id as id', 'document_embeddings.text', + 'document_embeddings.summary', 'documents.created_at', 'documents.created_by', 'document_embeddings.chunk as chunk_index', @@ -145,6 +159,7 @@ export class DocumentRetrievalService { 'documents.created_at', 'documents.created_by', 'document_embeddings.chunk', + 'document_embeddings.summary', ]) .orderBy('rank', 'desc') .limit(limit) @@ -153,6 +168,7 @@ export class DocumentRetrievalService { return results.map((result) => ({ id: result.id, text: result.text, + summary: result.summary, score: result.rank, type: 'keyword', createdAt: result.created_at, @@ -243,7 +259,7 @@ export class DocumentRetrievalService { ? authorMap.get(result.createdBy) : null; return new Document({ - pageContent: result.text, + pageContent: `${result.summary}\n\n${result.text}`, metadata: { id: result.id, score: result.finalScore, @@ -253,7 +269,7 @@ export class DocumentRetrievalService { author: author ? { id: author.id, - name: author.name || 'Anonymous', + name: author.name || 'Unknown', } : null, }, diff --git a/apps/server/src/services/llm-service.ts b/apps/server/src/services/llm-service.ts index 3eacf356..80d8f120 100644 --- a/apps/server/src/services/llm-service.ts +++ b/apps/server/src/services/llm-service.ts @@ -11,6 +11,8 @@ import { CitedAnswer, databaseFilterSchema, DatabaseFilterResult, + RewrittenQuery, + rewrittenQuerySchema, } from '@/types/llm'; import { @@ -61,13 +63,10 @@ const getChatModel = ( } }; -export const rewriteQuery = async (query: string): Promise => { +export const rewriteQuery = async (query: string): Promise => { const task = 'queryRewrite'; - const model = getChatModel(task); - return queryRewritePrompt - .pipe(model) - .pipe(new StringOutputParser()) - .invoke({ query }); + const model = getChatModel(task).withStructuredOutput(rewrittenQuerySchema); + return queryRewritePrompt.pipe(model).invoke({ query }); }; export const summarizeDocument = async ( diff --git a/apps/server/src/services/node-retrieval-service.ts b/apps/server/src/services/node-retrieval-service.ts index 96eee6cd..cabecd34 100644 --- a/apps/server/src/services/node-retrieval-service.ts +++ b/apps/server/src/services/node-retrieval-service.ts @@ -4,7 +4,7 @@ import { database } from '@/data/database'; import { configuration } from '@/lib/configuration'; import { sql } from 'kysely'; import { SearchResult } from '@/types/retrieval'; - +import { RewrittenQuery } from '@/types/llm'; export class NodeRetrievalService { private embeddings = new OpenAIEmbeddings({ apiKey: configuration.ai.embedding.apiKey, @@ -13,13 +13,15 @@ export class NodeRetrievalService { }); public async retrieve( - query: string, + rewrittenQuery: RewrittenQuery, workspaceId: string, userId: string, limit = configuration.ai.retrieval.hybridSearch.maxResults, contextNodeIds?: string[] ): Promise { - const embedding = await this.embeddings.embedQuery(query); + const embedding = await this.embeddings.embedQuery( + rewrittenQuery.semanticQuery + ); if (!embedding) { return []; } @@ -32,7 +34,13 @@ export class NodeRetrievalService { limit, contextNodeIds ), - this.keywordSearch(query, workspaceId, userId, limit, contextNodeIds), + this.keywordSearch( + rewrittenQuery.keywordQuery, + workspaceId, + userId, + limit, + contextNodeIds + ), ]); return this.combineSearchResults(semanticResults, keywordResults); @@ -57,6 +65,7 @@ export class NodeRetrievalService { .select((eb) => [ 'node_embeddings.node_id as id', 'node_embeddings.text', + 'node_embeddings.summary', 'nodes.created_at', 'nodes.created_by', 'node_embeddings.chunk as chunk_index', @@ -81,6 +90,7 @@ export class NodeRetrievalService { 'nodes.created_at', 'nodes.created_by', 'node_embeddings.chunk', + 'node_embeddings.summary', ]) .orderBy('similarity', 'asc') .limit(limit) @@ -89,6 +99,7 @@ export class NodeRetrievalService { return results.map((result) => ({ id: result.id, text: result.text, + summary: result.summary, score: result.similarity, type: 'semantic', createdAt: result.created_at, @@ -116,6 +127,7 @@ export class NodeRetrievalService { .select((eb) => [ 'node_embeddings.node_id as id', 'node_embeddings.text', + 'node_embeddings.summary', 'nodes.created_at', 'nodes.created_by', 'node_embeddings.chunk as chunk_index', @@ -144,6 +156,7 @@ export class NodeRetrievalService { 'nodes.created_at', 'nodes.created_by', 'node_embeddings.chunk', + 'node_embeddings.summary', ]) .orderBy('rank', 'desc') .limit(limit) @@ -152,6 +165,7 @@ export class NodeRetrievalService { return results.map((result) => ({ id: result.id, text: result.text, + summary: result.summary, score: result.rank, type: 'keyword', createdAt: result.created_at, @@ -242,7 +256,7 @@ export class NodeRetrievalService { ? authorMap.get(result.createdBy) : null; return new Document({ - pageContent: result.text, + pageContent: `${result.summary}\n\n${result.text}`, metadata: { id: result.id, score: result.finalScore, @@ -252,7 +266,7 @@ export class NodeRetrievalService { author: author ? { id: author.id, - name: author.name || 'Anonymous', + name: author.name || 'Unknown', } : null, }, diff --git a/apps/server/src/types/assistant.ts b/apps/server/src/types/assistant.ts index f1df25df..be86d743 100644 --- a/apps/server/src/types/assistant.ts +++ b/apps/server/src/types/assistant.ts @@ -1,5 +1,11 @@ import { Document } from '@langchain/core/documents'; import { Annotation } from '@langchain/langgraph'; +import { + RerankedDocuments, + CitedAnswer, + DatabaseFilterResult, + RewrittenQuery, +} from './llm'; export type Citation = { sourceId: string; @@ -30,20 +36,13 @@ export const ResponseState = Annotation.Root({ userDetails: Annotation<{ name: string; email: string }>(), parentMessageId: Annotation(), currentMessageId: Annotation(), - rewrittenQuery: Annotation(), + rewrittenQuery: Annotation(), contextDocuments: Annotation(), chatHistory: Annotation(), - rerankedContext: Annotation< - Array<{ - index: number; - score: number; - type: string; - sourceId: string; - }> - >(), + rerankedContext: Annotation(), topContext: Annotation(), finalAnswer: Annotation(), - citations: Annotation>(), + citations: Annotation(), originalMessage: Annotation(), intent: Annotation<'retrieve' | 'no_context'>(), databaseContext: Annotation< @@ -54,13 +53,7 @@ export const ResponseState = Annotation.Root({ sampleRecords: any[]; }> >(), - databaseFilters: Annotation<{ - shouldFilter: boolean; - filters: Array<{ - databaseId: string; - filters: any[]; - }>; - }>(), + databaseFilters: Annotation(), selectedContextNodeIds: Annotation(), }); diff --git a/apps/server/src/types/llm.ts b/apps/server/src/types/llm.ts index f7650c7f..814e05d8 100644 --- a/apps/server/src/types/llm.ts +++ b/apps/server/src/types/llm.ts @@ -33,3 +33,10 @@ export const databaseFilterSchema = z.object({ ), }); export type DatabaseFilterResult = z.infer; + +export const rewrittenQuerySchema = z.object({ + semanticQuery: z.string(), + keywordQuery: z.string(), +}); + +export type RewrittenQuery = z.infer; diff --git a/apps/server/src/types/retrieval.ts b/apps/server/src/types/retrieval.ts index 4eb992ec..0a7b8e8c 100644 --- a/apps/server/src/types/retrieval.ts +++ b/apps/server/src/types/retrieval.ts @@ -1,6 +1,7 @@ export type SearchResult = { id: string; text: string; + summary: string | null; score: number; type: 'semantic' | 'keyword'; createdAt?: Date;