Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions src/CodeIndex.Core/Search/CodeIndexService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,19 @@ public sealed class CodeIndexService
/// a given call is derived from this floor and the caller's <c>limit</c>.</summary>
private const int MinBranchDepth = 50;

/// <summary>Excerpts shown to callers are capped at this many lines.</summary>
private const int MaxExcerptLines = 15;
/// <summary>
/// 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 <c>signature</c> field separately
/// (see <see cref="Chunking.CodeChunk.Signature"/>), and <see cref="GetChunkAsync"/> 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 — <see cref="ReadExcerptAsync"/> never reads past the chunk's own <c>EndLine</c>, so
/// a one-line property still returns exactly one line, not a padded five.
/// </summary>
private const int MaxExcerptLines = 5;

private readonly IIndexBuilder _builder;
private readonly ISourceProvider _source;
Expand Down
18 changes: 11 additions & 7 deletions src/CodeIndex.Server/Tools/CodeSearchTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 " +
Expand All @@ -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 " +
Expand Down Expand Up @@ -109,11 +113,11 @@ public CodeSearchTools(ProjectRegistry registry)
public async Task<string> 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) " +
Expand Down
40 changes: 32 additions & 8 deletions tests/CodeIndex.Core.Tests/Search/CodeIndexServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ public class {{className}}
""";

/// <summary>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.</summary>
/// exceeds the excerpt cap (<see cref="CodeIndexService"/>'s <c>MaxExcerptLines</c>).
/// 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.</summary>
private static (string Content, IReadOnlyList<string> Lines) MakeBigMethodFile(
string ns, string className, string methodName, int bodyStatementCount)
{
Expand Down Expand Up @@ -96,7 +96,7 @@ public async Task SearchWithStatusAsync_FindsChunkByExactSymbolEvenWhenEmbedding
}

[Fact]
public async Task SearchWithStatusAsync_PopulatesExcerptCappedAtFifteenLines()
public async Task SearchWithStatusAsync_PopulatesExcerptCappedAtFiveLines()
{
(string content, IReadOnlyList<string> lines) = MakeBigMethodFile("Acme.Big", "Widget", "BigMethod", bodyStatementCount: 20);
InMemorySourceProvider source = new(new Dictionary<string, string> { ["src/Big.cs"] = content });
Expand All @@ -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<string, string>
{
["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()
{
Expand Down Expand Up @@ -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);
Expand Down