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
This commit is contained in:
Rob Colbert 2026-05-19 16:21:04 -04:00
parent 3c076fc01a
commit a7a6a91a13
5 changed files with 328 additions and 6 deletions

View File

@ -252,6 +252,7 @@ class VectorStoreService extends DtpService {
const response = await this.aiApi.embeddings( const response = await this.aiApi.embeddings(
env.qdrant.embeddingModel, env.qdrant.embeddingModel,
text, text,
env.qdrant.vectorSize,
); );
return response.embedding; return response.embedding;
} }

View File

@ -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<typeof vi.fn>;
};
};
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<string, unknown>).client = mockQdrantMethods;
}
if (!svc.aiApi) {
(svc as Record<string, unknown>).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;
});
});
});

View File

@ -204,7 +204,7 @@ export abstract class AiApi {
/** /**
* Generate an embedding vector for the given text. * Generate an embedding vector for the given text.
*/ */
abstract embeddings(modelId: string, text: string): Promise<IAiEmbeddingResponse>; abstract embeddings(modelId: string, text: string, dimensions?: number): Promise<IAiEmbeddingResponse>;
/** /**
* Forcefully abort any in-progress API request. * Forcefully abort any in-progress API request.

View File

@ -369,8 +369,8 @@ export class OllamaAiApi extends AiApi {
return chatResponse; return chatResponse;
} }
async embeddings(modelId: string, text: string): Promise<IAiEmbeddingResponse> { async embeddings(modelId: string, text: string, dimensions?: number): Promise<IAiEmbeddingResponse> {
const response = await this.client.embeddings({ model: modelId, prompt: text }); const response = await this.client.embed({ model: modelId, input: text, dimensions });
return { embedding: response.embedding, model: modelId }; return { embedding: response.embeddings[0]!, model: modelId };
} }
} }

View File

@ -648,11 +648,12 @@ export class OpenAiApi extends AiApi {
}; };
} }
async embeddings(modelId: string, text: string): Promise<IAiEmbeddingResponse> { async embeddings(modelId: string, text: string, dimensions?: number): Promise<IAiEmbeddingResponse> {
const response = await this.client.embeddings.create({ const response = await this.client.embeddings.create({
model: modelId, model: modelId,
input: text, input: text,
dimensions,
}); });
return { embedding: response.data[0].embedding, model: modelId }; return { embedding: response.data[0]!.embedding, model: modelId };
} }
} }