// src/services/vector-store.ts // Copyright (C) 2026 Robert Colbert // All Rights Reserved import { QdrantClient } from "@qdrant/js-client-rest"; import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters"; import env from "../config/env.js"; import { DtpService } from "../lib/service.js"; import { ChatTurnStatus } from "@gadget/api"; import { createAiApi, IAiEnvironment, IAiProvider as IAiApiProvider, AiApi, } from "@gadget/ai"; import ChatTurn from "../models/chat-turn.js"; import AiProvider from "../models/ai-provider.js"; import { ChatSessionService } from "./index.js"; export interface ISearchFilters { userId?: string; projectId?: string; sessionId?: string; turnId?: string; } export interface ISearchResult { id: string; content: string; score: number; userId: string; projectId: string; sessionId: string; turnId: string; role: string; createdAt: string; } const aiEnv: IAiEnvironment = { NODE_ENV: env.NODE_ENV || "develop", services: { google: { cse: { apiKey: env.google.cse.apiKey, engineId: env.google.cse.engineId, }, }, }, }; class VectorStoreService extends DtpService { private client!: QdrantClient; private aiApi!: AiApi; private splitter!: RecursiveCharacterTextSplitter; private _initialized = false; private _dimensionMismatch = false; get name(): string { return "VectorStoreService"; } get slug(): string { return "svc:vector-store"; } async start(): Promise { if (!env.qdrant.providerId) { this.log.warn( "qdrant.providerId is not configured — vector store service will not start. " + "Set providerId in gadget-code.yaml under qdrant to enable semantic search.", ); return; } this.log.info("initializing Qdrant client", { host: env.qdrant.host, port: env.qdrant.port, collection: env.qdrant.collection, providerId: env.qdrant.providerId, embeddingModel: env.qdrant.embeddingModel, vectorSize: env.qdrant.vectorSize, }); // Initialize Qdrant client this.client = new QdrantClient({ url: `http://${env.qdrant.host}:${env.qdrant.port}`, apiKey: env.qdrant.apiKey || undefined, }); // Load the configured AI provider for embeddings const providerDoc = await AiProvider.findById(env.qdrant.providerId); if (!providerDoc) { this.log.error( `Qdrant providerId "${env.qdrant.providerId}" not found in database — ` + "vector store service will not start.", ); return; } const aiProvider: IAiApiProvider = { _id: providerDoc._id, name: providerDoc.name, sdk: providerDoc.apiType, // map apiType → sdk baseUrl: providerDoc.baseUrl, apiKey: providerDoc.apiKey, }; this.aiApi = createAiApi(aiEnv, aiProvider, this.log); // Initialize text splitter this.splitter = new RecursiveCharacterTextSplitter({ chunkSize: 1000, chunkOverlap: 200, }); // Ensure the Qdrant collection exists and validate dimensions await this.ensureCollection(); // Validate that the embedding model produces vectors of the configured size await this.validateEmbeddingDimensions(); if (this._dimensionMismatch) { this.log.error( "VectorStoreService started with DIMENSION MISMATCH — searches and ingestion will fail. " + "See previous error logs for fix instructions.", ); } this._initialized = true; this.log.info("started", { host: env.qdrant.host, port: env.qdrant.port, collection: env.qdrant.collection, providerId: env.qdrant.providerId, embeddingModel: env.qdrant.embeddingModel, vectorSize: env.qdrant.vectorSize, dimensionMismatch: this._dimensionMismatch, }); } async stop(): Promise { this._initialized = false; this.log.info("stopped"); } /** * Check if the service is initialized and ready. */ get isReady(): boolean { return this._initialized; } /** * Create the Qdrant collection if it doesn't already exist. * Validates existing collection dimensions against the configured vectorSize. */ private async ensureCollection(): Promise { const collections = await this.client.getCollections(); const exists = collections.collections.some( (c) => c.name === env.qdrant.collection, ); if (!exists) { await this.client.createCollection(env.qdrant.collection, { vectors: { size: env.qdrant.vectorSize, distance: "Cosine" as const, }, }); this.log.info(`created Qdrant collection "${env.qdrant.collection}"`, { vectorSize: env.qdrant.vectorSize, distance: "Cosine", }); } else { // Validate existing collection dimensions against config try { const collectionInfo = await this.client.getCollection( env.qdrant.collection, ); const vectorsConfig = collectionInfo.config?.params?.vectors; // Handle both named and unnamed vector configurations // Unnamed: { size: N, distance: "Cosine" } — Named: { "default": { size: N, distance: "Cosine" } } const actualSize = (vectorsConfig as Record)?.size ?? ( Object.values( (vectorsConfig as Record) || {}, )[0] as Record )?.size; if (actualSize && actualSize !== env.qdrant.vectorSize) { this._dimensionMismatch = true; this.log.error( "QDRANT COLLECTION DIMENSION MISMATCH — searches and ingestion will fail!", { collectionName: env.qdrant.collection, configuredVectorSize: env.qdrant.vectorSize, actualCollectionVectorSize: actualSize, fix: `Either (1) delete the collection and restart to recreate it, or (2) update qdrant.vectorSize in your config to ${actualSize}`, }, ); } else { this.log.info( `Qdrant collection "${env.qdrant.collection}" already exists`, { vectorSize: actualSize || env.qdrant.vectorSize, }, ); } } catch (err) { this.log.warn("Could not validate Qdrant collection dimensions", { error: (err as Error).message, }); } } } /** * Validate that the embedding model produces vectors matching the configured vectorSize. * Sends a test embedding and compares its length against env.qdrant.vectorSize. */ private async validateEmbeddingDimensions(): Promise { try { const testEmbedding = await this.getEmbedding("test"); if (testEmbedding.length !== env.qdrant.vectorSize) { this._dimensionMismatch = true; this.log.error( "EMBEDDING MODEL DIMENSION MISMATCH — the configured vectorSize does not match the model output!", { configuredVectorSize: env.qdrant.vectorSize, actualModelDimensions: testEmbedding.length, embeddingModel: env.qdrant.embeddingModel, fix: `Update qdrant.vectorSize in your config to ${testEmbedding.length}`, }, ); } else { this.log.info("embedding dimension validation passed", { vectorSize: testEmbedding.length, model: env.qdrant.embeddingModel, }); } } catch (err) { this.log.warn("Could not validate embedding dimensions at startup", { error: (err as Error).message, }); } } /** * Generate an embedding vector for the given text. */ private async getEmbedding(text: string): Promise { const response = await this.aiApi.embeddings( env.qdrant.embeddingModel, text, ); return response.embedding; } /** * Ingest a chat turn into the vector store. * Fetches the turn by ID, extracts content, chunks, embeds, and upserts to Qdrant. * Fire-and-forget safe — logs errors but does not throw. */ async ingestTurn(turnId: string): Promise { if (!this._initialized) { this.log.warn( "ingestTurn called but service is not initialized — skipping", { turnId }, ); return; } if (this._dimensionMismatch) { this.log.error( "ingestTurn skipped — vector dimension mismatch detected at startup. Fix config and restart.", { turnId, configuredVectorSize: env.qdrant.vectorSize, }, ); return; } try { // Fetch and populate the turn let turn = await ChatTurn.findById(turnId); if (!turn) { this.log.warn("ingestTurn: turn not found", { turnId }); return; } // Only ingest finished turns if (turn.status !== ChatTurnStatus.Finished) { this.log.debug("ingestTurn: skipping non-finished turn", { turnId, status: turn.status, }); return; } // Populate references for metadata turn = await ChatTurn.populate(turn, ChatSessionService.populateChatTurn); // Extract content const userPrompt = turn.prompts.user || ""; // Extract agent response: concatenate all 'responding' mode blocks let agentResponse = ""; for (const block of turn.blocks) { if (block.mode === "responding" && typeof block.content === "string") { agentResponse += block.content + "\n"; } } // Combine user + agent text for chunking const combinedText = [ userPrompt ? `User: ${userPrompt}` : "", agentResponse ? `Agent: ${agentResponse.trim()}` : "", ] .filter(Boolean) .join("\n\n"); if (!combinedText.trim()) { this.log.debug("ingestTurn: no content to ingest", { turnId }); return; } // Chunk the text const chunks = await this.splitter.splitText(combinedText); // Determine role for each chunk based on content const points = []; for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]!; let role: string; if (chunk.startsWith("User:") && !chunk.includes("Agent:")) { role = "user"; } else if (chunk.startsWith("Agent:") && !chunk.includes("User:")) { role = "agent"; } else { role = "both"; } const embedding = await this.getEmbedding(chunk); // Validate embedding dimensions before upsert if (embedding.length !== env.qdrant.vectorSize) { this.log.error( "embedding dimension mismatch during ingest — skipping chunk", { expected: env.qdrant.vectorSize, actual: embedding.length, turnId, chunkIndex: i, fix: `Update qdrant.vectorSize in your config to ${embedding.length}`, }, ); continue; } points.push({ id: `${turnId}:${i}`, vector: embedding, payload: { content: chunk, userId: String(turn.user), projectId: turn.project ? String(turn.project) : "", sessionId: String(turn.session), turnId: turn._id, role, createdAt: turn.createdAt.toISOString(), }, }); } // Upsert all points await this.client.upsert(env.qdrant.collection, { wait: false, points, }); this.log.info("ingested turn to vector store", { turnId, chunkCount: points.length, }); } catch (error) { this.log.error("ingestTurn failed", { turnId, error, }); // Do not rethrow — this is designed to be fire-and-forget } } /** * Search the vector store for relevant chunks. */ async search( query: string, filters?: ISearchFilters, topK: number = 10, ): Promise { if (!this._initialized) { throw new Error("VectorStoreService is not initialized"); } if (this._dimensionMismatch) { throw new Error( `Vector dimension mismatch: the Qdrant collection dimensions do not match the ` + `configured vectorSize (${env.qdrant.vectorSize}). Either delete the collection ` + `and restart, or update qdrant.vectorSize in your config.`, ); } const queryVector = await this.getEmbedding(query); // Validate embedding dimensions before searching if (queryVector.length !== env.qdrant.vectorSize) { throw new Error( `Embedding dimension mismatch: model produced ${queryVector.length} dimensions, ` + `but collection expects ${env.qdrant.vectorSize}. ` + `Update qdrant.vectorSize in your config to ${queryVector.length}.`, ); } // Build Qdrant filter from provided filters const must: Array<{ key: string; match: { value: string }; }> = []; if (filters?.userId) { must.push({ key: "userId", match: { value: filters.userId } }); } if (filters?.projectId) { must.push({ key: "projectId", match: { value: filters.projectId } }); } if (filters?.sessionId) { must.push({ key: "sessionId", match: { value: filters.sessionId } }); } if (filters?.turnId) { must.push({ key: "turnId", match: { value: filters.turnId } }); } const searchResults = await this.client.search(env.qdrant.collection, { vector: queryVector, limit: topK, filter: must.length > 0 ? { must } : undefined, with_payload: true, }); return searchResults.map((point) => ({ id: point.id as string, content: (point.payload as any)?.content || "", score: point.score ?? 0, userId: (point.payload as any)?.userId || "", projectId: (point.payload as any)?.projectId || "", sessionId: (point.payload as any)?.sessionId || "", turnId: (point.payload as any)?.turnId || "", role: (point.payload as any)?.role || "", createdAt: (point.payload as any)?.createdAt || "", })); } /** * Remove all vector points for a given turn. * Used for cleanup when a turn is deleted. */ async removeTurnPoints(turnId: string): Promise { if (!this._initialized) { this.log.warn( "removeTurnPoints called but service is not initialized — skipping", { turnId }, ); return; } try { await this.client.delete(env.qdrant.collection, { filter: { must: [{ key: "turnId", match: { value: turnId } }], }, }); this.log.info("removed vector points for turn", { turnId }); } catch (error) { this.log.error("removeTurnPoints failed", { turnId, error, }); } } } export default new VectorStoreService();