Skip to content

Commit 1e7d056

Browse files
committed
feat(core): diversify retrieval results by document
Release highlights: - Return broader document coverage without reducing the requested result count. - Keep lexical fallback complete when the corpus exceeds one scan batch. Release details: - Add a configurable document cap with internal over-retrieval and ranked backfill. - Apply diversification to CLI, MCP, ask, evaluation, portable status, and explanations. - Scan fallback batches across the full eligible corpus and document MMR tradeoffs. Verification: - pnpm validate - Quality benchmark: 100/100 cases, reproducible, recall@10 1.000. - Manual CLI: 10/10 distinct documents by default versus 6/10 uncapped.
1 parent 4ffb8e2 commit 1e7d056

26 files changed

Lines changed: 567 additions & 190 deletions

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,10 @@ pnpm exec rgr research "release obligations" --compact --timeout-ms 10000
224224
```
225225

226226
`preview` inspects redacted chunks without writing an index. `audit` compares sources with indexed
227-
state. `research` combines bounded query variants with deterministic cross-query ranking. Use
227+
state. Search keeps one primary passage per document by default, over-retrieves before applying the
228+
cap, and preserves ranked backfill when fewer distinct documents are available. Override the cap
229+
with `--max-chunks-per-document`; context-radius neighbors remain attached to their primary result.
230+
`research` combines bounded query variants with deterministic cross-query ranking. Use
228231
`rgr doctor --deep` only when you need a live O(corpus) inventory; normal status and doctor checks
229232
read the compact activation manifest.
230233

docs/api-reference.md

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,13 @@ instead of presenting an unverifiable line claim.
5858

5959
With `explain: true`, `score` includes the vector and lexical ranks, their reciprocal-rank-fusion
6060
contributions, matched terms, backend scores, FTS or complete-fallback activation and reason,
61-
candidate materialization, query-variant count, indexed/unindexed rows, coverage, queue wait as
62-
`workloadQueueMs`, and
63-
`rankingPolicyFingerprint`. The fingerprint
64-
identifies the provider, retrieval profile, fusion parameters, and abstention threshold used by
65-
the result. Equal scores are ordered by stable source and chunk keys, so identical indexes return
66-
the same order regardless of backend row order. Search returns an empty array when every candidate
67-
fails the active provider's evidence threshold.
61+
fallback scan batches, candidate materialization, query-variant count, indexed/unindexed rows,
62+
coverage, document-cap and ranked-backfill state, queue wait as `workloadQueueMs`, and
63+
`rankingPolicyFingerprint`. The fingerprint identifies the provider, retrieval profile, document
64+
cap, fusion parameters, and abstention threshold used by the result. Equal scores are ordered by
65+
stable source and chunk keys, so identical indexes return the same order regardless of backend row
66+
order. Search returns an empty array when every candidate fails the active provider's evidence
67+
threshold.
6868

6969
### Persistent client for Node.js workers
7070

@@ -236,13 +236,17 @@ to perform an external action.
236236

237237
One evaluation pins a single configuration, connection, manifest generation, table handle, and
238238
embedding model. Cases run with bounded concurrency, preserve file order in the report, and release
239-
all scoped resources when evaluation finishes.
239+
all scoped resources when evaluation finishes. The report records the configured
240+
`maxChunksPerDocument` beside the ranking-policy fingerprint so reference results are reproducible.
240241

241-
`SearchOptions` accepts `cwd`, `topK`, `contextRadius`, `includePaths`, `excludePaths`,
242-
`contextPaths`, `explain`, `vectorSearchMode`, `signal`, and `timeoutMs`. Set
242+
`SearchOptions` accepts `cwd`, `topK`, `maxChunksPerDocument`, `contextRadius`, `includePaths`,
243+
`excludePaths`, `contextPaths`, `explain`, `vectorSearchMode`, `signal`, and `timeoutMs`. Set
243244
`vectorSearchMode: "exact"` to bypass ANN for diagnostic comparison; the default `"adaptive"`
244-
uses the compatible strategy recorded in the manifest. `topK` is limited to 100 and
245-
`contextRadius` is clamped to three chunks. `IngestOptions` also accepts `rebuild`, a
245+
uses the compatible strategy recorded in the manifest. `topK` and `maxChunksPerDocument` are
246+
limited to 100. The document cap defaults to one, applies after scoring, and is preceded by internal
247+
over-retrieval. Ranked backfill preserves the requested result count when too few distinct
248+
documents are available. `contextRadius` is clamped to three chunks and attaches neighbors after
249+
primary-result diversification. `IngestOptions` also accepts `rebuild`, a
246250
positive `batchSize` that defaults to 25 files and is capped at 128, `incrementalFailurePolicy`, and
247251
an optional `onProgress` callback. Set `collectMetrics: true` to include privacy-safe phase,
248252
throughput, cache-state, RSS, OCR subprocess, fallback, error, timeout, and bound-activation metrics

docs/cli-reference.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,25 +40,29 @@ rgr sources add "docs/**/*.md" "!docs/archive/**"
4040
rgr sources list
4141
rgr preview --path docs --max-files 5 --max-chunks 3
4242
rgr search "migration" --top-k 5 --context-radius 1
43+
rgr search "migration" --top-k 5 --max-chunks-per-document 2
4344
rgr search "migration" --include-path docs --exclude-path docs/archive
4445
rgr search "migration" --context-path "Guide > Migration" --explain
4546
rgr search "migration" --exact-vector-search
4647
```
4748

4849
`sources add` accepts paths, globs, and `!` exclusions. Search, ask, and research accept `--top-k`,
4950
`--include-path`, `--exclude-path`, and repeatable `--context-path`. Search and ask accept
50-
`--explain`; the optional score object reports RRF contributions, retriever ranks, raw backend
51-
scores, FTS or complete-fallback activation and reason, candidate and index coverage, queue wait,
52-
and matched query terms without changing ranking. Use `--compact` on search or research when
51+
`--max-chunks-per-document` and `--explain`. The document cap defaults to one, applies after scoring,
52+
and over-retrieves internally before final truncation. Ranked backfill keeps the requested result
53+
count when the corpus has too few distinct documents. The optional score object reports RRF
54+
contributions, retriever ranks, raw backend scores, document-cap and backfill state, FTS or
55+
complete-fallback activation and reason, fallback scan batches, candidate and index coverage, queue
56+
wait, and matched query terms without changing ranking. Use `--compact` on search or research when
5357
agent context is limited. This remains explicit for CLI automation; MCP search, ask, and research
54-
are compact by default. Search and ask accept `--exact-vector-search` to bypass an active ANN
55-
index for diagnostics against exhaustive vector search. `--top-k` is limited to 100 and
56-
`--context-radius` is clamped to three chunks.
58+
are compact by default. Search and ask accept `--exact-vector-search` to bypass an active ANN index
59+
for diagnostics against exhaustive vector search. `--top-k` and `--max-chunks-per-document` are
60+
limited to 100, and `--context-radius` is clamped to three chunks.
5761

5862
The explanation also contains a ranking-policy fingerprint so a stored quality report can be tied
59-
to the exact provider, profile, fusion, and abstention settings. Equal backend scores have a stable
60-
source-and-chunk tie-break. Search returns no result when all candidates fail the provider-aware
61-
evidence threshold; it does not force a low-confidence passage into the response.
63+
to the exact provider, profile, document cap, fusion, and abstention settings. Equal backend scores
64+
have a stable source-and-chunk tie-break. Search returns no result when all candidates fail the
65+
provider-aware evidence threshold; it does not force a low-confidence passage into the response.
6266

6367
`preview` uses the active redaction and chunking configuration but never writes storage. `audit`
6468
reports min, mean, p50, p95, and max chunk sizes plus structural-context coverage.

docs/configuration.md

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ edit JSON only for a real need.
2424
| `embeddingModelRevision` | Pinned commit for bundled profiles | Use an immutable 40-character commit for reproducible model artifacts. Unknown custom models default to the mutable `main` revision until explicitly pinned. |
2525
| `embeddingModelDigest` | `null` | `rgr models pull --enable` records a SHA-256 identity for the resolved local artifact tree. Do not set it by hand unless the local files were verified independently. |
2626
| `topK` | `8` | Change the CLI and TypeScript default, up to the hard limit of 100. MCP requests without `topK` start at the lower of this value and 3. |
27+
| `maxChunksPerDocument` | `1` | Limit primary ranked passages from one document before final `topK` truncation, up to 100. Ranked backfill preserves the requested result count when the candidate set has too few documents. |
2728
| `mcpMaxTopK` | `10` | Bound explicit MCP passage requests; values above 100 are rejected. |
2829
| `mcpMaxOutputBytes` | `32768` | Cap variable-size MCP tool and resource JSON; the server also enforces an absolute 1 MiB ceiling. |
2930
| `chunkSize` / `chunkOverlap` | `1200` / `200` | Tune chunking, then rebuild the index. Chunk size is capped at 1,000,000 characters. |
@@ -32,7 +33,7 @@ edit JSON only for a real need.
3233
| `embeddingBatchSize` | `32` | Bound one model call; values above `128` are rejected. |
3334
| `sourceFingerprintMode` | `fast` | Use `strict` to hash every source on every inventory instead of reusing unchanged private fingerprints. |
3435
| `incrementalFailurePolicy` | `preserve-last-good` | Use `remove-stale` only when failed changed files must disappear immediately. |
35-
| `hybridTextScanLimit` | `5000` | Bound only the complete-scan fallback used when FTS is unavailable; values above 10,000 are rejected. A fallback smaller than the active corpus is rejected instead of returning silently truncated lexical evidence. |
36+
| `hybridTextScanLimit` | `5000` | Set the batch size for the complete lexical scan used when FTS is unavailable; values above 10,000 are rejected. The fallback scans every matching chunk across as many batches as the corpus requires. |
3637
| `workloadLimits` | See below | Bound active search, embedding, and ingestion work plus their queues and queue deadlines. |
3738
| `includeExtensions` | `[]` | Add safe custom text extensions. |
3839

@@ -81,21 +82,33 @@ Profiles bound retrieval work. They are candidate and diversification budgets, n
8182
a larger budget improves every corpus. Evaluate the profile against a representative golden set
8283
before changing production configuration.
8384

84-
| Profile | Quality intent | Latency intent | Default `topK` | Fallback scan cap | Vector candidates | FTS candidates | First-pass chunks per source | Context radius |
85+
| Profile | Quality intent | Latency intent | Default `topK` | Fallback batch size | Vector candidates | FTS candidates | Document cap | Context radius |
8586
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
86-
| `fast` | Narrow, diverse evidence | Lowest work budget | 5 | 2,000 | max(40, 3 x `topK`) | max(100, 10 x `topK`) | 1, then backfill | 0 |
87-
| `balanced` | General-purpose evidence | Default work budget | 8 | 5,000 | max(80, 4 x `topK`) | max(250, 20 x `topK`) | 2, then backfill | 0 |
88-
| `quality` | Broader multi-section evidence | Highest work budget | 12 | 10,000 | max(200, 8 x `topK`) | min(4,000, max(500, 40 x `topK`)) | 4, then backfill | 1 |
89-
| `custom` | Golden-set validated | Operator-defined | configured | configured | max(80, 4 x `topK`) | max(250, 20 x `topK`) | 2, then backfill | 0 |
87+
| `fast` | Narrow, diverse evidence | Lowest work budget | 5 | 2,000 | max(40, 3 x `demand`) | max(100, 10 x `demand`) | configured, default 1 | 0 |
88+
| `balanced` | General-purpose evidence | Default work budget | 8 | 5,000 | max(80, 4 x `demand`) | max(250, 20 x `demand`) | configured, default 1 | 0 |
89+
| `quality` | Broader multi-section evidence | Highest work budget | 12 | 10,000 | max(200, 8 x `demand`) | min(4,000, max(500, 40 x `demand`)) | configured, default 1 | 1 |
90+
| `custom` | Golden-set validated | Operator-defined | configured | configured | max(80, 4 x `demand`) | max(250, 20 x `demand`) | configured, default 1 | 0 |
9091

92+
`demand` is `topK * ceil(4 / maxChunksPerDocument)`, with a minimum multiplier of one. This internal
93+
over-retrieval gives the diversity pass enough lower-ranked documents before final truncation.
9194
Vector candidates are capped at 1,000. The FTS pool is profile-aware and capped at 4,000,
92-
independently from `hybridTextScanLimit`. Structural context and body text feed the primary local
93-
index. Exact file paths use a bounded scalar variant. Controlled exact-phrase, identifier, and fuzzy
94-
rare-term queries expand only a primary pool that cannot fill `topK`, preserving established ranks.
95-
The diversity pass prefers distinct sources first, then backfills ranked non-duplicate, non-overlapping
96-
chunks to `topK`. Hybrid ranking uses deterministic reciprocal-rank fusion with `k = 60` and equal
97-
vector and lexical weights. Stable source and chunk keys break score ties before ranks are assigned.
98-
The active provider, profile, and ranking parameters form a policy fingerprint stored in quality
95+
independently from the complete-scan batch size. Structural context and body text feed the primary
96+
local index. Exact file paths use a bounded scalar variant. Controlled exact-phrase, identifier,
97+
and fuzzy rare-term queries expand only a primary pool that cannot fill the demand, preserving
98+
established ranks.
99+
100+
After scoring and abstention, the deterministic diversity pass keeps at most
101+
`maxChunksPerDocument` primary passages per relative path while preserving rank order. It then
102+
backfills ranked, non-duplicate, non-overlapping chunks only when too few documents or candidates
103+
remain to fill `topK`. Neighbor chunks requested through `contextRadius` are attached afterward and
104+
do not consume the primary document cap. MMR is not part of the default policy: pairwise similarity
105+
adds more work and corpus-dependent ordering, and the current golden benchmark does not show an
106+
advantage over the simple cap default. It can be evaluated later as an explicit opt-in strategy
107+
without changing this predictable default.
108+
109+
Hybrid ranking uses deterministic reciprocal-rank fusion with `k = 60` and equal vector and lexical
110+
weights. Stable source and chunk keys break score ties before ranks are assigned. The active
111+
provider, profile, document cap, and ranking parameters form a policy fingerprint stored in quality
99112
reports and exposed by score explanations.
100113

101114
Abstention is provider-aware. `local-hash` requires lexical evidence and gives query identifiers

packages/ragmir-core/benchmarks/quality.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ async function evaluateRankingVariants(client, goldenQueries) {
129129
const rows = await client.search(testCase.query, {
130130
topK: 100,
131131
explain: true,
132+
maxChunksPerDocument: 100,
132133
...(testCase.includePaths === undefined ? {} : { includePaths: testCase.includePaths }),
133134
...(testCase.excludePaths === undefined ? {} : { excludePaths: testCase.excludePaths }),
134135
...(testCase.contextPaths === undefined ? {} : { contextPaths: testCase.contextPaths }),

packages/ragmir-core/src/cli.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,11 @@ program
655655
.description("Retrieve the most relevant passages without calling an LLM.")
656656
.argument("<query>", "Search query.")
657657
.option("-k, --top-k <number>", "Number of passages to return.", parsePositiveInt)
658+
.option(
659+
"--max-chunks-per-document <number>",
660+
"Maximum primary passages per document before ranked backfill.",
661+
parsePositiveInt,
662+
)
658663
.option(
659664
"--context-radius <number>",
660665
"Include neighboring chunks around each matched passage.",
@@ -687,6 +692,7 @@ program
687692
query: string,
688693
options: {
689694
topK?: number
695+
maxChunksPerDocument?: number
690696
contextRadius?: number
691697
includePath: string[]
692698
excludePath: string[]
@@ -729,7 +735,7 @@ program
729735
const lexicalRank = result.score.lexicalRank ?? "n/a"
730736
console.log(
731737
pc.dim(
732-
`score=${result.score.combinedScore.toFixed(6)} fusion=${result.score.fusion} vector=${result.score.vectorContribution.toFixed(6)} lexical=${result.score.lexicalContribution.toFixed(6)} vectorRank=${vectorRank} lexicalRank=${lexicalRank} matchedTerms=${result.score.matchedTerms.join(",") || "n/a"}`,
738+
`score=${result.score.combinedScore.toFixed(6)} fusion=${result.score.fusion} vector=${result.score.vectorContribution.toFixed(6)} lexical=${result.score.lexicalContribution.toFixed(6)} vectorRank=${vectorRank} lexicalRank=${lexicalRank} documentCap=${result.score.maxChunksPerDocument} diversityBackfill=${result.score.diversityBackfillActivated} matchedTerms=${result.score.matchedTerms.join(",") || "n/a"}`,
733739
),
734740
)
735741
}
@@ -742,6 +748,11 @@ program
742748
.description("Return cited retrieval context for a question without calling an LLM.")
743749
.argument("<query>", "Question to answer.")
744750
.option("-k, --top-k <number>", "Number of passages to use.", parsePositiveInt)
751+
.option(
752+
"--max-chunks-per-document <number>",
753+
"Maximum primary passages per document before ranked backfill.",
754+
parsePositiveInt,
755+
)
745756
.option(
746757
"--context-radius <number>",
747758
"Include neighboring chunks around each matched passage.",
@@ -773,6 +784,7 @@ program
773784
query: string,
774785
options: {
775786
topK?: number
787+
maxChunksPerDocument?: number
776788
contextRadius?: number
777789
includePath: string[]
778790
excludePath: string[]
@@ -1919,6 +1931,7 @@ function withSearchOptions(
19191931
cwd: string,
19201932
options: {
19211933
topK?: number
1934+
maxChunksPerDocument?: number
19221935
contextRadius?: number
19231936
includePath?: string[]
19241937
excludePath?: string[]
@@ -1929,6 +1942,7 @@ function withSearchOptions(
19291942
): {
19301943
cwd: string
19311944
topK?: number
1945+
maxChunksPerDocument?: number
19321946
contextRadius?: number
19331947
includePaths?: string[]
19341948
excludePaths?: string[]
@@ -1939,6 +1953,7 @@ function withSearchOptions(
19391953
const result: {
19401954
cwd: string
19411955
topK?: number
1956+
maxChunksPerDocument?: number
19421957
contextRadius?: number
19431958
includePaths?: string[]
19441959
excludePaths?: string[]
@@ -1947,6 +1962,7 @@ function withSearchOptions(
19471962
vectorSearchMode?: "exact"
19481963
} = { cwd }
19491964
addOption(result, "topK", options.topK)
1965+
addOption(result, "maxChunksPerDocument", options.maxChunksPerDocument)
19501966
addOption(result, "contextRadius", options.contextRadius)
19511967
addPathFilters(result, options)
19521968
addOption(result, "explain", options.explain)

packages/ragmir-core/src/config.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ describe("loadConfig", () => {
4545
expect(config.accessLog).toBe(true)
4646
expect(config.mcpMaxTopK).toBe(10)
4747
expect(config.mcpMaxOutputBytes).toBe(32_768)
48+
expect(config.maxChunksPerDocument).toBe(1)
4849
expect(config.workloadLimits).toEqual({
4950
search: { concurrency: 8, maxQueue: 64, queueTimeoutMs: 30_000 },
5051
embedding: { concurrency: 1, maxQueue: 64, queueTimeoutMs: 30_000 },
@@ -538,10 +539,20 @@ describe("loadConfig", () => {
538539

539540
await writeFile(
540541
path.join(root, ".ragmir", "config.json"),
541-
JSON.stringify({ retrievalProfile: "fast", topK: 7, hybridTextScanLimit: 3_000 }),
542+
JSON.stringify({
543+
retrievalProfile: "fast",
544+
topK: 7,
545+
maxChunksPerDocument: 2,
546+
hybridTextScanLimit: 3_000,
547+
}),
542548
)
543549
expect(await loadConfig(root)).toEqual(
544-
expect.objectContaining({ retrievalProfile: "fast", topK: 7, hybridTextScanLimit: 3_000 }),
550+
expect.objectContaining({
551+
retrievalProfile: "fast",
552+
topK: 7,
553+
maxChunksPerDocument: 2,
554+
hybridTextScanLimit: 3_000,
555+
}),
545556
)
546557
})
547558

@@ -560,6 +571,9 @@ describe("loadConfig", () => {
560571
await writeFile(configPath, JSON.stringify({ topK: 101 }))
561572
await expect(loadConfig(root)).rejects.toThrow(/topK.*at most/i)
562573

574+
await writeFile(configPath, JSON.stringify({ maxChunksPerDocument: 101 }))
575+
await expect(loadConfig(root)).rejects.toThrow(/maxChunksPerDocument.*at most/i)
576+
563577
await writeFile(configPath, JSON.stringify({ mcpMaxTopK: 101 }))
564578
await expect(loadConfig(root)).rejects.toThrow(/mcpMaxTopK.*at most/i)
565579

0 commit comments

Comments
 (0)