From a7a6a91a13581ade0f566fae6869f8db1bba0ba0 Mon Sep 17 00:00:00 2001 From: Rob Colbert Date: Tue, 19 May 2026 16:21:04 -0400 Subject: [PATCH] fix: pass requested vector dimension to embedding API (Ollama + OpenAI) The qwen3-embedding:4b model defaults to 2560-d vectors. Both Ollama (client.embed()) and OpenAI support a dimensions parameter to request a specific output size. This change threads the configured qdrant.vectorSize through the AI provider layer so the model returns vectors matching the Qdrant collection dimensions. - AiApi.embeddings() now accepts optional dimensions parameter - Ollama provider: switched from client.embeddings() to client.embed() - OpenAI provider: passes dimensions to embeddings.create() - VectorStoreService.getEmbedding() passes env.qdrant.vectorSize - Added unit tests for dimension mismatch detection, collection creation, and search guards --- gadget-code/src/services/vector-store.ts | 1 + gadget-code/tests/vector-store.test.ts | 320 +++++++++++++++++++++++ packages/ai/src/api.ts | 2 +- packages/ai/src/ollama.ts | 6 +- packages/ai/src/openai.ts | 5 +- 5 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 gadget-code/tests/vector-store.test.ts diff --git a/gadget-code/src/services/vector-store.ts b/gadget-code/src/services/vector-store.ts index 197873d..30785ae 100644 --- a/gadget-code/src/services/vector-store.ts +++ b/gadget-code/src/services/vector-store.ts @@ -252,6 +252,7 @@ class VectorStoreService extends DtpService { const response = await this.aiApi.embeddings( env.qdrant.embeddingModel, text, + env.qdrant.vectorSize, ); return response.embedding; } diff --git a/gadget-code/tests/vector-store.test.ts b/gadget-code/tests/vector-store.test.ts new file mode 100644 index 0000000..e4ec47d --- /dev/null +++ b/gadget-code/tests/vector-store.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockEmbeddings = vi.hoisted(() => vi.fn()); +const mockFindById = vi.hoisted(() => + vi.fn().mockResolvedValue({ + _id: "test-provider-id", + name: "Test Provider", + apiType: "ollama", + baseUrl: "http://test:11434", + apiKey: "", + }), +); + +// Create mock methods shared between QdrantClient mock and test code +const mockQdrantMethods = vi.hoisted(() => ({ + getCollections: vi.fn(), + createCollection: vi.fn(), + getCollection: vi.fn(), + search: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), +})); + +vi.mock("../src/config/env.js", () => ({ + default: { + NODE_ENV: "test", + INSTALL_DIR: "/tmp", + timezone: "UTC", + pkg: { name: "test", version: "0.0.0" }, + site: {}, + ai: { ollama: { apiUrl: "http://test:11434", apiKey: "" } }, + auth: { jwtSecret: "test-secret" }, + session: { + secret: "test-secret", + trustProxy: false, + cookie: { secure: false, sameSite: false }, + }, + google: { cse: { apiKey: "", engineId: "" } }, + mongodb: { host: "localhost:27017", database: "test" }, + qdrant: { + host: "localhost", + port: 6333, + collection: "test-collection", + providerId: "test-provider-id", + embeddingModel: "test-model", + vectorSize: 1024, + }, + redis: { + host: "localhost", + port: 6379, + password: "", + keyPrefix: "test:", + lazyConnect: true, + }, + minio: { + endpoint: "localhost", + port: 9000, + useSsl: false, + accessKey: "", + secretKey: "", + buckets: { uploads: "", images: "", videos: "", audios: "" }, + }, + user: { passwordSalt: "test-salt" }, + https: { enabled: false, address: "localhost", port: 3443 }, + socket: { maxHttpBufferSize: 1048576 }, + frontend: { port: 5173 }, + email: { enabled: false, smtp: {}, contact: {} }, + log: { + https: { enabled: false }, + console: { enabled: false }, + file: { enabled: false }, + }, + }, +})); + +vi.mock("../src/models/ai-provider.js", () => ({ + default: { findById: mockFindById }, +})); + +vi.mock("@gadget/ai", () => ({ + createAiApi: vi.fn().mockReturnValue({ + embeddings: mockEmbeddings, + }), +})); + +vi.mock("@langchain/textsplitters", () => ({ + RecursiveCharacterTextSplitter: vi.fn(function () { + return { + splitText: vi.fn().mockResolvedValue(["chunk1"]), + }; + }), +})); + +// QdrantClient mock constructor that returns the shared mock methods +vi.mock("@qdrant/js-client-rest", () => ({ + QdrantClient: vi.fn(function () { + return mockQdrantMethods; + }), +})); + +import VectorStoreService from "../src/services/vector-store.js"; + +const svc = VectorStoreService as unknown as { + _initialized: boolean; + _dimensionMismatch: boolean; + client: typeof mockQdrantMethods; + aiApi: { + embeddings: ReturnType; + }; +}; + +function setMockDefaults() { + mockQdrantMethods.getCollections.mockResolvedValue({ collections: [] }); + mockQdrantMethods.createCollection.mockResolvedValue(undefined); + mockQdrantMethods.getCollection.mockResolvedValue({}); + mockQdrantMethods.search.mockResolvedValue([]); + mockQdrantMethods.upsert.mockResolvedValue(undefined); + mockQdrantMethods.delete.mockResolvedValue(undefined); +} + +function ensureInstanceSetup() { + // For tests that don't call start(), manually assign client/aiApi + // since start() normally does this via new QdrantClient() + createAiApi() + if (!svc.client) { + (svc as Record).client = mockQdrantMethods; + } + if (!svc.aiApi) { + (svc as Record).aiApi = { embeddings: mockEmbeddings }; + } +} + +describe("VectorStoreService", () => { + beforeEach(() => { + vi.clearAllMocks(); + setMockDefaults(); + + // Reset internal state on the instance + svc._initialized = false; + svc._dimensionMismatch = false; + }); + + describe("search", () => { + beforeEach(() => { + ensureInstanceSetup(); + }); + + it("throws when service is not initialized", async () => { + svc._initialized = false; + svc._dimensionMismatch = false; + + await expect(VectorStoreService.search("test query")).rejects.toThrow( + "VectorStoreService is not initialized", + ); + }); + + it("throws when dimension mismatch flag is set", async () => { + svc._initialized = true; + svc._dimensionMismatch = true; + + await expect(VectorStoreService.search("test query")).rejects.toThrow( + "Vector dimension mismatch: the Qdrant collection dimensions do not match the configured vectorSize (1024)", + ); + }); + + it("throws when query embedding dimensions mismatch config", async () => { + svc._initialized = true; + svc._dimensionMismatch = false; + + mockEmbeddings.mockResolvedValue({ + embedding: new Array(512).fill(0.1), + model: "test-model", + }); + + await expect(VectorStoreService.search("test query")).rejects.toThrow( + "Embedding dimension mismatch: model produced 512 dimensions, but collection expects 1024", + ); + }); + + it("passes vectorSize dimensions to the AI API when embedding", async () => { + svc._initialized = true; + svc._dimensionMismatch = false; + + mockQdrantMethods.search.mockResolvedValue([]); + mockEmbeddings.mockResolvedValue({ + embedding: new Array(1024).fill(0.1), + model: "test-model", + }); + + await VectorStoreService.search("test query"); + + expect(mockEmbeddings).toHaveBeenCalledWith( + "test-model", + "test query", + 1024, + ); + }); + + it("returns hydrated search results", async () => { + svc._initialized = true; + svc._dimensionMismatch = false; + + mockEmbeddings.mockResolvedValue({ + embedding: new Array(1024).fill(0.1), + model: "test-model", + }); + + mockQdrantMethods.search.mockResolvedValue([ + { + id: "point-1", + score: 0.95, + payload: { + content: "some content", + userId: "user-1", + projectId: "proj-1", + sessionId: "session-1", + turnId: "turn-1", + role: "user", + createdAt: "2026-01-01T00:00:00.000Z", + }, + }, + ]); + + const results = await VectorStoreService.search("test query", undefined, 5); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + id: "point-1", + content: "some content", + score: 0.95, + userId: "user-1", + }); + }); + }); + + describe("start / ensureCollection", () => { + it("creates collection when it does not exist", async () => { + mockQdrantMethods.getCollections.mockResolvedValue({ + collections: [], + }); + mockQdrantMethods.createCollection.mockResolvedValue(undefined); + mockEmbeddings.mockResolvedValue({ + embedding: new Array(1024).fill(0.1), + model: "test-model", + }); + + await VectorStoreService.start(); + + expect(mockQdrantMethods.createCollection).toHaveBeenCalledWith( + "test-collection", + { vectors: { size: 1024, distance: "Cosine" } }, + ); + expect(svc._initialized).toBe(true); + expect(svc._dimensionMismatch).toBe(false); + }); + + it("detects dimension mismatch when existing collection has wrong size", async () => { + mockQdrantMethods.getCollections.mockResolvedValue({ + collections: [{ name: "test-collection" }], + }); + mockQdrantMethods.getCollection.mockResolvedValue({ + config: { + params: { + vectors: { size: 768, distance: "Cosine" }, + }, + }, + }); + mockEmbeddings.mockResolvedValue({ + embedding: new Array(1024).fill(0.1), + model: "test-model", + }); + + await VectorStoreService.start(); + + expect(svc._initialized).toBe(true); + expect(svc._dimensionMismatch).toBe(true); + }); + + it("sets dimension mismatch when embedding model produces wrong dimensions", async () => { + mockQdrantMethods.getCollections.mockResolvedValue({ + collections: [], + }); + mockQdrantMethods.createCollection.mockResolvedValue(undefined); + mockEmbeddings.mockResolvedValue({ + embedding: new Array(512).fill(0.1), + model: "test-model", + }); + + await VectorStoreService.start(); + + expect(svc._initialized).toBe(true); + expect(svc._dimensionMismatch).toBe(true); + }); + + it("starts cleanly when everything matches", async () => { + mockQdrantMethods.getCollections.mockResolvedValue({ + collections: [], + }); + mockQdrantMethods.createCollection.mockResolvedValue(undefined); + mockEmbeddings.mockResolvedValue({ + embedding: new Array(1024).fill(0.1), + model: "test-model", + }); + + await VectorStoreService.start(); + + expect(svc._dimensionMismatch).toBe(false); + expect(svc._initialized).toBe(true); + }); + + it("skips start when no providerId is configured", async () => { + const env = await import("../src/config/env.js"); + const originalProviderId = env.default.qdrant.providerId; + env.default.qdrant.providerId = ""; + + await VectorStoreService.start(); + expect(svc._initialized).toBe(false); + + env.default.qdrant.providerId = originalProviderId; + }); + }); +}); diff --git a/packages/ai/src/api.ts b/packages/ai/src/api.ts index 5a0d917..4b31092 100644 --- a/packages/ai/src/api.ts +++ b/packages/ai/src/api.ts @@ -204,7 +204,7 @@ export abstract class AiApi { /** * Generate an embedding vector for the given text. */ - abstract embeddings(modelId: string, text: string): Promise; + abstract embeddings(modelId: string, text: string, dimensions?: number): Promise; /** * Forcefully abort any in-progress API request. diff --git a/packages/ai/src/ollama.ts b/packages/ai/src/ollama.ts index 614c491..7220e88 100644 --- a/packages/ai/src/ollama.ts +++ b/packages/ai/src/ollama.ts @@ -369,8 +369,8 @@ export class OllamaAiApi extends AiApi { return chatResponse; } - async embeddings(modelId: string, text: string): Promise { - const response = await this.client.embeddings({ model: modelId, prompt: text }); - return { embedding: response.embedding, model: modelId }; + async embeddings(modelId: string, text: string, dimensions?: number): Promise { + const response = await this.client.embed({ model: modelId, input: text, dimensions }); + return { embedding: response.embeddings[0]!, model: modelId }; } } diff --git a/packages/ai/src/openai.ts b/packages/ai/src/openai.ts index eb1e471..c2d480d 100644 --- a/packages/ai/src/openai.ts +++ b/packages/ai/src/openai.ts @@ -648,11 +648,12 @@ export class OpenAiApi extends AiApi { }; } - async embeddings(modelId: string, text: string): Promise { + async embeddings(modelId: string, text: string, dimensions?: number): Promise { const response = await this.client.embeddings.create({ model: modelId, input: text, + dimensions, }); - return { embedding: response.data[0].embedding, model: modelId }; + return { embedding: response.data[0]!.embedding, model: modelId }; } }