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);