Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit b1b8bf3

Browse files
committed
feat: auto-detect embedding dimension during validation
This change addresses issue #10991 where users can configure incorrect embedding dimensions, causing Qdrant to reject vector upserts with dimension mismatches. Changes: - Updated IEmbedder interface to include optional detectedDimension in validation result - All 8 embedders now return the detected dimension from their test embedding during validation - Updated CodeIndexServiceFactory.createVectorStore() to accept and prioritize auto-detected dimension over profile-based and manual configuration - Updated CodeIndexManager._recreateServices() to pass detected dimension from validation to vector store creation - Added comprehensive tests for the new functionality Priority order for dimension selection: 1. Auto-detected from test embedding (most reliable) 2. Profile-based from getModelDimension() 3. Manual configuration from modelDimension setting Fixes #10991
1 parent 2584504 commit b1b8bf3

14 files changed

Lines changed: 354 additions & 48 deletions

File tree

src/services/code-index/__tests__/service-factory.spec.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,125 @@ describe("CodeIndexServiceFactory", () => {
345345
mockGetDefaultModelId.mockReturnValue("default-model")
346346
})
347347

348+
it("should prioritize detectedDimension over all other dimension sources", () => {
349+
// Arrange
350+
const testConfig = {
351+
embedderProvider: "openai-compatible",
352+
modelId: "custom-model",
353+
modelDimension: 1024, // Manual config should be ignored
354+
qdrantUrl: "http://localhost:6333",
355+
qdrantApiKey: "test-key",
356+
}
357+
mockConfigManager.getConfig.mockReturnValue(testConfig as any)
358+
mockGetModelDimension.mockReturnValue(768) // Profile dimension should be ignored
359+
360+
// Act - pass detected dimension from validation
361+
factory.createVectorStore(4096)
362+
363+
// Assert - should use detected dimension (4096), not profile (768) or manual (1024)
364+
expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
365+
"/test/workspace",
366+
"http://localhost:6333",
367+
4096, // Auto-detected dimension takes priority
368+
"test-key",
369+
)
370+
})
371+
372+
it("should use detected dimension from Ollama embedder", () => {
373+
// Arrange - simulates Ollama with qwen3-embedding returning 4096 dimensions
374+
const testConfig = {
375+
embedderProvider: "ollama",
376+
modelId: "qwen3-embedding",
377+
modelDimension: 1536, // User's incorrect manual config
378+
qdrantUrl: "http://localhost:6333",
379+
qdrantApiKey: "test-key",
380+
}
381+
mockConfigManager.getConfig.mockReturnValue(testConfig as any)
382+
mockGetModelDimension.mockReturnValue(undefined) // Unknown model
383+
384+
// Act - pass detected dimension from validation (like the issue scenario)
385+
factory.createVectorStore(4096)
386+
387+
// Assert - should use auto-detected 4096, not user's incorrect 1536
388+
expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
389+
"/test/workspace",
390+
"http://localhost:6333",
391+
4096,
392+
"test-key",
393+
)
394+
})
395+
396+
it("should fall back to profile dimension when detected dimension is not provided", () => {
397+
// Arrange
398+
const testConfig = {
399+
embedderProvider: "openai",
400+
modelId: "text-embedding-3-large",
401+
qdrantUrl: "http://localhost:6333",
402+
qdrantApiKey: "test-key",
403+
}
404+
mockConfigManager.getConfig.mockReturnValue(testConfig as any)
405+
mockGetModelDimension.mockReturnValue(3072)
406+
407+
// Act - no detected dimension provided
408+
factory.createVectorStore()
409+
410+
// Assert - should use profile dimension
411+
expect(mockGetModelDimension).toHaveBeenCalledWith("openai", "text-embedding-3-large")
412+
expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
413+
"/test/workspace",
414+
"http://localhost:6333",
415+
3072,
416+
"test-key",
417+
)
418+
})
419+
420+
it("should fall back to manual dimension when detected and profile are unavailable", () => {
421+
// Arrange
422+
const testConfig = {
423+
embedderProvider: "openai-compatible",
424+
modelId: "unknown-model",
425+
modelDimension: 2048,
426+
qdrantUrl: "http://localhost:6333",
427+
qdrantApiKey: "test-key",
428+
}
429+
mockConfigManager.getConfig.mockReturnValue(testConfig as any)
430+
mockGetModelDimension.mockReturnValue(undefined)
431+
432+
// Act - no detected dimension, no profile dimension
433+
factory.createVectorStore()
434+
435+
// Assert - should use manual dimension
436+
expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
437+
"/test/workspace",
438+
"http://localhost:6333",
439+
2048,
440+
"test-key",
441+
)
442+
})
443+
444+
it("should ignore zero or negative detected dimension", () => {
445+
// Arrange
446+
const testConfig = {
447+
embedderProvider: "openai",
448+
modelId: "text-embedding-3-small",
449+
qdrantUrl: "http://localhost:6333",
450+
qdrantApiKey: "test-key",
451+
}
452+
mockConfigManager.getConfig.mockReturnValue(testConfig as any)
453+
mockGetModelDimension.mockReturnValue(1536)
454+
455+
// Act - pass invalid detected dimension
456+
factory.createVectorStore(0)
457+
458+
// Assert - should fall back to profile dimension
459+
expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
460+
"/test/workspace",
461+
"http://localhost:6333",
462+
1536,
463+
"test-key",
464+
)
465+
})
466+
348467
it("should use config.modelId for OpenAI provider", () => {
349468
// Arrange
350469
const testModelId = "text-embedding-3-large"
@@ -670,6 +789,58 @@ describe("CodeIndexServiceFactory", () => {
670789
}
671790
})
672791

792+
it("should return detectedDimension from embedder validation", async () => {
793+
// Arrange
794+
const testConfig = {
795+
embedderProvider: "ollama",
796+
modelId: "qwen3-embedding",
797+
ollamaOptions: {
798+
ollamaBaseUrl: "http://localhost:11434",
799+
},
800+
}
801+
mockConfigManager.getConfig.mockReturnValue(testConfig as any)
802+
MockedCodeIndexOllamaEmbedder.mockImplementation(() => mockEmbedderInstance)
803+
// Mock embedder returning detected dimension
804+
mockEmbedderInstance.validateConfiguration.mockResolvedValue({
805+
valid: true,
806+
detectedDimension: 4096,
807+
})
808+
809+
// Act
810+
const embedder = factory.createEmbedder()
811+
const result = await factory.validateEmbedder(embedder)
812+
813+
// Assert
814+
expect(result).toEqual({ valid: true, detectedDimension: 4096 })
815+
expect(mockEmbedderInstance.validateConfiguration).toHaveBeenCalled()
816+
})
817+
818+
it("should return detectedDimension from base64 embedding validation", async () => {
819+
// Arrange
820+
const testConfig = {
821+
embedderProvider: "openai-compatible",
822+
modelId: "custom-model",
823+
openAiCompatibleOptions: {
824+
baseUrl: "https://api.example.com/v1",
825+
apiKey: "test-api-key",
826+
},
827+
}
828+
mockConfigManager.getConfig.mockReturnValue(testConfig as any)
829+
MockedOpenAICompatibleEmbedder.mockImplementation(() => mockEmbedderInstance)
830+
// Mock embedder returning detected dimension from base64 parsing
831+
mockEmbedderInstance.validateConfiguration.mockResolvedValue({
832+
valid: true,
833+
detectedDimension: 1536,
834+
})
835+
836+
// Act
837+
const embedder = factory.createEmbedder()
838+
const result = await factory.validateEmbedder(embedder)
839+
840+
// Assert
841+
expect(result).toEqual({ valid: true, detectedDimension: 1536 })
842+
})
843+
673844
it("should validate OpenAI embedder successfully", async () => {
674845
// Arrange
675846
const testConfig = {

src/services/code-index/embedders/__tests__/ollama.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ describe("CodeIndexOllamaEmbedder", () => {
196196

197197
expect(result.valid).toBe(true)
198198
expect(result.error).toBeUndefined()
199+
expect(result.detectedDimension).toBe(3) // Auto-detected from test embedding
199200
expect(mockFetch).toHaveBeenCalledTimes(2)
200201

201202
// Check first call (GET /api/tags)
@@ -214,6 +215,38 @@ describe("CodeIndexOllamaEmbedder", () => {
214215
expect(secondCall[1]?.signal).toBeDefined() // AbortSignal for timeout
215216
})
216217

218+
it("should detect dimension from realistic embedding size", async () => {
219+
// Mock successful /api/tags call
220+
mockFetch.mockImplementationOnce(() =>
221+
Promise.resolve({
222+
ok: true,
223+
status: 200,
224+
json: () =>
225+
Promise.resolve({
226+
models: [{ name: "nomic-embed-text:latest" }],
227+
}),
228+
} as Response),
229+
)
230+
231+
// Mock successful /api/embed test call with 4096-dimension embedding (like qwen3-embedding)
232+
const largeEmbedding = new Array(4096).fill(0).map((_, i) => i * 0.001)
233+
mockFetch.mockImplementationOnce(() =>
234+
Promise.resolve({
235+
ok: true,
236+
status: 200,
237+
json: () =>
238+
Promise.resolve({
239+
embeddings: [largeEmbedding],
240+
}),
241+
} as Response),
242+
)
243+
244+
const result = await embedder.validateConfiguration()
245+
246+
expect(result.valid).toBe(true)
247+
expect(result.detectedDimension).toBe(4096)
248+
})
249+
217250
it("should fail validation when service is not available", async () => {
218251
mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"))
219252

src/services/code-index/embedders/__tests__/openai-compatible.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,7 @@ describe("OpenAICompatibleEmbedder", () => {
978978

979979
expect(result.valid).toBe(true)
980980
expect(result.error).toBeUndefined()
981+
expect(result.detectedDimension).toBe(3) // Auto-detected from array embedding
981982
expect(mockEmbeddingsCreate).toHaveBeenCalledWith({
982983
input: ["test"],
983984
model: testModelId,
@@ -1003,6 +1004,7 @@ describe("OpenAICompatibleEmbedder", () => {
10031004

10041005
expect(result.valid).toBe(true)
10051006
expect(result.error).toBeUndefined()
1007+
expect(result.detectedDimension).toBe(3) // Auto-detected from array embedding
10061008
expect(mockFetch).toHaveBeenCalledWith(
10071009
fullUrl,
10081010
expect.objectContaining({
@@ -1014,6 +1016,25 @@ describe("OpenAICompatibleEmbedder", () => {
10141016
)
10151017
})
10161018

1019+
it("should detect dimension from base64 encoded embedding", async () => {
1020+
embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
1021+
1022+
// Create a 1536-dimension embedding as base64 (like text-embedding-3-small)
1023+
const embedding = new Float32Array(1536).fill(0.1)
1024+
const base64String = Buffer.from(embedding.buffer).toString("base64")
1025+
1026+
const mockResponse = {
1027+
data: [{ embedding: base64String }],
1028+
usage: { prompt_tokens: 2, total_tokens: 2 },
1029+
}
1030+
mockEmbeddingsCreate.mockResolvedValue(mockResponse)
1031+
1032+
const result = await embedder.validateConfiguration()
1033+
1034+
expect(result.valid).toBe(true)
1035+
expect(result.detectedDimension).toBe(1536) // Auto-detected from base64 (1536 * 4 bytes / 4 = 1536)
1036+
})
1037+
10171038
it("should fail validation with authentication error", async () => {
10181039
embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
10191040

src/services/code-index/embedders/bedrock.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,10 +263,11 @@ export class BedrockEmbedder implements IEmbedder {
263263
}
264264

265265
/**
266-
* Validates the Bedrock embedder configuration by attempting a minimal embedding request
267-
* @returns Promise resolving to validation result with success status and optional error message
266+
* Validates the Bedrock embedder configuration by attempting a minimal embedding request.
267+
* Also detects the actual embedding dimension from a test embedding.
268+
* @returns Promise resolving to validation result with success status, optional error message, and detected dimension
268269
*/
269-
async validateConfiguration(): Promise<{ valid: boolean; error?: string }> {
270+
async validateConfiguration(): Promise<{ valid: boolean; error?: string; detectedDimension?: number }> {
270271
return withValidationErrorHandling(async () => {
271272
try {
272273
// Test with a minimal embedding request
@@ -280,7 +281,10 @@ export class BedrockEmbedder implements IEmbedder {
280281
}
281282
}
282283

283-
return { valid: true }
284+
// Get the dimension from the embedding
285+
const detectedDimension = result.embedding.length
286+
287+
return { valid: true, detectedDimension }
284288
} catch (error: any) {
285289
// Check for specific AWS errors
286290
if (error.name === "UnrecognizedClientException") {

src/services/code-index/embedders/gemini.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,11 @@ export class GeminiEmbedder implements IEmbedder {
6363
}
6464

6565
/**
66-
* Validates the Gemini embedder configuration by delegating to the underlying OpenAI-compatible embedder
67-
* @returns Promise resolving to validation result with success status and optional error message
66+
* Validates the Gemini embedder configuration by delegating to the underlying OpenAI-compatible embedder.
67+
* Also detects the actual embedding dimension from a test embedding.
68+
* @returns Promise resolving to validation result with success status, optional error message, and detected dimension
6869
*/
69-
async validateConfiguration(): Promise<{ valid: boolean; error?: string }> {
70+
async validateConfiguration(): Promise<{ valid: boolean; error?: string; detectedDimension?: number }> {
7071
try {
7172
// Delegate validation to the OpenAI-compatible embedder
7273
// The error messages will be specific to Gemini since we're using Gemini's base URL

src/services/code-index/embedders/mistral.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,11 @@ export class MistralEmbedder implements IEmbedder {
6262
}
6363

6464
/**
65-
* Validates the Mistral embedder configuration by delegating to the underlying OpenAI-compatible embedder
66-
* @returns Promise resolving to validation result with success status and optional error message
65+
* Validates the Mistral embedder configuration by delegating to the underlying OpenAI-compatible embedder.
66+
* Also detects the actual embedding dimension from a test embedding.
67+
* @returns Promise resolving to validation result with success status, optional error message, and detected dimension
6768
*/
68-
async validateConfiguration(): Promise<{ valid: boolean; error?: string }> {
69+
async validateConfiguration(): Promise<{ valid: boolean; error?: string; detectedDimension?: number }> {
6970
try {
7071
// Delegate validation to the OpenAI-compatible embedder
7172
// The error messages will be specific to Mistral since we're using Mistral's base URL

src/services/code-index/embedders/ollama.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,11 @@ export class CodeIndexOllamaEmbedder implements IEmbedder {
138138
}
139139

140140
/**
141-
* Validates the Ollama embedder configuration by checking service availability and model existence
142-
* @returns Promise resolving to validation result with success status and optional error message
141+
* Validates the Ollama embedder configuration by checking service availability and model existence.
142+
* Also detects the actual embedding dimension from a test embedding.
143+
* @returns Promise resolving to validation result with success status, optional error message, and detected dimension
143144
*/
144-
async validateConfiguration(): Promise<{ valid: boolean; error?: string }> {
145+
async validateConfiguration(): Promise<{ valid: boolean; error?: string; detectedDimension?: number }> {
145146
return withValidationErrorHandling(
146147
async () => {
147148
// First check if Ollama service is running by trying to list models
@@ -228,7 +229,19 @@ export class CodeIndexOllamaEmbedder implements IEmbedder {
228229
}
229230
}
230231

231-
return { valid: true }
232+
// Parse the test response to get the embedding dimension
233+
const testData = await testResponse.json()
234+
const embeddings = testData.embeddings
235+
let detectedDimension: number | undefined
236+
237+
if (embeddings && Array.isArray(embeddings) && embeddings.length > 0) {
238+
const firstEmbedding = embeddings[0]
239+
if (Array.isArray(firstEmbedding)) {
240+
detectedDimension = firstEmbedding.length
241+
}
242+
}
243+
244+
return { valid: true, detectedDimension }
232245
},
233246
"ollama",
234247
{

0 commit comments

Comments
 (0)