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:
parent
3c076fc01a
commit
a7a6a91a13
@ -252,6 +252,7 @@ class VectorStoreService extends DtpService {
|
||||
const response = await this.aiApi.embeddings(
|
||||
env.qdrant.embeddingModel,
|
||||
text,
|
||||
env.qdrant.vectorSize,
|
||||
);
|
||||
return response.embedding;
|
||||
}
|
||||
|
||||
320
gadget-code/tests/vector-store.test.ts
Normal file
320
gadget-code/tests/vector-store.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -204,7 +204,7 @@ export abstract class AiApi {
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@ -369,8 +369,8 @@ export class OllamaAiApi extends AiApi {
|
||||
return chatResponse;
|
||||
}
|
||||
|
||||
async embeddings(modelId: string, text: string): Promise<IAiEmbeddingResponse> {
|
||||
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<IAiEmbeddingResponse> {
|
||||
const response = await this.client.embed({ model: modelId, input: text, dimensions });
|
||||
return { embedding: response.embeddings[0]!, model: modelId };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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({
|
||||
model: modelId,
|
||||
input: text,
|
||||
dimensions,
|
||||
});
|
||||
return { embedding: response.data[0].embedding, model: modelId };
|
||||
return { embedding: response.data[0]!.embedding, model: modelId };
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user