Skip to content
16 changes: 16 additions & 0 deletions packages/types/src/codebase-index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { z } from "zod"

/**
* Codebase Index Constants
*/
export const CODEBASE_INDEX_DEFAULTS = {
MIN_SEARCH_RESULTS: 10,
MAX_SEARCH_RESULTS: 200,
DEFAULT_SEARCH_RESULTS: 50,
SEARCH_RESULTS_STEP: 10,
DEFAULT_SEARCH_MIN_SCORE: 0.4,
} as const

/**
* CodebaseIndexConfig
*/
Expand All @@ -11,6 +22,11 @@ export const codebaseIndexConfigSchema = z.object({
codebaseIndexEmbedderBaseUrl: z.string().optional(),
codebaseIndexEmbedderModelId: z.string().optional(),
codebaseIndexSearchMinScore: z.number().min(0).max(1).optional(),
codebaseIndexSearchMaxResults: z
.number()
.min(CODEBASE_INDEX_DEFAULTS.MIN_SEARCH_RESULTS)
.max(CODEBASE_INDEX_DEFAULTS.MAX_SEARCH_RESULTS)
.optional(),
})

export type CodebaseIndexConfig = z.infer<typeof codebaseIndexConfigSchema>
Expand Down
1 change: 1 addition & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1840,6 +1840,7 @@ export const webviewMessageHandler = async (
codebaseIndexEmbedderModelId: settings.codebaseIndexEmbedderModelId,
codebaseIndexOpenAiCompatibleBaseUrl: settings.codebaseIndexOpenAiCompatibleBaseUrl,
codebaseIndexOpenAiCompatibleModelDimension: settings.codebaseIndexOpenAiCompatibleModelDimension,
codebaseIndexSearchMaxResults: settings.codebaseIndexSearchMaxResults,
}

// Save global state first
Expand Down
62 changes: 60 additions & 2 deletions src/services/code-index/__tests__/config-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -767,7 +767,7 @@ describe("CodeIndexConfigManager", () => {
expect(configManager.currentSearchMinScore).toBe(0.15)
})

it("should fall back to default SEARCH_MIN_SCORE when neither user setting nor model threshold exists", async () => {
it("should fall back to default DEFAULT_SEARCH_MIN_SCORE when neither user setting nor model threshold exists", async () => {
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
Expand All @@ -781,7 +781,7 @@ describe("CodeIndexConfigManager", () => {
})

await configManager.loadConfiguration()
// Should fall back to default SEARCH_MIN_SCORE (0.4)
// Should fall back to default DEFAULT_SEARCH_MIN_SCORE (0.4)
expect(configManager.currentSearchMinScore).toBe(0.4)
})

Expand Down Expand Up @@ -881,6 +881,61 @@ describe("CodeIndexConfigManager", () => {
expect(anotherManager.currentSearchMinScore).toBe(0.4) // Default
})
})

describe("currentSearchMaxResults", () => {
it("should return user setting when provided, otherwise default", async () => {
// Test 1: User setting takes precedence
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderModelId: "text-embedding-3-small",
codebaseIndexSearchMaxResults: 150, // User setting
})

await configManager.loadConfiguration()
expect(configManager.currentSearchMaxResults).toBe(150) // User setting

// Test 2: Default when no user setting
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderModelId: "text-embedding-3-small",
// No user setting
})

const newManager = new CodeIndexConfigManager(mockContextProxy)
await newManager.loadConfiguration()
expect(newManager.currentSearchMaxResults).toBe(50) // Default (DEFAULT_MAX_SEARCH_RESULTS)

// Test 3: Boundary values
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderModelId: "text-embedding-3-small",
codebaseIndexSearchMaxResults: 10, // Minimum allowed
})

const minManager = new CodeIndexConfigManager(mockContextProxy)
await minManager.loadConfiguration()
expect(minManager.currentSearchMaxResults).toBe(10)

// Test 4: Maximum value
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderModelId: "text-embedding-3-small",
codebaseIndexSearchMaxResults: 200, // Maximum allowed
})

const maxManager = new CodeIndexConfigManager(mockContextProxy)
await maxManager.loadConfiguration()
expect(maxManager.currentSearchMaxResults).toBe(200)
})
})
})

describe("empty/missing API key handling", () => {
Expand Down Expand Up @@ -1157,9 +1212,12 @@ describe("CodeIndexConfigManager", () => {
modelId: "text-embedding-3-large",
openAiOptions: { openAiNativeApiKey: "test-openai-key" },
ollamaOptions: { ollamaBaseUrl: undefined },
geminiOptions: undefined,
openAiCompatibleOptions: undefined,
qdrantUrl: "http://qdrant.local",
qdrantApiKey: "test-qdrant-key",
searchMinScore: 0.4,
searchMaxResults: 50,
})
})

Expand Down
19 changes: 16 additions & 3 deletions src/services/code-index/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ApiHandlerOptions } from "../../shared/api"
import { ContextProxy } from "../../core/config/ContextProxy"
import { EmbedderProvider } from "./interfaces/manager"
import { CodeIndexConfig, PreviousConfigSnapshot } from "./interfaces/config"
import { SEARCH_MIN_SCORE } from "./constants"
import { DEFAULT_SEARCH_MIN_SCORE, DEFAULT_MAX_SEARCH_RESULTS } from "./constants"
import { getDefaultModelId, getModelDimension, getModelScoreThreshold } from "../../shared/embeddingModels"

/**
Expand All @@ -20,6 +20,7 @@ export class CodeIndexConfigManager {
private qdrantUrl?: string = "http://localhost:6333"
private qdrantApiKey?: string
private searchMinScore?: number
private searchMaxResults?: number

constructor(private readonly contextProxy: ContextProxy) {
// Initialize with current configuration to avoid false restart triggers
Expand All @@ -46,6 +47,7 @@ export class CodeIndexConfigManager {
codebaseIndexEmbedderBaseUrl: "",
codebaseIndexEmbedderModelId: "",
codebaseIndexSearchMinScore: undefined,
codebaseIndexSearchMaxResults: undefined,
}

const {
Expand All @@ -55,6 +57,7 @@ export class CodeIndexConfigManager {
codebaseIndexEmbedderBaseUrl,
codebaseIndexEmbedderModelId,
codebaseIndexSearchMinScore,
codebaseIndexSearchMaxResults,
} = codebaseIndexConfig

const openAiKey = this.contextProxy?.getSecret("codeIndexOpenAiKey") ?? ""
Expand All @@ -71,6 +74,7 @@ export class CodeIndexConfigManager {
this.qdrantUrl = codebaseIndexQdrantUrl
this.qdrantApiKey = qdrantApiKey ?? ""
this.searchMinScore = codebaseIndexSearchMinScore
this.searchMaxResults = codebaseIndexSearchMaxResults
this.openAiOptions = { openAiNativeApiKey: openAiKey }

// Set embedder provider with support for openai-compatible
Expand Down Expand Up @@ -334,6 +338,7 @@ export class CodeIndexConfigManager {
qdrantUrl: this.qdrantUrl,
qdrantApiKey: this.qdrantApiKey,
searchMinScore: this.currentSearchMinScore,
searchMaxResults: this.currentSearchMaxResults,
}
}

Expand Down Expand Up @@ -377,7 +382,7 @@ export class CodeIndexConfigManager {

/**
* Gets the configured minimum search score based on user setting, model-specific threshold, or fallback.
* Priority: 1) User setting, 2) Model-specific threshold, 3) Default SEARCH_MIN_SCORE constant.
* Priority: 1) User setting, 2) Model-specific threshold, 3) Default DEFAULT_SEARCH_MIN_SCORE constant.
*/
public get currentSearchMinScore(): number {
// First check if user has configured a custom score threshold
Expand All @@ -388,6 +393,14 @@ export class CodeIndexConfigManager {
// Fall back to model-specific threshold
const currentModelId = this.modelId ?? getDefaultModelId(this.embedderProvider)
const modelSpecificThreshold = getModelScoreThreshold(this.embedderProvider, currentModelId)
return modelSpecificThreshold ?? SEARCH_MIN_SCORE
return modelSpecificThreshold ?? DEFAULT_SEARCH_MIN_SCORE
}

/**
* Gets the configured maximum search results.
* Returns user setting if configured, otherwise returns default.
*/
public get currentSearchMaxResults(): number {
return this.searchMaxResults ?? DEFAULT_MAX_SEARCH_RESULTS
}
}
6 changes: 4 additions & 2 deletions src/services/code-index/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { CODEBASE_INDEX_DEFAULTS } from "@roo-code/types"

/**Parser */
export const MAX_BLOCK_CHARS = 1000
export const MIN_BLOCK_CHARS = 100
export const MIN_CHUNK_REMAINDER_CHARS = 200 // Minimum characters for the *next* chunk after a split
export const MAX_CHARS_TOLERANCE_FACTOR = 1.15 // 15% tolerance for max chars

/**Search */
export const SEARCH_MIN_SCORE = 0.4
export const MAX_SEARCH_RESULTS = 50 // Maximum number of search results to return
export const DEFAULT_SEARCH_MIN_SCORE = CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE
export const DEFAULT_MAX_SEARCH_RESULTS = CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS

/**File Watcher */
export const QDRANT_CODE_BLOCK_NAMESPACE = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
Expand Down
1 change: 1 addition & 0 deletions src/services/code-index/interfaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface CodeIndexConfig {
qdrantUrl?: string
qdrantApiKey?: string
searchMinScore?: number
searchMaxResults?: number
}

/**
Expand Down
11 changes: 9 additions & 2 deletions src/services/code-index/interfaces/vector-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,17 @@ export interface IVectorStore {
/**
* Searches for similar vectors
* @param queryVector Vector to search for
* @param limit Maximum number of results to return
* @param directoryPrefix Optional directory prefix to filter results
* @param minScore Optional minimum score threshold
* @param maxResults Optional maximum number of results to return
* @returns Promise resolving to search results
*/
search(queryVector: number[], directoryPrefix?: string, minScore?: number): Promise<VectorStoreSearchResult[]>
search(
queryVector: number[],
directoryPrefix?: string,
minScore?: number,
maxResults?: number,
): Promise<VectorStoreSearchResult[]>

/**
* Deletes points by file path
Expand Down
3 changes: 2 additions & 1 deletion src/services/code-index/search-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export class CodeIndexSearchService {
}

const minScore = this.configManager.currentSearchMinScore
const maxResults = this.configManager.currentSearchMaxResults

const currentState = this.stateManager.getCurrentStatus().systemStatus
if (currentState !== "Indexed" && currentState !== "Indexing") {
Expand All @@ -52,7 +53,7 @@ export class CodeIndexSearchService {
}

// Perform search
const results = await this.vectorStore.search(vector, normalizedPrefix, minScore)
const results = await this.vectorStore.search(vector, normalizedPrefix, minScore, maxResults)
return results
} catch (error) {
console.error("[CodeIndexSearchService] Error during search:", error)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createHash } from "crypto"

import { QdrantVectorStore } from "../qdrant-client"
import { getWorkspacePath } from "../../../../utils/path"
import { MAX_SEARCH_RESULTS, SEARCH_MIN_SCORE } from "../../constants"
import { DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_SEARCH_MIN_SCORE } from "../../constants"

// Mocks
vitest.mock("@qdrant/js-client-rest")
Expand Down Expand Up @@ -1005,8 +1005,8 @@ describe("QdrantVectorStore", () => {
expect(mockQdrantClientInstance.query).toHaveBeenCalledWith(expectedCollectionName, {
query: queryVector,
filter: undefined,
score_threshold: SEARCH_MIN_SCORE,
limit: MAX_SEARCH_RESULTS,
score_threshold: DEFAULT_SEARCH_MIN_SCORE,
limit: DEFAULT_MAX_SEARCH_RESULTS,
params: {
hnsw_ef: 128,
exact: false,
Expand Down Expand Up @@ -1056,8 +1056,8 @@ describe("QdrantVectorStore", () => {
},
],
},
score_threshold: SEARCH_MIN_SCORE,
limit: MAX_SEARCH_RESULTS,
score_threshold: DEFAULT_SEARCH_MIN_SCORE,
limit: DEFAULT_MAX_SEARCH_RESULTS,
params: {
hnsw_ef: 128,
exact: false,
Expand All @@ -1083,7 +1083,31 @@ describe("QdrantVectorStore", () => {
query: queryVector,
filter: undefined,
score_threshold: customMinScore,
limit: MAX_SEARCH_RESULTS,
limit: DEFAULT_MAX_SEARCH_RESULTS,
params: {
hnsw_ef: 128,
exact: false,
},
with_payload: {
include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"],
},
})
})

it("should use custom maxResults when provided", async () => {
const queryVector = [0.1, 0.2, 0.3]
const customMaxResults = 100
const mockQdrantResults = { points: [] }

mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults)

await vectorStore.search(queryVector, undefined, undefined, customMaxResults)

expect(mockQdrantClientInstance.query).toHaveBeenCalledWith(expectedCollectionName, {
query: queryVector,
filter: undefined,
score_threshold: DEFAULT_SEARCH_MIN_SCORE,
limit: customMaxResults,
params: {
hnsw_ef: 128,
exact: false,
Expand Down Expand Up @@ -1229,8 +1253,8 @@ describe("QdrantVectorStore", () => {
},
],
},
score_threshold: SEARCH_MIN_SCORE,
limit: MAX_SEARCH_RESULTS,
score_threshold: DEFAULT_SEARCH_MIN_SCORE,
limit: DEFAULT_MAX_SEARCH_RESULTS,
params: {
hnsw_ef: 128,
exact: false,
Expand All @@ -1254,7 +1278,7 @@ describe("QdrantVectorStore", () => {
;(console.error as any).mockRestore()
})

it("should use constants MAX_SEARCH_RESULTS and SEARCH_MIN_SCORE correctly", async () => {
it("should use constants DEFAULT_MAX_SEARCH_RESULTS and DEFAULT_SEARCH_MIN_SCORE correctly", async () => {
const queryVector = [0.1, 0.2, 0.3]
const mockQdrantResults = { points: [] }

Expand All @@ -1263,8 +1287,8 @@ describe("QdrantVectorStore", () => {
await vectorStore.search(queryVector)

const callArgs = mockQdrantClientInstance.query.mock.calls[0][1]
expect(callArgs.limit).toBe(MAX_SEARCH_RESULTS)
expect(callArgs.score_threshold).toBe(SEARCH_MIN_SCORE)
expect(callArgs.limit).toBe(DEFAULT_MAX_SEARCH_RESULTS)
expect(callArgs.score_threshold).toBe(DEFAULT_SEARCH_MIN_SCORE)
})
})
})
Loading
Loading