Skip to content

fix(search): cap per-file hits so a small limit spans more files - #34

Merged
Platonenkov merged 3 commits into
mainfrom
claude/result-diversity-524a4
Aug 4, 2026
Merged

fix(search): cap per-file hits so a small limit spans more files#34
Platonenkov merged 3 commits into
mainfrom
claude/result-diversity-524a4

Conversation

@Platonenkov

Copy link
Copy Markdown
Collaborator

Summary

Semantic search under-retrieves peripheral files when a small limit gets crowded out by sibling chunks from one or two central files. HybridRanker fuses purely by rank position and CodeIndexService took a flat Take(limit) off the front of that order, with no notion of which file a candidate came from.

Measured against the real wallet project's index (same methodology as the earlier cost/accuracy benchmark): a natural-language query about the network-unavailable UI flow put three separate members of NetworkUnavailableModal.razor.cs into 3 of 5 result slots at limit=5, leaving no room for XrplSharpClientService.ExecuteIfConnected — a distinct, alternate failure path (the connectivity check that throws NotConnectedException) that ranked 7th overall but 1st among files not already represented.

  • Add ResultDiversifier: a cap-then-backfill pass (default cap 2 per file) that walks the fused ranking once, keeping non-exempt candidates under the cap and backfilling deferred ones afterward. Never returns fewer results than a plain Take(limit) would have.
  • Exempt symbol-branch hits (literal identifier matches) from the cap — an earlier version capped everything uniformly and regressed two SearchQualityTests golden queries ("TrustSet", "LedgerEntry") where several sibling declarations in one file are the legitimately correct multi-part answer. SymbolMatcher only ever matches a query that is, as a whole, a literal substring of a symbol/signature/directory, so it essentially never matches a natural-language sentence — the shape of every query in the real reproduction — making this a clean, principled split between "precision identifier lookup" and "vector-only crowding."
  • Wire it into CodeIndexService.SearchWithStatusAsync: fuse to branchDepth (not limit) so the diversifier has headroom to draw from, then diversify down to limit.

What this does not fix

A second reproduction case (auto-lock lifecycle: the Windows-only wiring in Platforms/Windows/App.xaml.cs vs. the cross-platform App.xaml.cs/AuthStateProvider.cs) is not a crowding defect — confirmed by restricting search to just that file via path_filter, which still returns very few/zero hits for the exact phrasing that missed it. That file competes against more than maxPerFile other genuinely distinct, relevant files (iOS background handling, the settings page, the web lifecycle bridge) for the same small limit, so no per-file cap recovers it; it is a separate ranking/relevance-floor question, not addressed here.

Test plan

  • New unit tests in ResultDiversifierTests.cs covering: crowding across multiple peripheral files, symbol-branch exemption (the exact TrustSetFlags-shaped regression), exempt candidates not consuming their file's non-exempt quota, backfill guarantees, edge cases (empty/negative limit, non-positive cap).
  • New integration regression test in CodeIndexServiceTests.cs reproducing the file-crowding shape end-to-end through SearchWithStatusAsync.
  • Full suite: 359/359 CodeIndex.Core.Tests (including all SearchQualityTests) + 59/59 CodeIndex.Server.Tests pass.
  • Reproduced against the live wallet index (907 files, 6956 chunks, real Ollama embeddings) with a before/after harness built against this commit vs. the parent commit: ExecuteIfConnected/NotConnectedException now appears in the top 5 for both network-failure reproduction queries where it previously did not.

RRF fusion in HybridRanker ranks purely by position and
CodeIndexService then took a flat Take(limit) off the front of that
order, with no notion of which file a candidate came from. When a
topic has several sibling declarations in the same file that all
score highly, they can consume most of a small limit before a
different, still-relevant file's chunk is ever reached, even when it
ranked only a few places lower.

Add ResultDiversifier: a cap-then-backfill pass that walks the fused
ranking once, keeping at most 2 (default) chunks per file during the
first pass and appending deferred candidates afterwards to fill any
remaining slots. This never returns fewer results than a plain
Take(limit) would have, and never reorders within a file - it only
lets other files' candidates in ahead of a dominant file's overflow.

Wire it into CodeIndexService.SearchWithStatusAsync: fuse to
branchDepth (not limit) so the diversifier has headroom to draw from,
then diversify down to limit.
Uniform per-file capping regressed two SearchQualityTests golden
queries ("TrustSet", "LedgerEntry"): each legitimately matches three
sibling declarations in one file via SymbolMatcher's exact/prefix/
substring bands, and capping bumped the query's own named class out
in favor of an unrelated chunk from a different, merely-still-under-
cap file - a strictly worse result, not a diversified one.

SymbolMatcher only ever matches a query that is, as a whole, a
literal substring of a chunk's symbol/signature/directory, so it
essentially never matches a natural-language sentence - the shape of
every query in the real reproduction. Exempting symbol-branch hits
from the cap therefore leaves precision identifier lookups exactly as
clustered as they earned the right to be, while still catching the
vector-only crowding the real defect is made of.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 66843887-00f4-466e-9fdd-e133fee259ea

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

…last

Backfill runs after the capped pass, so appending a deferred candidate
directly placed it behind every hit selected while it sat deferred. With
three chunks from one file and one from another at limit 4, the output came
back as ranks [0, 1, 3, 2]: a rank-3 hit presented below the rank-4 hit that
had displaced it, while its own `score` field still said it was stronger.

Selection is now recorded as flags over the input's positions and emitted in
input order, so membership changes and ordering does not -- which is the only
thing diversification was ever meant to do.

The new test asserts both the literal expected order and the invariant behind
it (scores never increase down the list), since the invariant is what a
caller actually relies on.
@Platonenkov

Copy link
Copy Markdown
Collaborator Author

Дописал одно и завёл отдельный issue на второе.

Порядок выдачи. Backfill дописывал отложенного кандидата в конец, поэтому список переставал быть монотонным по релевантности: при трёх чанках одного файла и одном чужом с limit=4 возвращалось [0, 1, 3, 2] — хит третьего ранга ниже того, который его вытеснил, при том что его собственное поле score утверждало обратное. Отбор теперь пишется флагами по позициям входа и отдаётся в порядке входа: меняется состав, порядок — нет. Тест проверяет и конкретный порядок, и сам инвариант (оценки не растут вниз по списку).

Кейс auto-lock — не про разнообразие, и это важное уточнение к моей же постановке задачи. Вы это показали убедительно: path_filter, прижатый к самому файлу, вернул ноль хитов, то есть вытеснять было нечего — чанк не проходит порог MinCosineSimilarity = 0.55. Я формулировал задачу как «недобор из-за тесноты», а теснота объясняет только вторую формулировку запроса. Вынес в #35.

Отдельно отмечу правильный ход с golden-запросами: когда первая версия сломала TrustSet и LedgerEntry, вы пересмотрели дизайн, а не тест. Освобождение символьных хитов от ограничения обосновано по существу — SymbolMatcher срабатывает только когда весь запрос целиком является подстрокой символа, сигнатуры или пути, поэтому естественно-языковые запросы туда не попадают вовсе.

Тесты: 419 (360 Core + 59 Server), было 408.

@Platonenkov
Platonenkov merged commit 8d1e396 into main Aug 4, 2026
4 checks passed
@Platonenkov
Platonenkov deleted the claude/result-diversity-524a4 branch August 4, 2026 21:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant