mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: add custom query instruction support for embedding models
- Add codebaseIndexCustomQueryInstruction field to CodebaseIndexConfig schema - Update all embedder implementations to accept and use custom query instructions - Add UI input field in CodeIndexPopover for configuring custom instructions - Add comprehensive tests for custom query instruction functionality - Add i18n translations for new UI elements This allows users to customize the query prefix/instruction for models like embeddinggemma and qwen3-embedding that benefit from instruction-aware embeddings. Fixes #8759
This commit is contained in:
parent
34392dd4dd
commit
e159a66d00
15 changed files with 614 additions and 18 deletions
|
|
@ -36,6 +36,8 @@ export const codebaseIndexConfigSchema = z.object({
|
|||
// OpenAI Compatible specific fields
|
||||
codebaseIndexOpenAiCompatibleBaseUrl: z.string().optional(),
|
||||
codebaseIndexOpenAiCompatibleModelDimension: z.number().optional(),
|
||||
// Custom query instruction for instruction-aware embedding models
|
||||
codebaseIndexCustomQueryInstruction: z.string().optional(),
|
||||
})
|
||||
|
||||
export type CodebaseIndexConfig = z.infer<typeof codebaseIndexConfigSchema>
|
||||
|
|
|
|||
|
|
@ -141,6 +141,42 @@ describe("CodeIndexConfigManager", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("should load custom query instruction from globalState", async () => {
|
||||
const mockGlobalState = {
|
||||
codebaseIndexEnabled: true,
|
||||
codebaseIndexQdrantUrl: "http://qdrant.local",
|
||||
codebaseIndexEmbedderProvider: "openai",
|
||||
codebaseIndexEmbedderModelId: "text-embedding-3-large",
|
||||
codebaseIndexCustomQueryInstruction: "Represent this code for searching:",
|
||||
}
|
||||
mockContextProxy.getGlobalState.mockReturnValue(mockGlobalState)
|
||||
|
||||
// Mock both sync and async secret access
|
||||
setupSecretMocks({
|
||||
codeIndexOpenAiKey: "test-openai-key",
|
||||
codeIndexQdrantApiKey: "test-qdrant-key",
|
||||
})
|
||||
|
||||
const result = await configManager.loadConfiguration()
|
||||
|
||||
expect(result.currentConfig).toEqual({
|
||||
isConfigured: true,
|
||||
embedderProvider: "openai",
|
||||
modelId: "text-embedding-3-large",
|
||||
modelDimension: undefined,
|
||||
openAiOptions: { openAiNativeApiKey: "test-openai-key" },
|
||||
ollamaOptions: { ollamaBaseUrl: undefined },
|
||||
geminiOptions: undefined,
|
||||
mistralOptions: undefined,
|
||||
openAiCompatibleOptions: undefined,
|
||||
vercelAiGatewayOptions: undefined,
|
||||
qdrantUrl: "http://qdrant.local",
|
||||
qdrantApiKey: "test-qdrant-key",
|
||||
searchMinScore: 0.4,
|
||||
customQueryInstruction: "Represent this code for searching:",
|
||||
})
|
||||
})
|
||||
|
||||
it("should load OpenAI Compatible configuration from globalState and secrets", async () => {
|
||||
const mockGlobalState = {
|
||||
codebaseIndexEnabled: true,
|
||||
|
|
@ -1808,5 +1844,104 @@ describe("CodeIndexConfigManager", () => {
|
|||
expect(configManager.currentModelDimension).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("currentCustomQueryInstruction", () => {
|
||||
it("should return custom query instruction when set", async () => {
|
||||
mockContextProxy.getGlobalState.mockReturnValue({
|
||||
codebaseIndexEnabled: true,
|
||||
codebaseIndexQdrantUrl: "http://localhost:6333",
|
||||
codebaseIndexEmbedderProvider: "openai",
|
||||
codebaseIndexEmbedderModelId: "text-embedding-3-small",
|
||||
codebaseIndexCustomQueryInstruction: "Represent this code for retrieval:",
|
||||
})
|
||||
mockContextProxy.getSecret.mockImplementation((key: string) => {
|
||||
if (key === "codeIndexOpenAiKey") return "test-key"
|
||||
return undefined
|
||||
})
|
||||
|
||||
configManager = new CodeIndexConfigManager(mockContextProxy)
|
||||
await configManager.loadConfiguration()
|
||||
|
||||
expect(configManager.currentCustomQueryInstruction).toBe("Represent this code for retrieval:")
|
||||
})
|
||||
|
||||
it("should return undefined when custom query instruction is not set", async () => {
|
||||
mockContextProxy.getGlobalState.mockReturnValue({
|
||||
codebaseIndexEnabled: true,
|
||||
codebaseIndexQdrantUrl: "http://localhost:6333",
|
||||
codebaseIndexEmbedderProvider: "openai",
|
||||
codebaseIndexEmbedderModelId: "text-embedding-3-small",
|
||||
// No custom query instruction
|
||||
})
|
||||
mockContextProxy.getSecret.mockImplementation((key: string) => {
|
||||
if (key === "codeIndexOpenAiKey") return "test-key"
|
||||
return undefined
|
||||
})
|
||||
|
||||
configManager = new CodeIndexConfigManager(mockContextProxy)
|
||||
await configManager.loadConfiguration()
|
||||
|
||||
expect(configManager.currentCustomQueryInstruction).toBe(undefined)
|
||||
})
|
||||
|
||||
it("should return empty string when custom query instruction is explicitly empty", async () => {
|
||||
mockContextProxy.getGlobalState.mockReturnValue({
|
||||
codebaseIndexEnabled: true,
|
||||
codebaseIndexQdrantUrl: "http://localhost:6333",
|
||||
codebaseIndexEmbedderProvider: "openai",
|
||||
codebaseIndexEmbedderModelId: "text-embedding-3-small",
|
||||
codebaseIndexCustomQueryInstruction: "",
|
||||
})
|
||||
mockContextProxy.getSecret.mockImplementation((key: string) => {
|
||||
if (key === "codeIndexOpenAiKey") return "test-key"
|
||||
return undefined
|
||||
})
|
||||
|
||||
configManager = new CodeIndexConfigManager(mockContextProxy)
|
||||
await configManager.loadConfiguration()
|
||||
|
||||
expect(configManager.currentCustomQueryInstruction).toBe("")
|
||||
})
|
||||
|
||||
it("should handle custom query instruction with different providers", async () => {
|
||||
// Test with Ollama
|
||||
mockContextProxy.getGlobalState.mockReturnValue({
|
||||
codebaseIndexEnabled: true,
|
||||
codebaseIndexQdrantUrl: "http://localhost:6333",
|
||||
codebaseIndexEmbedderProvider: "ollama",
|
||||
codebaseIndexEmbedderBaseUrl: "http://localhost:11434",
|
||||
codebaseIndexEmbedderModelId: "nomic-embed-text",
|
||||
codebaseIndexCustomQueryInstruction: "search_query: ",
|
||||
})
|
||||
|
||||
configManager = new CodeIndexConfigManager(mockContextProxy)
|
||||
await configManager.loadConfiguration()
|
||||
|
||||
expect(configManager.currentCustomQueryInstruction).toBe("search_query: ")
|
||||
|
||||
// Test with OpenAI Compatible
|
||||
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
|
||||
if (key === "codebaseIndexConfig") {
|
||||
return {
|
||||
codebaseIndexEnabled: true,
|
||||
codebaseIndexQdrantUrl: "http://localhost:6333",
|
||||
codebaseIndexEmbedderProvider: "openai-compatible",
|
||||
codebaseIndexEmbedderModelId: "custom-model",
|
||||
codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1",
|
||||
codebaseIndexCustomQueryInstruction: "query: ",
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
setupSecretMocks({
|
||||
codebaseIndexOpenAiCompatibleApiKey: "test-key",
|
||||
})
|
||||
|
||||
const newManager = new CodeIndexConfigManager(mockContextProxy)
|
||||
await newManager.loadConfiguration()
|
||||
|
||||
expect(newManager.currentCustomQueryInstruction).toBe("query: ")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -194,6 +194,8 @@ describe("CodeIndexServiceFactory", () => {
|
|||
"https://api.example.com/v1",
|
||||
"test-api-key",
|
||||
testModelId,
|
||||
undefined, // maxItemTokens
|
||||
undefined, // customQueryInstruction
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -216,7 +218,9 @@ describe("CodeIndexServiceFactory", () => {
|
|||
expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith(
|
||||
"https://api.example.com/v1",
|
||||
"test-api-key",
|
||||
undefined,
|
||||
undefined, // modelId
|
||||
undefined, // maxItemTokens
|
||||
undefined, // customQueryInstruction
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -279,7 +283,7 @@ describe("CodeIndexServiceFactory", () => {
|
|||
factory.createEmbedder()
|
||||
|
||||
// Assert
|
||||
expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", undefined)
|
||||
expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", undefined, undefined)
|
||||
})
|
||||
|
||||
it("should create GeminiEmbedder with specified modelId", () => {
|
||||
|
|
@ -297,7 +301,7 @@ describe("CodeIndexServiceFactory", () => {
|
|||
factory.createEmbedder()
|
||||
|
||||
// Assert
|
||||
expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "text-embedding-004")
|
||||
expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "text-embedding-004", undefined)
|
||||
})
|
||||
|
||||
it("should throw error when Gemini API key is missing", () => {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export class CodeIndexConfigManager {
|
|||
private qdrantApiKey?: string
|
||||
private searchMinScore?: number
|
||||
private searchMaxResults?: number
|
||||
private customQueryInstruction?: string
|
||||
|
||||
constructor(private readonly contextProxy: ContextProxy) {
|
||||
// Initialize with current configuration to avoid false restart triggers
|
||||
|
|
@ -61,6 +62,7 @@ export class CodeIndexConfigManager {
|
|||
codebaseIndexEmbedderModelId,
|
||||
codebaseIndexSearchMinScore,
|
||||
codebaseIndexSearchMaxResults,
|
||||
codebaseIndexCustomQueryInstruction,
|
||||
} = codebaseIndexConfig
|
||||
|
||||
const openAiKey = this.contextProxy?.getSecret("codeIndexOpenAiKey") ?? ""
|
||||
|
|
@ -78,6 +80,7 @@ export class CodeIndexConfigManager {
|
|||
this.qdrantApiKey = qdrantApiKey ?? ""
|
||||
this.searchMinScore = codebaseIndexSearchMinScore
|
||||
this.searchMaxResults = codebaseIndexSearchMaxResults
|
||||
this.customQueryInstruction = codebaseIndexCustomQueryInstruction
|
||||
|
||||
// Validate and set model dimension
|
||||
const rawDimension = codebaseIndexConfig.codebaseIndexEmbedderModelDimension
|
||||
|
|
@ -150,6 +153,7 @@ export class CodeIndexConfigManager {
|
|||
qdrantUrl?: string
|
||||
qdrantApiKey?: string
|
||||
searchMinScore?: number
|
||||
customQueryInstruction?: string
|
||||
}
|
||||
requiresRestart: boolean
|
||||
}> {
|
||||
|
|
@ -195,6 +199,7 @@ export class CodeIndexConfigManager {
|
|||
qdrantUrl: this.qdrantUrl,
|
||||
qdrantApiKey: this.qdrantApiKey,
|
||||
searchMinScore: this.currentSearchMinScore,
|
||||
customQueryInstruction: this.customQueryInstruction,
|
||||
},
|
||||
requiresRestart,
|
||||
}
|
||||
|
|
@ -399,6 +404,7 @@ export class CodeIndexConfigManager {
|
|||
qdrantApiKey: this.qdrantApiKey,
|
||||
searchMinScore: this.currentSearchMinScore,
|
||||
searchMaxResults: this.currentSearchMaxResults,
|
||||
customQueryInstruction: this.customQueryInstruction,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -480,4 +486,12 @@ export class CodeIndexConfigManager {
|
|||
public get currentSearchMaxResults(): number {
|
||||
return this.searchMaxResults ?? DEFAULT_MAX_SEARCH_RESULTS
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the configured custom query instruction for instruction-aware embedding models.
|
||||
* Returns the user-configured instruction or undefined if not set.
|
||||
*/
|
||||
public get currentCustomQueryInstruction(): string | undefined {
|
||||
return this.customQueryInstruction
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,386 @@
|
|||
// Test suite for custom query instruction functionality across all embedders
|
||||
import { OpenAiEmbedder } from "../openai"
|
||||
import { CodeIndexOllamaEmbedder } from "../ollama"
|
||||
import { OpenAICompatibleEmbedder } from "../openai-compatible"
|
||||
import { GeminiEmbedder } from "../gemini"
|
||||
import { MistralEmbedder } from "../mistral"
|
||||
import { VercelAiGatewayEmbedder } from "../vercel-ai-gateway"
|
||||
import { getModelQueryPrefix } from "../../../../shared/embeddingModels"
|
||||
|
||||
// Mock the embeddingModels module
|
||||
vi.mock("../../../../shared/embeddingModels", () => ({
|
||||
getModelQueryPrefix: vi.fn(),
|
||||
getModelDimension: vi.fn(),
|
||||
getDefaultModelId: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockedGetModelQueryPrefix = vi.mocked(getModelQueryPrefix)
|
||||
|
||||
describe("Custom Query Instruction Support", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Setup default mock behavior
|
||||
mockedGetModelQueryPrefix.mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
describe("OpenAI Embedder", () => {
|
||||
it("should use custom query instruction when provided", async () => {
|
||||
const customInstruction = "Represent this code for searching:"
|
||||
const embedder = new OpenAiEmbedder({
|
||||
openAiNativeApiKey: "test-api-key",
|
||||
openAiEmbeddingModelId: "text-embedding-3-small",
|
||||
customQueryInstruction: customInstruction,
|
||||
})
|
||||
|
||||
// Mock the OpenAI API call
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: new Array(1536).fill(0.1) }],
|
||||
}),
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
await embedder.createEmbeddings(["test query"])
|
||||
|
||||
// Check that the custom instruction was used
|
||||
const fetchCall = mockFetch.mock.calls[0]
|
||||
const requestBody = JSON.parse(fetchCall[1].body)
|
||||
expect(requestBody.input).toBe(customInstruction + "test query")
|
||||
expect(mockedGetModelQueryPrefix).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should fall back to model-specific prefix when no custom instruction", async () => {
|
||||
const modelPrefix = "search_query: "
|
||||
mockedGetModelQueryPrefix.mockReturnValue(modelPrefix)
|
||||
|
||||
const embedder = new OpenAiEmbedder({
|
||||
openAiNativeApiKey: "test-api-key",
|
||||
openAiEmbeddingModelId: "text-embedding-3-small",
|
||||
// No custom instruction
|
||||
})
|
||||
|
||||
// Mock the OpenAI API call
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: new Array(1536).fill(0.1) }],
|
||||
}),
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
await embedder.createEmbeddings(["test query"])
|
||||
|
||||
// Check that the model prefix was used
|
||||
const fetchCall = mockFetch.mock.calls[0]
|
||||
const requestBody = JSON.parse(fetchCall[1].body)
|
||||
expect(requestBody.input).toBe(modelPrefix + "test query")
|
||||
expect(mockedGetModelQueryPrefix).toHaveBeenCalledWith("openai", "text-embedding-3-small")
|
||||
})
|
||||
|
||||
it("should use empty string custom instruction when explicitly set", async () => {
|
||||
const embedder = new OpenAiEmbedder({
|
||||
openAiNativeApiKey: "test-api-key",
|
||||
openAiEmbeddingModelId: "text-embedding-3-small",
|
||||
customQueryInstruction: "", // Explicitly empty custom instruction
|
||||
})
|
||||
|
||||
// Mock the OpenAI API call
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: new Array(1536).fill(0.1) }],
|
||||
}),
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
await embedder.createEmbeddings(["test query"])
|
||||
|
||||
// Check that no prefix was added
|
||||
const fetchCall = mockFetch.mock.calls[0]
|
||||
const requestBody = JSON.parse(fetchCall[1].body)
|
||||
expect(requestBody.input).toBe("test query")
|
||||
expect(mockedGetModelQueryPrefix).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Ollama Embedder", () => {
|
||||
it("should use custom query instruction when provided", async () => {
|
||||
const customInstruction = "query: "
|
||||
const embedder = new CodeIndexOllamaEmbedder({
|
||||
ollamaBaseUrl: "http://localhost:11434",
|
||||
ollamaModelId: "nomic-embed-text",
|
||||
customQueryInstruction: customInstruction,
|
||||
})
|
||||
|
||||
// Mock the Ollama API call
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
embeddings: [new Array(768).fill(0.1)],
|
||||
}),
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
await embedder.createEmbeddings(["test query"])
|
||||
|
||||
// Check that the custom instruction was used
|
||||
const fetchCall = mockFetch.mock.calls[0]
|
||||
const requestBody = JSON.parse(fetchCall[1].body)
|
||||
expect(requestBody.input).toBe(customInstruction + "test query")
|
||||
expect(mockedGetModelQueryPrefix).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should fall back to model-specific prefix when no custom instruction", async () => {
|
||||
const modelPrefix = "search_query: "
|
||||
mockedGetModelQueryPrefix.mockReturnValue(modelPrefix)
|
||||
|
||||
const embedder = new CodeIndexOllamaEmbedder({
|
||||
ollamaBaseUrl: "http://localhost:11434",
|
||||
ollamaModelId: "nomic-embed-code",
|
||||
// No custom instruction
|
||||
})
|
||||
|
||||
// Mock the Ollama API call
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
embeddings: [new Array(768).fill(0.1)],
|
||||
}),
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
await embedder.createEmbeddings(["test query"])
|
||||
|
||||
// Check that the model prefix was used
|
||||
const fetchCall = mockFetch.mock.calls[0]
|
||||
const requestBody = JSON.parse(fetchCall[1].body)
|
||||
expect(requestBody.input).toBe(modelPrefix + "test query")
|
||||
expect(mockedGetModelQueryPrefix).toHaveBeenCalledWith("ollama", "nomic-embed-code")
|
||||
})
|
||||
})
|
||||
|
||||
describe("OpenAI Compatible Embedder", () => {
|
||||
it("should use custom query instruction when provided", async () => {
|
||||
const customInstruction = "Represent for retrieval: "
|
||||
const embedder = new OpenAICompatibleEmbedder(
|
||||
"https://api.example.com",
|
||||
"custom-model",
|
||||
"test-api-key",
|
||||
1024, // dimension
|
||||
customInstruction,
|
||||
)
|
||||
|
||||
// Mock the API call
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: new Array(1024).fill(0.1) }],
|
||||
}),
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
await embedder.createEmbeddings(["test query"])
|
||||
|
||||
// Check that the custom instruction was used
|
||||
const fetchCall = mockFetch.mock.calls[0]
|
||||
const requestBody = JSON.parse(fetchCall[1].body)
|
||||
expect(requestBody.input).toBe(customInstruction + "test query")
|
||||
expect(mockedGetModelQueryPrefix).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should fall back to model-specific prefix when no custom instruction", async () => {
|
||||
const modelPrefix = "search_document: "
|
||||
mockedGetModelQueryPrefix.mockReturnValue(modelPrefix)
|
||||
|
||||
const embedder = new OpenAICompatibleEmbedder(
|
||||
"https://api.example.com",
|
||||
"custom-model",
|
||||
"test-api-key",
|
||||
1024, // dimension
|
||||
undefined, // No custom instruction
|
||||
)
|
||||
|
||||
// Mock the API call
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: new Array(1024).fill(0.1) }],
|
||||
}),
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
await embedder.createEmbeddings(["test query"])
|
||||
|
||||
// Check that the model prefix was used
|
||||
const fetchCall = mockFetch.mock.calls[0]
|
||||
const requestBody = JSON.parse(fetchCall[1].body)
|
||||
expect(requestBody.input).toBe(modelPrefix + "test query")
|
||||
expect(mockedGetModelQueryPrefix).toHaveBeenCalledWith("openai-compatible", "custom-model")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Gemini Embedder", () => {
|
||||
it("should use custom query instruction when provided", async () => {
|
||||
const customInstruction = "Code search query: "
|
||||
const embedder = new GeminiEmbedder("test-api-key", "text-embedding-004", customInstruction)
|
||||
|
||||
// Mock the Gemini API call
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
embedding: {
|
||||
values: new Array(768).fill(0.1),
|
||||
},
|
||||
}),
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
await embedder.createEmbeddings(["test query"])
|
||||
|
||||
// Check that the custom instruction was used
|
||||
const fetchCall = mockFetch.mock.calls[0]
|
||||
const requestBody = JSON.parse(fetchCall[1].body)
|
||||
expect(requestBody.content.parts[0].text).toBe(customInstruction + "test query")
|
||||
expect(mockedGetModelQueryPrefix).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should fall back to model-specific prefix when no custom instruction", async () => {
|
||||
const modelPrefix = "retrieval: "
|
||||
mockedGetModelQueryPrefix.mockReturnValue(modelPrefix)
|
||||
|
||||
const embedder = new GeminiEmbedder(
|
||||
"test-api-key",
|
||||
"text-embedding-004",
|
||||
undefined, // No custom instruction
|
||||
)
|
||||
|
||||
// Mock the Gemini API call
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
embedding: {
|
||||
values: new Array(768).fill(0.1),
|
||||
},
|
||||
}),
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
await embedder.createEmbeddings(["test query"])
|
||||
|
||||
// Check that the model prefix was used
|
||||
const fetchCall = mockFetch.mock.calls[0]
|
||||
const requestBody = JSON.parse(fetchCall[1].body)
|
||||
expect(requestBody.content.parts[0].text).toBe(modelPrefix + "test query")
|
||||
expect(mockedGetModelQueryPrefix).toHaveBeenCalledWith("gemini", "text-embedding-004")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Mistral Embedder", () => {
|
||||
it("should use custom query instruction when provided", async () => {
|
||||
const customInstruction = "Embedding query: "
|
||||
const embedder = new MistralEmbedder("test-api-key", "mistral-embed", customInstruction)
|
||||
|
||||
// Mock the Mistral API call
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: new Array(1024).fill(0.1) }],
|
||||
}),
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
await embedder.createEmbeddings(["test query"])
|
||||
|
||||
// Check that the custom instruction was used
|
||||
const fetchCall = mockFetch.mock.calls[0]
|
||||
const requestBody = JSON.parse(fetchCall[1].body)
|
||||
expect(requestBody.input[0]).toBe(customInstruction + "test query")
|
||||
expect(mockedGetModelQueryPrefix).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should fall back to model-specific prefix when no custom instruction", async () => {
|
||||
const modelPrefix = "search_query: "
|
||||
mockedGetModelQueryPrefix.mockReturnValue(modelPrefix)
|
||||
|
||||
const embedder = new MistralEmbedder(
|
||||
"test-api-key",
|
||||
"mistral-embed",
|
||||
undefined, // No custom instruction
|
||||
)
|
||||
|
||||
// Mock the Mistral API call
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: new Array(1024).fill(0.1) }],
|
||||
}),
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
await embedder.createEmbeddings(["test query"])
|
||||
|
||||
// Check that the model prefix was used
|
||||
const fetchCall = mockFetch.mock.calls[0]
|
||||
const requestBody = JSON.parse(fetchCall[1].body)
|
||||
expect(requestBody.input[0]).toBe(modelPrefix + "test query")
|
||||
expect(mockedGetModelQueryPrefix).toHaveBeenCalledWith("mistral", "mistral-embed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Vercel AI Gateway Embedder", () => {
|
||||
it("should use custom query instruction when provided", async () => {
|
||||
const customInstruction = "Search: "
|
||||
const embedder = new VercelAiGatewayEmbedder(
|
||||
"test-api-key",
|
||||
"openai:text-embedding-3-small",
|
||||
customInstruction,
|
||||
)
|
||||
|
||||
// Mock the Vercel AI Gateway API call
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: new Array(1536).fill(0.1) }],
|
||||
}),
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
await embedder.createEmbeddings(["test query"])
|
||||
|
||||
// Check that the custom instruction was used
|
||||
const fetchCall = mockFetch.mock.calls[0]
|
||||
const requestBody = JSON.parse(fetchCall[1].body)
|
||||
expect(requestBody.input).toBe(customInstruction + "test query")
|
||||
expect(mockedGetModelQueryPrefix).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should fall back to model-specific prefix when no custom instruction", async () => {
|
||||
const modelPrefix = "query: "
|
||||
mockedGetModelQueryPrefix.mockReturnValue(modelPrefix)
|
||||
|
||||
const embedder = new VercelAiGatewayEmbedder(
|
||||
"test-api-key",
|
||||
"openai:text-embedding-3-small",
|
||||
undefined, // No custom instruction
|
||||
)
|
||||
|
||||
// Mock the Vercel AI Gateway API call
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: new Array(1536).fill(0.1) }],
|
||||
}),
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
await embedder.createEmbeddings(["test query"])
|
||||
|
||||
// Check that the model prefix was used
|
||||
const fetchCall = mockFetch.mock.calls[0]
|
||||
const requestBody = JSON.parse(fetchCall[1].body)
|
||||
expect(requestBody.input).toBe(modelPrefix + "test query")
|
||||
expect(mockedGetModelQueryPrefix).toHaveBeenCalledWith("vercel-ai-gateway", "openai:text-embedding-3-small")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -23,8 +23,9 @@ export class GeminiEmbedder implements IEmbedder {
|
|||
* Creates a new Gemini embedder
|
||||
* @param apiKey The Gemini API key for authentication
|
||||
* @param modelId The model ID to use (defaults to gemini-embedding-001)
|
||||
* @param customQueryInstruction Optional custom query instruction for instruction-aware models
|
||||
*/
|
||||
constructor(apiKey: string, modelId?: string) {
|
||||
constructor(apiKey: string, modelId?: string, customQueryInstruction?: string) {
|
||||
if (!apiKey) {
|
||||
throw new Error(t("embeddings:validation.apiKeyRequired"))
|
||||
}
|
||||
|
|
@ -38,6 +39,7 @@ export class GeminiEmbedder implements IEmbedder {
|
|||
apiKey,
|
||||
this.modelId,
|
||||
GEMINI_MAX_ITEM_TOKENS,
|
||||
customQueryInstruction,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@ export class MistralEmbedder implements IEmbedder {
|
|||
* Creates a new Mistral embedder
|
||||
* @param apiKey The Mistral API key for authentication
|
||||
* @param modelId The model ID to use (defaults to codestral-embed-2505)
|
||||
* @param customQueryInstruction Optional custom query instruction for instruction-aware models
|
||||
*/
|
||||
constructor(apiKey: string, modelId?: string) {
|
||||
constructor(apiKey: string, modelId?: string, customQueryInstruction?: string) {
|
||||
if (!apiKey) {
|
||||
throw new Error(t("embeddings:validation.apiKeyRequired"))
|
||||
}
|
||||
|
|
@ -37,6 +38,7 @@ export class MistralEmbedder implements IEmbedder {
|
|||
apiKey,
|
||||
this.modelId,
|
||||
MAX_ITEM_TOKENS, // This is the max token limit (8191), not the embedding dimension
|
||||
customQueryInstruction,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ const OLLAMA_VALIDATION_TIMEOUT_MS = 30000 // 30 seconds for validation requests
|
|||
export class CodeIndexOllamaEmbedder implements IEmbedder {
|
||||
private readonly baseUrl: string
|
||||
private readonly defaultModelId: string
|
||||
private readonly customQueryInstruction?: string
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
constructor(options: ApiHandlerOptions & { customQueryInstruction?: string }) {
|
||||
// Ensure ollamaBaseUrl and ollamaModelId exist on ApiHandlerOptions or add defaults
|
||||
let baseUrl = options.ollamaBaseUrl || "http://localhost:11434"
|
||||
|
||||
|
|
@ -27,6 +28,7 @@ export class CodeIndexOllamaEmbedder implements IEmbedder {
|
|||
|
||||
this.baseUrl = baseUrl
|
||||
this.defaultModelId = options.ollamaModelId || "nomic-embed-text:latest"
|
||||
this.customQueryInstruction = options.customQueryInstruction
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -39,8 +41,8 @@ export class CodeIndexOllamaEmbedder implements IEmbedder {
|
|||
const modelToUse = model || this.defaultModelId
|
||||
const url = `${this.baseUrl}/api/embed` // Endpoint as specified
|
||||
|
||||
// Apply model-specific query prefix if required
|
||||
const queryPrefix = getModelQueryPrefix("ollama", modelToUse)
|
||||
// Use custom query instruction if provided, otherwise fall back to model-specific prefix
|
||||
const queryPrefix = this.customQueryInstruction || getModelQueryPrefix("ollama", modelToUse)
|
||||
const processedTexts = queryPrefix
|
||||
? texts.map((text, index) => {
|
||||
// Prevent double-prefixing
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
|
|||
private readonly apiKey: string
|
||||
private readonly isFullUrl: boolean
|
||||
private readonly maxItemTokens: number
|
||||
private readonly customQueryInstruction?: string
|
||||
|
||||
// Global rate limiting state shared across all instances
|
||||
private static globalRateLimitState = {
|
||||
|
|
@ -56,8 +57,15 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
|
|||
* @param apiKey The API key for authentication
|
||||
* @param modelId Optional model identifier (defaults to "text-embedding-3-small")
|
||||
* @param maxItemTokens Optional maximum tokens per item (defaults to MAX_ITEM_TOKENS)
|
||||
* @param customQueryInstruction Optional custom query instruction for instruction-aware models
|
||||
*/
|
||||
constructor(baseUrl: string, apiKey: string, modelId?: string, maxItemTokens?: number) {
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
modelId?: string,
|
||||
maxItemTokens?: number,
|
||||
customQueryInstruction?: string,
|
||||
) {
|
||||
if (!baseUrl) {
|
||||
throw new Error(t("embeddings:validation.baseUrlRequired"))
|
||||
}
|
||||
|
|
@ -83,6 +91,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
|
|||
// Cache the URL type check for performance
|
||||
this.isFullUrl = this.isFullEndpointUrl(baseUrl)
|
||||
this.maxItemTokens = maxItemTokens || MAX_ITEM_TOKENS
|
||||
this.customQueryInstruction = customQueryInstruction
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -94,8 +103,8 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
|
|||
async createEmbeddings(texts: string[], model?: string): Promise<EmbeddingResponse> {
|
||||
const modelToUse = model || this.defaultModelId
|
||||
|
||||
// Apply model-specific query prefix if required
|
||||
const queryPrefix = getModelQueryPrefix("openai-compatible", modelToUse)
|
||||
// Use custom query instruction if provided, otherwise fall back to model-specific prefix
|
||||
const queryPrefix = this.customQueryInstruction || getModelQueryPrefix("openai-compatible", modelToUse)
|
||||
const processedTexts = queryPrefix
|
||||
? texts.map((text, index) => {
|
||||
// Prevent double-prefixing
|
||||
|
|
|
|||
|
|
@ -21,12 +21,13 @@ import { handleOpenAIError } from "../../../api/providers/utils/openai-error-han
|
|||
export class OpenAiEmbedder extends OpenAiNativeHandler implements IEmbedder {
|
||||
private embeddingsClient: OpenAI
|
||||
private readonly defaultModelId: string
|
||||
private readonly customQueryInstruction?: string
|
||||
|
||||
/**
|
||||
* Creates a new OpenAI embedder
|
||||
* @param options API handler options
|
||||
*/
|
||||
constructor(options: ApiHandlerOptions & { openAiEmbeddingModelId?: string }) {
|
||||
constructor(options: ApiHandlerOptions & { openAiEmbeddingModelId?: string; customQueryInstruction?: string }) {
|
||||
super(options)
|
||||
const apiKey = this.options.openAiNativeApiKey ?? "not-provided"
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ export class OpenAiEmbedder extends OpenAiNativeHandler implements IEmbedder {
|
|||
}
|
||||
|
||||
this.defaultModelId = options.openAiEmbeddingModelId || "text-embedding-3-small"
|
||||
this.customQueryInstruction = options.customQueryInstruction
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -50,8 +52,8 @@ export class OpenAiEmbedder extends OpenAiNativeHandler implements IEmbedder {
|
|||
async createEmbeddings(texts: string[], model?: string): Promise<EmbeddingResponse> {
|
||||
const modelToUse = model || this.defaultModelId
|
||||
|
||||
// Apply model-specific query prefix if required
|
||||
const queryPrefix = getModelQueryPrefix("openai", modelToUse)
|
||||
// Use custom query instruction if provided, otherwise fall back to model-specific prefix
|
||||
const queryPrefix = this.customQueryInstruction || getModelQueryPrefix("openai", modelToUse)
|
||||
const processedTexts = queryPrefix
|
||||
? texts.map((text, index) => {
|
||||
// Prevent double-prefixing
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ export class VercelAiGatewayEmbedder implements IEmbedder {
|
|||
* Creates a new Vercel AI Gateway embedder
|
||||
* @param apiKey The Vercel AI Gateway API key for authentication
|
||||
* @param modelId The model ID to use (defaults to mistral/codestral-embed)
|
||||
* @param customQueryInstruction Optional custom query instruction for instruction-aware models
|
||||
*/
|
||||
constructor(apiKey: string, modelId?: string) {
|
||||
constructor(apiKey: string, modelId?: string, customQueryInstruction?: string) {
|
||||
if (!apiKey) {
|
||||
throw new Error(t("embeddings:validation.apiKeyRequired"))
|
||||
}
|
||||
|
|
@ -46,6 +47,7 @@ export class VercelAiGatewayEmbedder implements IEmbedder {
|
|||
apiKey,
|
||||
this.modelId,
|
||||
MAX_ITEM_TOKENS,
|
||||
customQueryInstruction,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface CodeIndexConfig {
|
|||
qdrantApiKey?: string
|
||||
searchMinScore?: number
|
||||
searchMaxResults?: number
|
||||
customQueryInstruction?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export class CodeIndexServiceFactory {
|
|||
return new OpenAiEmbedder({
|
||||
...config.openAiOptions,
|
||||
openAiEmbeddingModelId: config.modelId,
|
||||
customQueryInstruction: config.customQueryInstruction,
|
||||
})
|
||||
} else if (provider === "ollama") {
|
||||
if (!config.ollamaOptions?.ollamaBaseUrl) {
|
||||
|
|
@ -54,6 +55,7 @@ export class CodeIndexServiceFactory {
|
|||
return new CodeIndexOllamaEmbedder({
|
||||
...config.ollamaOptions,
|
||||
ollamaModelId: config.modelId,
|
||||
customQueryInstruction: config.customQueryInstruction,
|
||||
})
|
||||
} else if (provider === "openai-compatible") {
|
||||
if (!config.openAiCompatibleOptions?.baseUrl || !config.openAiCompatibleOptions?.apiKey) {
|
||||
|
|
@ -63,22 +65,28 @@ export class CodeIndexServiceFactory {
|
|||
config.openAiCompatibleOptions.baseUrl,
|
||||
config.openAiCompatibleOptions.apiKey,
|
||||
config.modelId,
|
||||
undefined, // maxItemTokens
|
||||
config.customQueryInstruction,
|
||||
)
|
||||
} else if (provider === "gemini") {
|
||||
if (!config.geminiOptions?.apiKey) {
|
||||
throw new Error(t("embeddings:serviceFactory.geminiConfigMissing"))
|
||||
}
|
||||
return new GeminiEmbedder(config.geminiOptions.apiKey, config.modelId)
|
||||
return new GeminiEmbedder(config.geminiOptions.apiKey, config.modelId, config.customQueryInstruction)
|
||||
} else if (provider === "mistral") {
|
||||
if (!config.mistralOptions?.apiKey) {
|
||||
throw new Error(t("embeddings:serviceFactory.mistralConfigMissing"))
|
||||
}
|
||||
return new MistralEmbedder(config.mistralOptions.apiKey, config.modelId)
|
||||
return new MistralEmbedder(config.mistralOptions.apiKey, config.modelId, config.customQueryInstruction)
|
||||
} else if (provider === "vercel-ai-gateway") {
|
||||
if (!config.vercelAiGatewayOptions?.apiKey) {
|
||||
throw new Error(t("embeddings:serviceFactory.vercelAiGatewayConfigMissing"))
|
||||
}
|
||||
return new VercelAiGatewayEmbedder(config.vercelAiGatewayOptions.apiKey, config.modelId)
|
||||
return new VercelAiGatewayEmbedder(
|
||||
config.vercelAiGatewayOptions.apiKey,
|
||||
config.modelId,
|
||||
config.customQueryInstruction,
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ interface LocalCodeIndexSettings {
|
|||
codebaseIndexEmbedderModelDimension?: number // Generic dimension for all providers
|
||||
codebaseIndexSearchMaxResults?: number
|
||||
codebaseIndexSearchMinScore?: number
|
||||
codebaseIndexCustomQueryInstruction?: string
|
||||
|
||||
// Secret settings (start empty, will be loaded separately)
|
||||
codeIndexOpenAiKey?: string
|
||||
|
|
@ -187,6 +188,7 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
|
|||
codebaseIndexEmbedderModelDimension: undefined,
|
||||
codebaseIndexSearchMaxResults: CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS,
|
||||
codebaseIndexSearchMinScore: CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE,
|
||||
codebaseIndexCustomQueryInstruction: "",
|
||||
codeIndexOpenAiKey: "",
|
||||
codeIndexQdrantApiKey: "",
|
||||
codebaseIndexOpenAiCompatibleBaseUrl: "",
|
||||
|
|
@ -222,6 +224,7 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
|
|||
codebaseIndexConfig.codebaseIndexSearchMaxResults ?? CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS,
|
||||
codebaseIndexSearchMinScore:
|
||||
codebaseIndexConfig.codebaseIndexSearchMinScore ?? CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE,
|
||||
codebaseIndexCustomQueryInstruction: codebaseIndexConfig.codebaseIndexCustomQueryInstruction || "",
|
||||
codeIndexOpenAiKey: "",
|
||||
codeIndexQdrantApiKey: "",
|
||||
codebaseIndexOpenAiCompatibleBaseUrl: codebaseIndexConfig.codebaseIndexOpenAiCompatibleBaseUrl || "",
|
||||
|
|
@ -1287,6 +1290,27 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
|
|||
</VSCodeButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Query Instruction */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t("settings:codeIndex.customQueryInstructionLabel")}
|
||||
</label>
|
||||
<StandardTooltip
|
||||
content={t("settings:codeIndex.customQueryInstructionHint")}>
|
||||
<span className="codicon codicon-info text-xs text-vscode-descriptionForeground cursor-help" />
|
||||
</StandardTooltip>
|
||||
</div>
|
||||
<VSCodeTextField
|
||||
value={currentSettings.codebaseIndexCustomQueryInstruction || ""}
|
||||
onInput={(e: any) =>
|
||||
updateSetting("codebaseIndexCustomQueryInstruction", e.target.value)
|
||||
}
|
||||
placeholder={t("settings:codeIndex.customQueryInstructionPlaceholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -89,6 +89,9 @@
|
|||
"qdrantKeyLabel": "Qdrant Key:",
|
||||
"qdrantApiKeyLabel": "Qdrant API Key",
|
||||
"qdrantApiKeyPlaceholder": "Enter your Qdrant API key (optional)",
|
||||
"customQueryInstructionLabel": "Custom Query Instruction",
|
||||
"customQueryInstructionPlaceholder": "Enter custom query instruction (e.g., 'Represent this code for retrieving similar code:')",
|
||||
"customQueryInstructionHint": "Custom instruction to prepend to queries when embedding. Leave empty to use model defaults.",
|
||||
"setupConfigLabel": "Setup",
|
||||
"advancedConfigLabel": "Advanced Configuration",
|
||||
"searchMinScoreLabel": "Search Score Threshold",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue