From 729ec5161c9e4032c7af9ac85a089f650ae0fd6f Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Mon, 3 Aug 2026 16:55:38 -0300 Subject: [PATCH] perf(core): shrink code_search's per-call payload Two benchmarks measured code_search costing more tokens than Grep despite finding things in fewer calls: the payload per call was the problem, not the search itself (up to 150 lines of source per call: 15-line excerpts x a 10-hit default limit). - CodeIndexService.MaxExcerptLines: 15 -> 5. An excerpt only needs to let a caller judge relevance; the signature is already a separate field, and code_get_chunk exists for the full body. A flat per-hit cap is kept (rather than a signature-aware one) because Signature is a synthesized string with no reliable 1:1 line mapping back to the raw source, so a smarter cap would need new chunk metadata to do safely. Short chunks are unaffected either way (ReadExcerptAsync never reads past EndLine). - CodeSearchTools default `limit`: 10 -> 5. MinBranchDepth (50) is unchanged, so fusion ranking quality is unaffected by a smaller default result count. - code_search/code_get_chunk tool descriptions updated so the shorter excerpt makes code_get_chunk the obvious next step once a hit looks promising, and the README's parameter table/prose updated to match. Every reference-query test in SearchQualityTests passes an explicit limit (3 or 5), so the smaller default didn't touch them. Added a test that a short chunk's excerpt is exactly its own lines, not padded to the cap. --- README.md | 12 ++++-- src/CodeIndex.Core/Search/CodeIndexService.cs | 15 ++++++- src/CodeIndex.Server/Tools/CodeSearchTools.cs | 18 +++++---- .../Search/CodeIndexServiceTests.cs | 40 +++++++++++++++---- 4 files changed, 65 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 93d0f75..641c855 100644 --- a/README.md +++ b/README.md @@ -199,14 +199,15 @@ find where something is *implemented*, not to find every literal occurrence of a | Parameter | Type | Default | Description | |---|---|---|---| | `query` | string | — | Natural-language question or exact identifier/symbol name. Blank/whitespace-only is rejected with an error rather than returning arbitrary hits. | -| `limit` | int | `10` | Maximum number of hits to return. Negative is rejected with an error; `0` returns none. Not silently capped — a large limit searches deeper into both branches to try to satisfy it. | +| `limit` | int | `5` | Maximum number of hits to return. Negative is rejected with an error; `0` returns none. Not silently capped — a large limit searches deeper into both branches to try to satisfy it. | | `kind` | string? | `null` | Restrict to one chunk kind: `Class`, `Interface`, `Struct`, `Record`, `Enum`, `Method`, `Constructor`, `Property`, `Field`, or `FileFragment`. Case-insensitive; an unrecognized value is ignored silently. | | `path_filter` | string? | `null` | Case-insensitive substring filter on the file's relative path. | | `project` | string? | `null` | Restrict the search to one configured project's `Id`. Omit to search every configured project and merge the results. | Returns a ranked list of hits, each with an `id`, the `project` it came from, file path, line -range, kind, symbol, signature, doc comment (if any), a short excerpt, an optional -`excerpt_may_be_stale` flag, and a `score`: the fused Reciprocal-Rank-Fusion value (not a raw +range, kind, symbol, signature, doc comment (if any), a short excerpt (at most 5 lines from the +start of the declaration — enough to judge relevance, not a substitute for `code_get_chunk`), an +optional `excerpt_may_be_stale` flag, and a `score`: the fused Reciprocal-Rank-Fusion value (not a raw similarity percentage — see [Searching across projects](#searching-across-projects) for what feeds it). Higher is better; a hit near the bottom of only one branch's ranking is a weak match worth a second look. The vector branch also applies a relevance floor before fusion — see @@ -361,6 +362,11 @@ search-heavy sessions — was reasoned from the mechanism (fewer wrong files ope sweeps) and never actually measured. An external reviewer pointed that out. This section replaces it with a real measurement. +**Note:** this measurement predates the `limit`/excerpt-length defaults documented above (it ran +against `limit=10` and a 15-line excerpt cap, since lowered to `5` and 5 lines respectively — see +`code_search`'s per-hit payload). A re-run against the current defaults has not been done; treat +the numbers below as the shape of the trade-off, not as still-current absolute figures. + **Method.** 10 pairs of subagents (20 agents total, one Claude Code session each) were given the *same* task text and pointed at the same indexed C# repository (`XrplCSharp`, 773 files, 8,988 chunks, this same server). One member of each pair could use only `Grep`/`Glob`/`Read`; the other diff --git a/src/CodeIndex.Core/Search/CodeIndexService.cs b/src/CodeIndex.Core/Search/CodeIndexService.cs index a455b39..b4aa4ec 100644 --- a/src/CodeIndex.Core/Search/CodeIndexService.cs +++ b/src/CodeIndex.Core/Search/CodeIndexService.cs @@ -76,8 +76,19 @@ public sealed class CodeIndexService /// a given call is derived from this floor and the caller's limit. private const int MinBranchDepth = 50; - /// Excerpts shown to callers are capped at this many lines. - private const int MaxExcerptLines = 15; + /// + /// Excerpts shown to callers are capped at this many lines. Kept small on purpose: an + /// excerpt's job is to let a caller judge relevance, not to substitute for the full + /// declaration — every hit already carries the chunk's signature field separately + /// (see ), and exists + /// specifically for callers that need the rest of the body. A flat per-hit cap, rather than + /// one that scales with the chunk's own length, is deliberate: a longer member does not + /// need a longer preview to be judged relevant, and scaling the cap would reintroduce the + /// exact per-call cost this constant exists to bound. Short chunks are unaffected either + /// way — never reads past the chunk's own EndLine, so + /// a one-line property still returns exactly one line, not a padded five. + /// + private const int MaxExcerptLines = 5; private readonly IIndexBuilder _builder; private readonly ISourceProvider _source; diff --git a/src/CodeIndex.Server/Tools/CodeSearchTools.cs b/src/CodeIndex.Server/Tools/CodeSearchTools.cs index 9464e70..6a64428 100644 --- a/src/CodeIndex.Server/Tools/CodeSearchTools.cs +++ b/src/CodeIndex.Server/Tools/CodeSearchTools.cs @@ -35,9 +35,11 @@ public sealed class CodeSearchTools "more than one project is configured on this server and 'project' is omitted, every " + "configured project is searched and the results are merged into one ranked list (each hit " + "still names which project it came from); pass 'project' to search only that one. Each " + - "hit carries a short excerpt, an 'id' (pass it to code_get_chunk to read the declaration's " + - "full body), and a 'score': a Reciprocal-Rank-Fusion value combining the vector and symbol " + - "branches, not a raw similarity percentage — higher is better, but the absolute number is " + + "hit carries a short excerpt (at most 5 lines from the start of the declaration — enough " + + "to judge relevance, not a substitute for the body), an 'id' (pass it to code_get_chunk " + + "to read the declaration's full body — expect to need this often, not just occasionally, " + + "once the excerpt looks promising), and a 'score': a Reciprocal-Rank-Fusion value combining " + + "the vector and symbol branches, not a raw similarity percentage — higher is better, but the absolute number is " + "not meaningful on its own. As a rough guide, a hit near 0.03 ranked at or near the top of " + "both the semantic and symbol match; a hit near 0.008-0.015 was found by only one branch, " + "near the bottom of its ranking, and is a weak match worth a second look before trusting " + @@ -52,8 +54,10 @@ public sealed class CodeSearchTools private const string GetChunkDescription = "Fetches the full body of one chunk (a complete class/method/property/etc. declaration) " + - "by the 'id' returned in a code_search hit. Use this once code_search's excerpt is not " + - "enough and you need the whole declaration. An id is opaque and already names its project " + + "by the 'id' returned in a code_search hit. code_search's excerpt is deliberately short " + + "(at most 5 lines) — call this whenever the excerpt only gets you as far as recognizing " + + "the right declaration, which will be often, not just on the rare hit where 5 lines " + + "happens to fall short. An id is opaque and already names its project " + "(e.g. \"xrpl:3:4137\") — pass it back exactly as code_search returned it; there is no need " + "to also pass a separate project parameter. Chunk ids are tied to one project's index as " + "it existed at that specific search and do NOT survive a reindex (an explicit " + @@ -109,11 +113,11 @@ public CodeSearchTools(ProjectRegistry registry) public async Task SearchAsync( [Description("Natural-language question or exact identifier/symbol name to search for.")] string query, - [Description("Maximum number of hits to return. Default 10. Must not be negative (0 is " + + [Description("Maximum number of hits to return. Default 5. Must not be negative (0 is " + "valid and returns no hits). Not silently capped: a large limit searches deeper into " + "both the semantic and symbol branches to try to satisfy it, bounded only by how many " + "chunks exist (or match 'kind'/'path_filter').")] - int limit = 10, + int limit = 5, [Description("Optional filter restricting results to one chunk kind: Class, Interface, " + "Struct, Record, Enum, Method, Constructor, Property, Field, or FileFragment. " + "Case-insensitive. An unrecognized value is ignored silently (no filter is applied) " + diff --git a/tests/CodeIndex.Core.Tests/Search/CodeIndexServiceTests.cs b/tests/CodeIndex.Core.Tests/Search/CodeIndexServiceTests.cs index 21909a9..1806174 100644 --- a/tests/CodeIndex.Core.Tests/Search/CodeIndexServiceTests.cs +++ b/tests/CodeIndex.Core.Tests/Search/CodeIndexServiceTests.cs @@ -37,9 +37,9 @@ public class {{className}} """; /// A file whose method body is long enough that the method chunk's line range - /// exceeds the 15-line excerpt cap. Returns both the file content and the exact source lines - /// used to build it, so tests can compute the expected excerpt/body independently of any - /// production code path. + /// exceeds the excerpt cap ('s MaxExcerptLines). + /// Returns both the file content and the exact source lines used to build it, so tests can + /// compute the expected excerpt/body independently of any production code path. private static (string Content, IReadOnlyList Lines) MakeBigMethodFile( string ns, string className, string methodName, int bodyStatementCount) { @@ -96,7 +96,7 @@ public async Task SearchWithStatusAsync_FindsChunkByExactSymbolEvenWhenEmbedding } [Fact] - public async Task SearchWithStatusAsync_PopulatesExcerptCappedAtFifteenLines() + public async Task SearchWithStatusAsync_PopulatesExcerptCappedAtFiveLines() { (string content, IReadOnlyList lines) = MakeBigMethodFile("Acme.Big", "Widget", "BigMethod", bodyStatementCount: 20); InMemorySourceProvider source = new(new Dictionary { ["src/Big.cs"] = content }); @@ -108,15 +108,38 @@ public async Task SearchWithStatusAsync_PopulatesExcerptCappedAtFifteenLines() SearchHit hit = Assert.Single(result.Hits, h => h.Chunk.Symbol.EndsWith("BigMethod", StringComparison.Ordinal)); int fullLineCount = hit.Chunk.EndLine - hit.Chunk.StartLine + 1; - Assert.True(fullLineCount > 15, "fixture must produce a chunk longer than the excerpt cap"); + Assert.True(fullLineCount > 5, "fixture must produce a chunk longer than the excerpt cap"); string[] excerptLines = SourceLines.Split(hit.Excerpt); - Assert.Equal(15, excerptLines.Length); + Assert.Equal(5, excerptLines.Length); - string[] expectedLines = lines.Skip(hit.Chunk.StartLine - 1).Take(15).ToArray(); + string[] expectedLines = lines.Skip(hit.Chunk.StartLine - 1).Take(5).ToArray(); Assert.Equal(expectedLines, excerptLines); } + [Fact] + public async Task SearchWithStatusAsync_ShortChunkExcerptIsNotPaddedToTheCap() + { + // A one-line method body must come back as exactly its own lines, not padded out to + // MaxExcerptLines — the cap is a ceiling, not a target length. + InMemorySourceProvider source = new(new Dictionary + { + ["src/Small.cs"] = MakeSimpleFile("Acme.Small", "Widget", "DoSmall"), + }); + CodeIndexService service = CreateService(source, new StubEmbeddingClient(), out _); + + SearchResult result = await service.SearchWithStatusAsync( + "DoSmall", limit: 5, kind: null, pathFilter: null, TestContext.Current.CancellationToken); + + SearchHit hit = Assert.Single(result.Hits, h => h.Chunk.Symbol.EndsWith("DoSmall", StringComparison.Ordinal)); + + int fullLineCount = hit.Chunk.EndLine - hit.Chunk.StartLine + 1; + Assert.True(fullLineCount < 5, "fixture must produce a chunk shorter than the excerpt cap"); + + string[] excerptLines = SourceLines.Split(hit.Excerpt); + Assert.Equal(fullLineCount, excerptLines.Length); + } + [Fact] public async Task SearchWithStatusAsync_FiltersByKind() { @@ -198,7 +221,8 @@ public async Task GetChunkAsync_ReturnsFullBodyForValidIdAndNullForOutOfRange() Assert.True(methodChunkId >= 0); CodeChunk chunk = snapshot.Chunks[methodChunkId]; int fullLineCount = chunk.EndLine - chunk.StartLine + 1; - Assert.True(fullLineCount > 15, "fixture must produce a chunk longer than the excerpt cap"); + Assert.True(fullLineCount > 5, "fixture must produce a chunk longer than code_search's excerpt cap, " + + "to demonstrate GetChunkAsync returns the full body rather than a capped excerpt"); int generation = snapshot.Header.Generation; SearchHit? hit = await service.GetChunkAsync(generation, methodChunkId, TestContext.Current.CancellationToken);