diff --git a/AGENTS.md b/AGENTS.md
index f9c63506..0b1caf7f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -59,6 +59,7 @@
## Validation
- Run the smallest relevant check while editing and `pnpm validate` before a release pull request.
+- Keep `pnpm knip` clean: delete unused files, exports, and dependencies instead of ignoring them.
- Reconcile `git status` with the intended scope before staging. Never stage secrets or generated local state.
- Use Conventional Commits. Commit and push only when the user authorizes them.
- Every commit promoted to `main` that can trigger semantic-release must include these exact body
diff --git a/CLAUDE.md b/CLAUDE.md
index 30564c72..53503cb6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,6 +5,7 @@
```bash
pnpm bootstrap
pnpm validate
+pnpm knip
pnpm dev:landing
pnpm example
```
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4245a0c8..77174770 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -50,9 +50,13 @@ Before opening a pull request, run:
pnpm validate
```
-`pnpm validate` runs Biome, a dependency security audit, TypeScript checks, coverage-gated Vitest,
-production CLI and MCP smoke tests, the static landing build, public API checks, and npm package and
-release-artifact checks.
+`pnpm validate` runs Biome, Knip, a dependency security audit, TypeScript checks, coverage-gated
+Vitest, production CLI and MCP smoke tests, the static landing build, public API checks, and npm
+package and release-artifact checks.
+
+`pnpm knip` reports unused files, exports, dependencies, and duplicate exports across the workspace.
+Remove what it finds rather than ignoring it. Tag an export `@public` only when a benchmark or
+script imports it from the build, and `@alias` only for a deliberate public alias.
To smoke-test the library API against your local build while developing Ragmir Core, run
`pnpm example` (see
diff --git a/README.md b/README.md
index 0267d079..65fb0b75 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,8 @@
- 93% of real developer questions find the right file in the top 10 · 40 ms median search on a
- 43,874-chunk monorepo · benchmarks
+ 93% of real developer questions find the right file in the top 10 · 39 ms median search on a
+ 29,778-chunk monorepo · benchmarks
@@ -56,12 +56,18 @@ npx rgr search "how are permissions checked?" --compact
- **Code and documents in one index.** Hybrid BM25 and vector search understands paths and
camelCase identifiers and boosts the code that defines what you ask about, so "how are
permissions checked" reaches `PermissionService.java`, not only its tests and notes.
-- **Fast on real repositories.** Median search takes 40 ms on a 43,874-chunk Java and TypeScript
+- **Fast on real repositories.** Median search takes 39 ms on a 29,778-chunk Java and TypeScript
monorepo with Office files, and the right file reaches the top 10 for 93% of 101 real developer
- questions (87% with 6.1.6).
+ questions.
+- **Ahead of the usual RAG stacks.** On this repository, the right file reaches the top 3 for 76%
+ of developer questions, against 56% for a LangChain.js pipeline and 70% for MiniSearch BM25 with
+ the same embedding model, and Ragmir refuses 27 of 30 off-topic questions where they refuse at
+ most 3.
- **Evidence you can verify.** Citations point to source lines, PDF pages, slides, sheet cells, or
EPUB sections. Results say when their file changed after indexing (`sourceStatus`) and when
- their support is thin (`weakEvidence`); unrelated questions return nothing instead of noise.
+ their support is thin (`weakEvidence`). Questions your sources cannot answer return nothing
+ instead of noise: 58 of 90 off-topic and 24 of 90 near-domain questions on real bases, up from
+ 41 and 1.
- **Always current.** `rgr doctor` compares the files on disk with the index on every run, and
`rgr hooks install` refreshes it in the background after commits, merges, and checkouts.
- **Private by default.** No account, telemetry, or hosted storage. The default `local-hash`
@@ -180,25 +186,50 @@ use the same retrieval API without one.
## Benchmarks
-Ragmir 6.2 against 6.1.6 on the same machine, configuration, and index, rebuilt by 6.2. Each
-answerable question has one expected file; "top 3" and "top 10" count the questions whose file
-appears there. Unrelated questions (nonsense, weather, recipes, sports) should return nothing.
-Latency is the median of warm sequential searches in one process.
-
-| Corpus | Embeddings | Answer in top 3 | Answer in top 10 | nDCG@10 | Unrelated questions refused | Median search |
-| --- | --- | ---: | ---: | ---: | ---: | ---: |
-| Java/TypeScript monorepo with Office files and scans: 2,117 files, 43,874 chunks, 101 developer questions | multilingual-e5-small | 70% → **73%** | 87% → **93%** | 0.639 → **0.670** | 0 → **3** of 30 | 31 → 40 ms |
-| French and English product documentation: 298 files, 20,931 chunks, 60 questions | mxbai-embed-xsmall | 100% → **100%** | 100% → **100%** | 0.932 → **0.938** | 3 → **15** of 30 | 25 → 31 ms |
-
-Measured on October 27, 2026 on an Apple M3 Max with Node.js 22.23 and LanceDB 0.30, with the
-`quality` profile these projects use and 30 unrelated questions per corpus. The two private corpora
-are described rather than published. Of the passages 6.2 still returned for unrelated questions,
-it flagged 41 of 81 on the monorepo and 37 of 38 on the documentation as weak evidence, against
-7 of 94 and 0 of 60 correct answers. On the monorepo it also applied six exclusion rules that
-6.1.6 silently missed on accented file names and cited 27 decomposed paths in canonical form. The
-extra milliseconds pay for word statistics, code-definition boosts, and crowding checks. Run the
-[benchmarks](./packages/ragmir-core/benchmarks/README.md) and `rgr evaluate` on your own corpus
-before relying on any number.
+### Real projects
+
+Ragmir 6.4 against 6.3.1 on the same machine, configuration, and indexes. Each answerable question
+lists the files that answer it; "top 3" and "top 10" count the questions with one of them there.
+Off-topic questions (sports, cooking, weather, nonsense) and near-domain questions about features
+the project does not have should return nothing. Latency is the median of warm sequential searches
+in one process.
+
+| Corpus | Embeddings | Answer in top 3 | Answer in top 10 | Off-topic refused | Near-domain refused | Correct answers flagged weak | Median search |
+| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| Java/TypeScript monorepo with Office files and scans: 2,297 files, 29,778 chunks, 101 developer questions | multilingual-e5-base | 74% → **74%** | 93% → **93%** | 0 → **16** of 30 | 1 → **11** of 30 | 11 → **8** of 94 | 39 → 39 ms |
+| French and English product documentation: 299 files, 21,011 chunks, 60 questions | mxbai-embed-xsmall | 100% → **100%** | 100% → **100%** | 16 → **17** of 30 | 0 → **6** of 30 | 0 → **0** of 60 | 28 → 28 ms |
+| This repository: 229 files, 2,490 chunks, 99 developer questions | mxbai-embed-xsmall | 75% → **75%** | 97% → **97%** | 25 → **25** of 30 | 0 → **7** of 30 | 32 → **6** of 96 | 22 → 22 ms |
+
+Measured on November 1, 2026 on an Apple M3 Max with Node.js 26 and LanceDB 0.30, with the
+`quality` profile these projects use. Near-domain questions pair the project's own vocabulary with
+an integration, service, or format it does not have, and each was checked absent from every
+indexed file. 6.4 kept every answer in place and caught them by measuring each index's noise
+floor: a passage only counts as a strong vector match when it is nearer than 24 off-topic probe
+questions, and a search whose first five results are all weak answers nothing. The two private
+corpora are described rather than published. Run the [benchmarks](./packages/ragmir-core/benchmarks/README.md)
+and `rgr evaluate` on your own corpus before relying on any number.
+
+### Against other retrieval stacks
+
+`pnpm bench:competitors` indexes the 210 tracked documentation and source files of this
+repository and asks its 99 developer questions plus 30 off-topic and 30 near-domain questions. Every
+vector side uses the same model and vectors (`mxbai-embed-xsmall-v1`); the other stacks read the same
+files split by LangChain's `RecursiveCharacterTextSplitter` (1,000 characters, 200 overlap).
+
+| System | Answer in top 3 | Answer in top 10 | MRR@10 | Off-topic refused | Near-domain refused | Median search |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| **Ragmir**, hybrid with `mxbai-embed-xsmall-v1` | **76%** | **97%** | **0.647** | **27** of 30 | **5** of 30 | 22 ms |
+| **Ragmir**, `local-hash` without a model | **79%** | 96% | 0.611 | 9 of 30 | 0 of 30 | 20 ms |
+| LangChain.js splitter with exact cosine search, as `MemoryVectorStore` | 56% | 89% | 0.509 | 0 of 30 | 0 of 30 | 5 ms |
+| Orama 3 hybrid, default similarity 0.8 | 18% | 35% | 0.144 | 2 of 30 | 0 of 30 | 5 ms |
+| Orama 3 hybrid, similarity 0.1 | 38% | 64% | 0.317 | 0 of 30 | 0 of 30 | 6 ms |
+| MiniSearch 7 BM25 | 70% | 92% | 0.566 | 3 of 30 | 0 of 30 | 1 ms |
+
+In-memory libraries answer faster, but they return results for almost every off-topic question,
+and none ranks the answering file as high: it reaches the top 3 for 76 to 79% of questions with Ragmir,
+against 56% with the LangChain.js pipeline and 70% with MiniSearch. Measured on the same machine on
+November 1, 2026; [the benchmark guide](./packages/ragmir-core/benchmarks/README.md) explains every
+setting.
## TypeScript
diff --git a/docs/agent-integration.md b/docs/agent-integration.md
index 1dc1298f..1921a344 100644
--- a/docs/agent-integration.md
+++ b/docs/agent-integration.md
@@ -134,7 +134,8 @@ questions appears once. Within one MCP session, passages already returned come b
Results flag two conditions. `sourceStatus: "modified"` or `"missing"` means the cited file changed
after indexing; with `mcpAutoIngest: true`, the server then starts one background incremental
ingest (at most once a minute). `weakEvidence: true` means the passage holds few of the question's
-distinctive words and has no strong semantic match.
+distinctive words and has no strong semantic match. An empty answer means the base holds no
+evidence for the question: say so instead of answering from the nearest passages.
The tools accept common argument spellings (`top_k`, `radius`, `max_bytes`, `paths`, `q`) and
clamp out-of-range numbers instead of failing; the response notes each adjustment and lists ignored
diff --git a/docs/api-reference.md b/docs/api-reference.md
index dbb31a8a..ecc36ee2 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -71,7 +71,8 @@ It identifies indexed evidence, not the current file on disk. Each result whose
disappeared since indexing also carries `sourceStatus: "modified"` or `"missing"`, from one `stat`
per cited file; `doctor` checks the whole corpus by size and modification time, and `audit` by
checksum. `weakEvidence: true` marks a passage with few of the question's distinctive words and
-no strong vector match. Compact results retain these fields.
+no strong vector match; a Transformers search whose first five results are all weak returns an
+empty list instead. Compact results retain these fields.
For XLSX tables, full results attach the header as a separate `context` passage with its own cell
citation, including at `contextRadius: 0`. Expansion also includes it; compact search requires
@@ -119,6 +120,8 @@ it excludes paths, source text, and queries. No telemetry is transmitted.
Operations accepting `OperationOptions` support `signal` and `timeoutMs`. The library returns
values or throws errors; it does not write to stdout/stderr. Use `isRagmirError(error)` to inspect
structured codes such as `TIMEOUT`, `OVERLOADED`, `INDEX_BUSY`, `INDEX_UNAVAILABLE`, and `CLIENT_CLOSED`.
+A config that still holds retired fields throws `CONFIG_MIGRATION_REQUIRED`: run `rgr upgrade` to
+back it up, migrate it, and rebuild the index.
Per-project process-local queues bound search, embeddings, and ingestion. A private writer lock
serializes index writers across local OS processes. Readers keep generation leases through their
diff --git a/docs/cli-reference.md b/docs/cli-reference.md
index 6c4da57b..485a4915 100644
--- a/docs/cli-reference.md
+++ b/docs/cli-reference.md
@@ -46,8 +46,9 @@ rgr search "migration" --exact-vector-search
Text output marks a result whose file changed or disappeared after indexing
(`source modified since indexing`) and a result with thin support (`weak evidence`). JSON results
-carry the same information as `sourceStatus` and `weakEvidence`. `--scope` applies a named scope
-from the config; `--include-path` accepts backslashes on every platform.
+carry the same information as `sourceStatus` and `weakEvidence`. A semantic search whose first five
+results are all weak prints no result: the base holds no evidence for the question. `--scope`
+applies a named scope from the config; `--include-path` accepts backslashes on every platform.
`sources add` accepts paths, globs, and `!` exclusions. Search accepts `--top-k`,
`--include-path`, `--exclude-path`, and repeatable `--context-path`. Search accepts
@@ -202,7 +203,9 @@ doctor and `rgr audit` label their O(corpus) cost in text and JSON output. A mis
manifest always yields `ready=false`, including legacy tables that predate manifest activation.
`readiness.lexicalIndexCurrent` is `false` for an index built under an older keyword policy, such as
one without accent-folded terms or path and identifier terms. Search keeps using that index; the
-next `rgr ingest` or `rgr upgrade` stages and validates a rebuild before activating it.
+next `rgr ingest` or `rgr upgrade` stages and validates a rebuild before activating it. Its next
+steps also ask for `rgr storage optimize` when the index is split into many small fragments, and for
+`rgr ingest` when a semantic index has no stored noise floor yet.
`rgr ingest --rebuild` writes batches into an isolated LanceDB generation. The existing index stays
active until the new table and manifest pass row-count, checksum, and duplicate-ID validation. The
diff --git a/docs/configuration.md b/docs/configuration.md
index bbc4ead2..64f6692a 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -149,11 +149,20 @@ distinctive word counts more than a common one:
`mxbai-embed-xsmall-v1` uses 1.1. Other models keep the previous rule: any related query word,
or a distance up to 1.1.
+Every Transformers index also measures its own noise floor: `rgr ingest` embeds 24 fixed off-topic
+probe questions (sports, cooking, geography, nonsense, in English and French) and stores the
+distance of the nearest passage any of them reaches. A model packs texts into its own band and a
+corpus shifts it, so only a passage nearer than every off-topic probe counts as a strong vector
+match, and a question with no known word needs one. An index built before this release measures
+the floor once per process on its first search; the next `rgr ingest` stores it.
+
Each result whose passage holds few of the question's distinctive words and has no strong vector
-match carries `weakEvidence: true`. It is still returned; agents should verify it first. On a
-French and English golden set over a Java and TypeScript monorepo, these rules kept every
-answerable question's source, and with `mxbai-embed-xsmall-v1` they rejected 16 of 30 unrelated
-questions instead of 3. Evaluate your own corpus with `rgr evaluate`.
+match carries `weakEvidence: true`. When the first five results of a Transformers search are all
+weak, the answer holds no real evidence and search returns nothing. `local-hash` search keeps such
+results. On a Java and TypeScript monorepo, a product documentation base, and this repository
+(260 answerable questions), these rules kept every answer in place while refusing 58 of 90
+off-topic and 24 of 90 near-domain unanswerable questions, against 41 and 1 before, and flagged
+14 correct answers as weak instead of 43. Evaluate your own corpus with `rgr evaluate`.
Changing an embedding provider, model, revision, digest, or chunking field requires
`rgr ingest --rebuild`. Revision and artifact digest participate in the index, vector-index, and
diff --git a/docs/migration.md b/docs/migration.md
index beeb6336..b8e51995 100644
--- a/docs/migration.md
+++ b/docs/migration.md
@@ -3,6 +3,22 @@
This is a breaking surface change. Ragmir now concentrates on local indexing, search, exact
citations, OCR, and integration with developer agents.
+## Index noise floor and weak answers (6.4)
+
+Existing indexes stay searchable after the update; nothing needs deleting.
+
+- **Ranking policy version 8.** Transformers search caps strong vector evidence with each index's
+ noise floor and answers nothing when its first five results are all `weakEvidence`.
+ `mxbai-embed-xsmall-v1` flags a passage as weak below 30% of the question's IDF-weighted words
+ instead of 50%. Rerun `rgr evaluate`: stored quality reports no longer verify the active policy.
+- **Noise floor.** `rgr ingest` stores the floor in the index manifest. An older index measures it
+ once per process on its first search until the next `rgr ingest`.
+- **Typed migration error.** A config with retired fields now throws `CONFIG_MIGRATION_REQUIRED`
+ instead of an internal error. Run `rgr upgrade`.
+- **Fragmented indexes.** `rgr doctor` asks for `rgr storage optimize` when an index is split into
+ many small fragments, which slowed searches up to five times on indexes built before automatic
+ compaction.
+
## Canonical paths, live freshness, and calibrated evidence (6.2)
Existing indexes stay searchable after the update; nothing needs deleting.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 992a91ec..1ba24192 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -94,7 +94,9 @@ need explicitly configured local extractors.
## Search is weak
Results marked `weakEvidence` hold few of the question's distinctive words and have no strong
-semantic match: rephrase with the project's own terms or narrow the search with a `scope`. When one
+semantic match: rephrase with the project's own terms or narrow the search with a `scope`. A
+semantic search returns nothing when its first five results are all weak; a rephrased question
+with the project's vocabulary usually finds the evidence if it exists. When one
export or log dominates results, `rgr audit` reports index concentration; exclude it, lower it
with `sourceWeights`, or cap it with `maxChunksPerDataFile`.
diff --git a/knip.json b/knip.json
new file mode 100644
index 00000000..b9f8e29b
--- /dev/null
+++ b/knip.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://unpkg.com/knip@6/schema.json",
+ "ignoreExportsUsedInFile": {
+ "interface": true,
+ "type": true
+ },
+ "ignoreBinaries": ["mise", "textutil"],
+ "workspaces": {
+ ".": {
+ "entry": ["scripts/*.mjs", "tests/public-api-consumer/consumer.ts"],
+ "project": ["scripts/**/*.mjs", "tests/**/*.ts"]
+ },
+ "packages/ragmir-core": {
+ "entry": ["benchmarks/*.mjs", "scripts/*.mjs", "examples/**/*.{mjs,ts}"],
+ "project": ["src/**/*.ts", "benchmarks/**/*.mjs", "scripts/**/*.mjs"]
+ },
+ "packages/ragmir-landing": {
+ "ignoreDependencies": ["@astrojs/check"]
+ }
+ }
+}
diff --git a/llms.txt b/llms.txt
index 8ee63e55..99e8c384 100644
--- a/llms.txt
+++ b/llms.txt
@@ -31,8 +31,9 @@ the query. It owns reasoning, generation, and actions, with the model chosen by
session as short stubs. Instructions name the base; unrelated workspaces get a scope notice, or
a refusal with `mcpAllowedRoots`. Register Ragmir per project, not for every project.
- Results carry `sourceStatus` when the cited file changed after indexing and `weakEvidence` when
- support is thin. `rgr doctor` checks freshness on every run; `rgr hooks install` refreshes the
- index after Git operations.
+ support is thin. An empty semantic answer means the base holds no evidence for the question.
+ `rgr doctor` checks freshness on every run; `rgr hooks install` refreshes the index after Git
+ operations.
- Agents search, expand selected evidence, and refine queries. Ragmir does not run the agent loop.
- Present four workflow examples: build a feature, diagnose an incident, and plan a migration with
Claude Code or Codex using cited project evidence; then make a confidential architecture decision
diff --git a/package.json b/package.json
index 654aaa81..cdf721d7 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"api:check": "tsc -p tests/public-api-consumer/tsconfig.json",
"bootstrap": "mise install && pnpm install",
"bench:compare": "pnpm --filter @jcode.labs/ragmir bench:compare",
+ "bench:competitors": "pnpm --filter @jcode.labs/ragmir bench:competitors",
"bench:cli-startup": "pnpm --filter @jcode.labs/ragmir bench:cli-startup",
"bench:discovery": "pnpm --filter @jcode.labs/ragmir bench:discovery",
"bench:ingest-memory": "pnpm --filter @jcode.labs/ragmir bench:ingest-memory",
@@ -61,6 +62,7 @@
"example": "pnpm --filter @jcode.labs/ragmir build && node packages/ragmir-core/examples/library-api-demo/run.mjs",
"format": "biome format --write .",
"lint": "biome ci .",
+ "knip": "knip --no-progress",
"lint:fix": "biome check --write .",
"license:check": "node scripts/license-policy-smoke.mjs",
"offline:smoke": "node scripts/offline-install-smoke.mjs",
@@ -72,7 +74,7 @@
"smoke": "pnpm public:smoke && node scripts/smoke.mjs && pnpm --filter @jcode.labs/ragmir mcp:smoke",
"test": "pnpm --filter @jcode.labs/ragmir-landing test && pnpm --filter @jcode.labs/ragmir test",
"test:coverage": "pnpm --filter @jcode.labs/ragmir-landing test:coverage && pnpm --filter @jcode.labs/ragmir test:coverage",
- "validate": "pnpm lint && pnpm audit:security && pnpm check && pnpm test:coverage && pnpm build && pnpm api:check && pnpm smoke && pnpm package:check && pnpm release:semantic:smoke && pnpm release:artifacts",
+ "validate": "pnpm lint && pnpm knip && pnpm audit:security && pnpm check && pnpm test:coverage && pnpm build && pnpm api:check && pnpm smoke && pnpm package:check && pnpm release:semantic:smoke && pnpm release:artifacts",
"bench:developer-evidence": "pnpm --filter @jcode.labs/ragmir bench:developer-evidence"
},
"devDependencies": {
@@ -81,7 +83,9 @@
"@commitlint/config-conventional": "^21.2.4",
"@semantic-release/exec": "^7.1.0",
"@vitest/coverage-v8": "5.0.5",
+ "knip": "^6.44.1",
"semantic-release": "^25.0.9",
+ "typescript": "^7.0.2",
"yaml": "^2.9.1"
}
}
diff --git a/packages/ragmir-core/README.md b/packages/ragmir-core/README.md
index 3b4f4842..81fe5e0a 100644
--- a/packages/ragmir-core/README.md
+++ b/packages/ragmir-core/README.md
@@ -11,8 +11,8 @@
- 93% of real developer questions find the right file in the top 10 · 40 ms median search on a
- 43,874-chunk monorepo · benchmarks
+ 93% of real developer questions find the right file in the top 10 · 39 ms median search on a
+ 29,778-chunk monorepo · benchmarks
@@ -31,12 +31,14 @@ model you choose. Ragmir does not bundle a chat model or host your documents.
- **Code and documents in one index:** hybrid BM25 and vector search that understands paths and
camelCase identifiers and boosts the code that defines what you ask about.
-- **Fast on real repositories:** 40 ms median search on a 43,874-chunk monorepo, with the right
+- **Fast on real repositories:** 39 ms median search on a 29,778-chunk monorepo, with the right
source in the top 10 for 93% of real developer questions
([benchmarks](https://github.com/jcode-works/jcode-ragmir#benchmarks)).
+- **Ahead of the usual RAG stacks:** the right file reaches the top 3 for 76% of this repository's
+ developer questions, against 56% for a LangChain.js pipeline and 70% for MiniSearch BM25.
- **Evidence you can verify:** citations point to source lines or native page, slide, sheet/cell,
and EPUB coordinates. Results flag changed files (`sourceStatus`) and thin support
- (`weakEvidence`); unrelated questions return nothing.
+ (`weakEvidence`); questions your sources cannot answer return nothing instead of noise.
- **Always current:** `rgr doctor` checks the files on disk on every run, and `rgr hooks install`
refreshes the index after commits, merges, and checkouts.
- **Private and portable:** offline by default, with no account or telemetry, and the same paths,
diff --git a/packages/ragmir-core/benchmarks/README.md b/packages/ragmir-core/benchmarks/README.md
index 9609c9cc..7622a68f 100644
--- a/packages/ragmir-core/benchmarks/README.md
+++ b/packages/ragmir-core/benchmarks/README.md
@@ -17,6 +17,7 @@ Generated corpora, model caches, and raw results stay outside Git.
| `pnpm bench:observability` | Diagnostic completeness, privacy, and disabled-path cost |
| `pnpm bench:parsers [-- --stress]` | Office, EPUB, PDF throughput, memory, citations, and malformed input |
| `pnpm bench:compare -- --baseline baseline.json --current current.json` | Comparable result regression |
+| `pnpm bench:competitors [-- --model-path /path/to/models]` | Ragmir against LangChain.js, Orama, and MiniSearch on this repository |
Additional root scripts cover CLI startup, discovery, ingestion memory, metadata, local-hash
runtime, storage maintenance, status, runtime reuse, OCR cache, generation retention, concurrency,
@@ -35,6 +36,14 @@ and explicit reranker, compression, hashing, and content-dedup experiments.
complete corpus independently, with a one-chunk document cap and no calibrated abstention.
The gate covers exact references, long-query preservation, and spreadsheet header retrieval.
Other outcomes are reported, not presented as representative developer-task success rates.
+- `bench:competitors` indexes the tracked documentation and source of this repository at the
+ current commit and asks the questions in `fixtures/repository-questions.json`: 99 developer
+ questions with every file that answers them, 30 off-topic questions, and 30 near-domain
+ questions about features Ragmir does not have. The other stacks read the same files split by
+ LangChain's `RecursiveCharacterTextSplitter` (1,000 characters, 200 overlap), and every vector
+ side uses Ragmir's embedding function, so models, prefixes, and vectors are identical. Orama
+ runs with its default 0.8 hybrid similarity and with 0.1, which lets its vector side rank. A
+ question counts as answered when one of its files appears; a refusal is an empty result.
- `bench:compare` recognizes scale and quality reports separately. It validates both quality runs,
their absolute gates, and explicit workload versions. Missing identities or metrics are invalid,
incompatible workloads are inconclusive, and only complete comparable reports can pass.
diff --git a/packages/ragmir-core/benchmarks/cli-startup.mjs b/packages/ragmir-core/benchmarks/cli-startup.mjs
index d00d6281..369a0e13 100644
--- a/packages/ragmir-core/benchmarks/cli-startup.mjs
+++ b/packages/ragmir-core/benchmarks/cli-startup.mjs
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"
import path from "node:path"
import { performance } from "node:perf_hooks"
import { fileURLToPath } from "node:url"
+import { percentile } from "./lib/metrics.mjs"
const benchmarkRoot = path.dirname(fileURLToPath(import.meta.url))
const packageRoot = path.resolve(benchmarkRoot, "..")
@@ -88,10 +89,6 @@ function measureCli(arguments_) {
}
}
-function percentile(sorted, quantile) {
- return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * quantile) - 1)]
-}
-
function positiveInteger(value) {
const parsed = Number.parseInt(value, 10)
if (!Number.isInteger(parsed) || parsed <= 0) {
diff --git a/packages/ragmir-core/benchmarks/competitors.mjs b/packages/ragmir-core/benchmarks/competitors.mjs
new file mode 100644
index 00000000..b312ccb8
--- /dev/null
+++ b/packages/ragmir-core/benchmarks/competitors.mjs
@@ -0,0 +1,310 @@
+// Compare Ragmir with popular local TypeScript retrieval stacks on the public Ragmir repository:
+// the same tracked files, the same questions, and, for every vector side, the same embedding
+// model and vectors. Writes benchmarks/.results/competitors.json and prints a Markdown table.
+import { execFileSync } from "node:child_process"
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
+import os from "node:os"
+import path from "node:path"
+import { performance } from "node:perf_hooks"
+import { fileURLToPath } from "node:url"
+import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters"
+import { create, insertMultiple, search as searchOrama } from "@orama/orama"
+import MiniSearch from "minisearch"
+import picomatch from "picomatch"
+import { DEFAULT_CONFIG } from "../dist/defaults.js"
+import { embedTexts } from "../dist/embeddings.js"
+import { createRagmirClient, initProject, loadConfig } from "../dist/index.js"
+import { environmentMetadata } from "./lib/metrics.mjs"
+
+const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
+const repositoryRoot = path.resolve(packageRoot, "../..")
+const options = parseArguments(process.argv.slice(2))
+const model = options.model ?? "mixedbread-ai/mxbai-embed-xsmall-v1"
+const revision = options.revision ?? "e6ac24e5d6efb8782b59de1647b3ececb4ece94e"
+const modelPath = path.resolve(options.modelPath ?? path.join(repositoryRoot, ".ragmir/models"))
+const resultPath = path.resolve(
+ options.result ?? path.join(packageRoot, "benchmarks/.results/competitors.json"),
+)
+const TOP_K = 10
+// The documentation and source a developer agent asks about, tracked in Git.
+const SOURCES = [
+ "README.md",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "CHANGELOG.md",
+ "CONTRIBUTING.md",
+ "RELEASING.md",
+ "SECURITY.md",
+ "SECURITY-HARDENING.md",
+ "llms.txt",
+ "package.json",
+ "docs/**/*.md",
+ "packages/*/README.md",
+ "packages/*/package.json",
+ "packages/ragmir-core/src/**/*.ts",
+ "packages/ragmir-core/skills/**/*.md",
+ "packages/ragmir-core/benchmarks/README.md",
+ "packages/ragmir-landing/src/**/*.{astro,ts,tsx}",
+ "packages/ragmir-landing/messages/*.json",
+ "scripts/*.mjs",
+]
+
+const questions = JSON.parse(
+ await readFile(path.join(packageRoot, "benchmarks/fixtures/repository-questions.json"), "utf8"),
+)
+const isSource = picomatch(SOURCES, { dot: true })
+const files = execFileSync("git", ["ls-files", "-z"], { cwd: repositoryRoot, encoding: "utf8" })
+ .split("\0")
+ .filter((file) => file && isSource(file))
+ .sort()
+const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repositoryRoot, encoding: "utf8" }).trim()
+
+const workspace = await mkdtemp(path.join(os.tmpdir(), "ragmir-competitors-"))
+const systems = []
+try {
+ for (const file of files) {
+ const target = path.join(workspace, file)
+ await mkdir(path.dirname(target), { recursive: true })
+ await writeFile(target, await readFile(path.join(repositoryRoot, file)))
+ }
+ await initProject(workspace)
+ const semanticConfig = {
+ ...DEFAULT_CONFIG,
+ sources: SOURCES,
+ embeddingProvider: "transformers",
+ embeddingModel: model,
+ embeddingModelRevision: revision,
+ embeddingModelPath: modelPath,
+ retrievalProfile: "quality",
+ }
+
+ for (const provider of ["transformers", "local-hash"]) {
+ const config =
+ provider === "transformers"
+ ? semanticConfig
+ : { ...DEFAULT_CONFIG, sources: SOURCES, retrievalProfile: "quality" }
+ await writeFile(path.join(workspace, ".ragmir/config.json"), JSON.stringify(config))
+ const client = await createRagmirClient({ cwd: workspace })
+ try {
+ const startedAt = performance.now()
+ const ingest = await client.ingest({ rebuild: true })
+ if (ingest.errors.length > 0) throw new Error(JSON.stringify(ingest.errors))
+ const indexMs = performance.now() - startedAt
+ systems.push(
+ await measure(
+ provider === "transformers"
+ ? `Ragmir (hybrid, ${shortModel(model)})`
+ : "Ragmir (local-hash, no model)",
+ indexMs,
+ async (query) =>
+ (await client.search(query, { topK: TOP_K })).map((result) => result.relativePath),
+ ),
+ )
+ } finally {
+ await client.close()
+ }
+ }
+
+ // Every other stack reads the same files, split by LangChain's recommended splitter, and the
+ // vector stacks reuse Ragmir's embedding function: identical model, prefixes, and vectors.
+ await writeFile(path.join(workspace, ".ragmir/config.json"), JSON.stringify(semanticConfig))
+ const embeddingConfig = await loadConfig(workspace)
+ const splitter = new RecursiveCharacterTextSplitter({ chunkSize: 1000, chunkOverlap: 200 })
+ const splitStartedAt = performance.now()
+ const chunks = []
+ for (const file of files) {
+ const text = await readFile(path.join(repositoryRoot, file), "utf8")
+ for (const piece of await splitter.splitText(text)) {
+ chunks.push({ id: String(chunks.length), path: file, text: piece })
+ }
+ }
+ const splitMs = performance.now() - splitStartedAt
+ const embedStartedAt = performance.now()
+ const vectors = []
+ for (let offset = 0; offset < chunks.length; offset += 64) {
+ vectors.push(
+ ...(await embedTexts(
+ chunks.slice(offset, offset + 64).map((chunk) => chunk.text),
+ embeddingConfig,
+ "document",
+ )),
+ )
+ }
+ const embedMs = performance.now() - embedStartedAt
+ const embedQuery = async (query) => (await embedTexts([query], embeddingConfig, "query"))[0]
+
+ // LangChain's MemoryVectorStore ranks by exact cosine similarity over every stored vector.
+ systems.push(
+ await measure("LangChain.js splitter + MemoryVectorStore-style cosine", splitMs + embedMs, async (query) => {
+ const queryVector = await embedQuery(query)
+ return vectors
+ .map((vector, index) => ({ index, score: cosine(queryVector, vector) }))
+ .sort((left, right) => right.score - left.score)
+ .slice(0, TOP_K)
+ .map(({ index }) => chunks[index].path)
+ }),
+ )
+
+ const oramaStartedAt = performance.now()
+ const orama = create({
+ schema: { path: "string", text: "string", embedding: `vector[${vectors[0].length}]` },
+ })
+ await insertMultiple(
+ orama,
+ chunks.map((chunk, index) => ({ path: chunk.path, text: chunk.text, embedding: vectors[index] })),
+ )
+ const oramaMs = splitMs + embedMs + (performance.now() - oramaStartedAt)
+ // Orama's default 0.8 similarity drops most vector hits of this model, so both its default and
+ // a 0.1 threshold that lets the vector side rank are reported.
+ for (const similarity of [0.8, 0.1]) {
+ systems.push(
+ await measure(`Orama hybrid (similarity ${similarity})`, oramaMs, async (query) => {
+ const result = await searchOrama(orama, {
+ mode: "hybrid",
+ term: query,
+ vector: { value: await embedQuery(query), property: "embedding" },
+ similarity,
+ limit: TOP_K,
+ })
+ return result.hits.map((hit) => hit.document.path)
+ }),
+ )
+ }
+
+ const miniStartedAt = performance.now()
+ const minisearch = new MiniSearch({ fields: ["text"], storeFields: ["path"] })
+ minisearch.addAll(chunks)
+ const miniMs = splitMs + (performance.now() - miniStartedAt)
+ systems.push(
+ await measure("MiniSearch BM25", miniMs, async (query) =>
+ minisearch
+ .search(query)
+ .slice(0, TOP_K)
+ .map((hit) => hit.path),
+ ),
+ )
+
+ const report = {
+ schemaVersion: 1,
+ createdAt: new Date().toISOString(),
+ commit,
+ environment: await environmentMetadata(),
+ corpus: { trackedFiles: files.length, langchainChunks: chunks.length },
+ model: { name: model, revision },
+ questions: {
+ answerable: questions.answerable.length,
+ unrelated: questions.unrelated.length,
+ nearDomain: questions.nearDomain.length,
+ },
+ systems,
+ }
+ await mkdir(path.dirname(resultPath), { recursive: true })
+ await writeFile(resultPath, `${JSON.stringify(report, null, 2)}\n`)
+ console.log(markdownTable(report))
+ console.log(`\nWrote ${path.relative(process.cwd(), resultPath)}`)
+} finally {
+ await rm(workspace, { recursive: true, force: true })
+}
+
+async function measure(name, indexMs, retrieve) {
+ for (const entry of questions.answerable.slice(0, 3)) await retrieve(entry.query)
+ const latencies = []
+ const timed = async (query) => {
+ const startedAt = performance.now()
+ const paths = await retrieve(query)
+ latencies.push(performance.now() - startedAt)
+ return paths
+ }
+ const ranking = []
+ for (const entry of questions.answerable) {
+ ranking.push(rankingMetrics(await timed(entry.query), entry.expectedPaths))
+ }
+ const refused = async (list) => {
+ let count = 0
+ for (const query of list) count += (await timed(query)).length === 0 ? 1 : 0
+ return count
+ }
+ const unrelatedRefused = await refused(questions.unrelated)
+ const nearDomainRefused = await refused(questions.nearDomain)
+ latencies.sort((left, right) => left - right)
+ const share = (key) => ranking.filter((entry) => entry[key]).length / ranking.length
+ const mean = (key) => ranking.reduce((sum, entry) => sum + entry[key], 0) / ranking.length
+ return {
+ name,
+ indexSeconds: round(indexMs / 1000, 1),
+ hitAt1: round(share("hit1")),
+ hitAt3: round(share("hit3")),
+ hitAt10: round(share("hit10")),
+ mrrAt10: round(mean("rr")),
+ ndcgAt10: round(mean("ndcg")),
+ unrelatedRefused,
+ nearDomainRefused,
+ medianMs: round(latencies[Math.floor(latencies.length / 2)], 1),
+ p95Ms: round(latencies[Math.floor(latencies.length * 0.95)], 1),
+ }
+}
+
+/** File-level metrics: a passage counts when its file answers the question; repeats count once. */
+function rankingMetrics(paths, expectedPaths) {
+ const expected = new Set(expectedPaths)
+ const seen = new Set()
+ let dcg = 0
+ let reciprocalRank = 0
+ for (const [index, file] of paths.slice(0, TOP_K).entries()) {
+ if (!expected.has(file) || seen.has(file)) continue
+ seen.add(file)
+ dcg += 1 / Math.log2(index + 2)
+ if (reciprocalRank === 0) reciprocalRank = 1 / (index + 1)
+ }
+ let ideal = 0
+ for (let index = 0; index < Math.min(expected.size, TOP_K); index += 1) ideal += 1 / Math.log2(index + 2)
+ const hit = (k) => paths.slice(0, k).some((file) => expected.has(file))
+ return { hit1: hit(1), hit3: hit(3), hit10: hit(TOP_K), rr: reciprocalRank, ndcg: dcg / ideal }
+}
+
+function cosine(left, right) {
+ let dot = 0
+ let leftNorm = 0
+ let rightNorm = 0
+ for (let index = 0; index < left.length; index += 1) {
+ dot += left[index] * right[index]
+ leftNorm += left[index] * left[index]
+ rightNorm += right[index] * right[index]
+ }
+ return dot / Math.sqrt(leftNorm * rightNorm)
+}
+
+function markdownTable(report) {
+ const percent = (value) => `${Math.round(value * 100)}%`
+ const rows = report.systems.map(
+ (system) =>
+ `| ${system.name} | ${percent(system.hitAt3)} | ${percent(system.hitAt10)} | ${system.mrrAt10} | ${system.unrelatedRefused}/${report.questions.unrelated} | ${system.nearDomainRefused}/${report.questions.nearDomain} | ${system.medianMs} ms |`,
+ )
+ return [
+ `${report.questions.answerable} answerable questions over ${report.corpus.trackedFiles} tracked files at ${report.commit.slice(0, 7)}`,
+ "",
+ "| System | Answer in top 3 | Answer in top 10 | MRR@10 | Off-topic refused | Near-domain refused | Median search |",
+ "| --- | ---: | ---: | ---: | ---: | ---: | ---: |",
+ ...rows,
+ ].join("\n")
+}
+
+function shortModel(name) {
+ return name.split("/").pop()
+}
+
+function round(value, digits = 3) {
+ return Number(value.toFixed(digits))
+}
+
+function parseArguments(argumentsList) {
+ const parsed = {}
+ for (let index = 0; index < argumentsList.length; index += 1) {
+ const current = argumentsList[index]
+ if (!current.startsWith("--")) continue
+ const key = current.slice(2).replace(/-([a-z])/gu, (_, letter) => letter.toUpperCase())
+ parsed[key] = argumentsList[index + 1]
+ index += 1
+ }
+ return parsed
+}
diff --git a/packages/ragmir-core/benchmarks/concurrency-worker.mjs b/packages/ragmir-core/benchmarks/concurrency-worker.mjs
index ba5e63b9..caabc638 100644
--- a/packages/ragmir-core/benchmarks/concurrency-worker.mjs
+++ b/packages/ragmir-core/benchmarks/concurrency-worker.mjs
@@ -2,6 +2,7 @@ import { performance } from "node:perf_hooks"
import { createRagmirClient } from "../dist/index.js"
import { loadConfig } from "../dist/config.js"
import { workloadSnapshot } from "../dist/workload.js"
+import { percentile } from "./lib/metrics.mjs"
const root = requiredEnvironment("RAGMIR_CONCURRENCY_ROOT")
const requests = positiveInteger(process.env.RAGMIR_CONCURRENCY_REQUESTS ?? "100")
@@ -79,13 +80,6 @@ function summarize(sorted) {
}
}
-function percentile(sorted, ratio) {
- if (sorted.length === 0) {
- return 0
- }
- return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)] ?? 0
-}
-
function requiredEnvironment(name) {
const value = process.env[name]
if (!value) {
diff --git a/packages/ragmir-core/benchmarks/fixtures/repository-questions.json b/packages/ragmir-core/benchmarks/fixtures/repository-questions.json
new file mode 100644
index 00000000..bc743ade
--- /dev/null
+++ b/packages/ragmir-core/benchmarks/fixtures/repository-questions.json
@@ -0,0 +1,1172 @@
+{
+ "description": "Questions about the public Ragmir repository: answerable developer questions with every file that answers them, off-topic questions, and near-domain questions about features Ragmir does not have. Expected paths are tracked files.",
+ "answerable": [
+ {
+ "id": "guided-setup-1",
+ "query": "How should a coding agent inspect a repository before installing Ragmir?",
+ "expectedPaths": [
+ "docs/quick-start.md",
+ "packages/ragmir-core/src/setup-prompt.ts",
+ "README.md",
+ "docs/agent-integration.md",
+ "packages/ragmir-core/README.md"
+ ]
+ },
+ {
+ "id": "guided-setup-2",
+ "query": "Which approvals must the guided Ragmir setup request before changing dependencies or downloading models?",
+ "expectedPaths": [
+ "docs/quick-start.md",
+ "packages/ragmir-core/src/setup-prompt.ts",
+ "README.md",
+ "docs/agent-integration.md",
+ "packages/ragmir-core/README.md"
+ ]
+ },
+ {
+ "id": "guided-setup-3",
+ "query": "How does the guided setup choose sources and exclude secrets or generated noise?",
+ "expectedPaths": [
+ "docs/quick-start.md",
+ "packages/ragmir-core/src/setup-prompt.ts",
+ "README.md",
+ "docs/agent-integration.md",
+ "packages/ragmir-core/README.md"
+ ]
+ },
+ {
+ "id": "guided-setup-4",
+ "query": "What verification should the guided setup run after indexing the repository?",
+ "expectedPaths": [
+ "docs/quick-start.md",
+ "packages/ragmir-core/src/setup-prompt.ts",
+ "README.md",
+ "docs/agent-integration.md",
+ "packages/ragmir-core/README.md"
+ ]
+ },
+ {
+ "id": "guided-setup-5",
+ "query": "How should an existing Ragmir installation be inspected before it is upgraded?",
+ "expectedPaths": [
+ "docs/quick-start.md",
+ "packages/ragmir-core/src/setup-prompt.ts",
+ "README.md",
+ "docs/agent-integration.md",
+ "packages/ragmir-core/README.md"
+ ]
+ },
+ {
+ "id": "package-manager-1",
+ "query": "How does the guided setup prompt detect pnpm npm Yarn or Bun?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/package-manager.ts",
+ "packages/ragmir-core/src/setup-prompt.ts",
+ "docs/quick-start.md",
+ "packages/ragmir-landing/src/components/library-section.tsx"
+ ]
+ },
+ {
+ "id": "package-manager-2",
+ "query": "Which package manager signal has priority when Ragmir builds commands?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/package-manager.ts",
+ "packages/ragmir-core/src/setup-prompt.ts",
+ "docs/quick-start.md",
+ "packages/ragmir-landing/src/components/library-section.tsx"
+ ]
+ },
+ {
+ "id": "package-manager-3",
+ "query": "What rgr execution prefix is generated for a Bun project?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/package-manager.ts",
+ "packages/ragmir-core/src/setup-prompt.ts",
+ "docs/quick-start.md",
+ "packages/ragmir-landing/src/components/library-section.tsx"
+ ]
+ },
+ {
+ "id": "package-manager-4",
+ "query": "How does Ragmir avoid choosing a second lockfile in a workspace?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/package-manager.ts",
+ "packages/ragmir-core/src/setup-prompt.ts",
+ "docs/quick-start.md",
+ "packages/ragmir-landing/src/components/library-section.tsx"
+ ]
+ },
+ {
+ "id": "package-manager-5",
+ "query": "How does rgrCommand turn a detected package manager into a display command?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/package-manager.ts",
+ "packages/ragmir-core/src/setup-prompt.ts",
+ "docs/quick-start.md",
+ "packages/ragmir-landing/src/components/library-section.tsx"
+ ]
+ },
+ {
+ "id": "privacy-1",
+ "query": "What stays local when Ragmir Core indexes and retrieves project documents?",
+ "expectedPaths": [
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "SECURITY-HARDENING.md",
+ "llms.txt",
+ "context7.json"
+ ]
+ },
+ {
+ "id": "privacy-2",
+ "query": "Does Ragmir Core upload the corpus or call a language model?",
+ "expectedPaths": [
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "SECURITY-HARDENING.md",
+ "llms.txt",
+ "context7.json"
+ ]
+ },
+ {
+ "id": "privacy-3",
+ "query": "Where are generated indexes reports helpers and metadata-only logs stored?",
+ "expectedPaths": [
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "SECURITY-HARDENING.md",
+ "llms.txt",
+ "context7.json"
+ ]
+ },
+ {
+ "id": "privacy-4",
+ "query": "When can retrieved passages cross a network boundary?",
+ "expectedPaths": [
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "SECURITY-HARDENING.md",
+ "llms.txt",
+ "context7.json"
+ ]
+ },
+ {
+ "id": "privacy-5",
+ "query": "Why is local-hash described as offline rather than semantic?",
+ "expectedPaths": [
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "SECURITY-HARDENING.md",
+ "llms.txt",
+ "context7.json"
+ ]
+ },
+ {
+ "id": "agent-mcp-1",
+ "query": "How should a coding agent connect to Ragmir through the local MCP server?",
+ "expectedPaths": [
+ "docs/agent-integration.md",
+ "README.md",
+ "docs/api-reference.md",
+ "llms.txt",
+ "context7.json",
+ "packages/ragmir-core/src/mcp.ts",
+ "packages/ragmir-core/skills/ragmir/SKILL.md"
+ ]
+ },
+ {
+ "id": "agent-mcp-2",
+ "query": "Which native helpers are generated for Claude Code Codex Kimi OpenCode and Cline?",
+ "expectedPaths": [
+ "docs/agent-integration.md",
+ "README.md",
+ "docs/api-reference.md",
+ "llms.txt",
+ "context7.json",
+ "packages/ragmir-core/src/mcp.ts",
+ "packages/ragmir-core/skills/ragmir/SKILL.md"
+ ]
+ },
+ {
+ "id": "agent-mcp-3",
+ "query": "How does an MCP helper select the correct knowledge base in a monorepo?",
+ "expectedPaths": [
+ "docs/agent-integration.md",
+ "README.md",
+ "docs/api-reference.md",
+ "llms.txt",
+ "context7.json",
+ "packages/ragmir-core/src/mcp.ts",
+ "packages/ragmir-core/skills/ragmir/SKILL.md"
+ ]
+ },
+ {
+ "id": "agent-mcp-4",
+ "query": "Why should an agent read ragmir://context before retrieval?",
+ "expectedPaths": [
+ "docs/agent-integration.md",
+ "README.md",
+ "docs/api-reference.md",
+ "llms.txt",
+ "context7.json",
+ "packages/ragmir-core/src/mcp.ts",
+ "packages/ragmir-core/skills/ragmir/SKILL.md"
+ ]
+ },
+ {
+ "id": "agent-mcp-5",
+ "query": "What happens when an unmanaged same-name agent skill already exists?",
+ "expectedPaths": [
+ "docs/agent-integration.md",
+ "README.md",
+ "docs/api-reference.md",
+ "llms.txt",
+ "context7.json",
+ "packages/ragmir-core/src/mcp.ts",
+ "packages/ragmir-core/skills/ragmir/SKILL.md"
+ ]
+ },
+ {
+ "id": "team-1",
+ "query": "How do teammates synchronize Ragmir sources while keeping one local index per developer?",
+ "expectedPaths": [
+ "docs/agent-integration.md",
+ "README.md",
+ "docs/configuration.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "team-2",
+ "query": "What does a metadata-only team snapshot contain and exclude?",
+ "expectedPaths": [
+ "docs/agent-integration.md",
+ "README.md",
+ "docs/configuration.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "team-3",
+ "query": "How does team compare explain local-only peer-only and changed files?",
+ "expectedPaths": [
+ "docs/agent-integration.md",
+ "README.md",
+ "docs/configuration.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "team-4",
+ "query": "Who decides which Git Drive or folder revision is authoritative?",
+ "expectedPaths": [
+ "docs/agent-integration.md",
+ "README.md",
+ "docs/configuration.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "team-5",
+ "query": "Why must actively written .ragmir storage stay local to each workstation?",
+ "expectedPaths": [
+ "docs/agent-integration.md",
+ "README.md",
+ "docs/configuration.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "cli-1",
+ "query": "Which rgr commands cover setup preview ingest search audit and maintenance?",
+ "expectedPaths": [
+ "docs/cli-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "packages/ragmir-core/skills/ragmir/SKILL.md"
+ ]
+ },
+ {
+ "id": "cli-2",
+ "query": "How can a user explain lexical and vector contributions to a search ranking?",
+ "expectedPaths": [
+ "docs/cli-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "packages/ragmir-core/skills/ragmir/SKILL.md"
+ ]
+ },
+ {
+ "id": "cli-3",
+ "query": "What bounds apply to top-k and context radius in CLI retrieval?",
+ "expectedPaths": [
+ "docs/cli-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "packages/ragmir-core/skills/ragmir/SKILL.md"
+ ]
+ },
+ {
+ "id": "cli-4",
+ "query": "How does research report timeout code scan and evidence budgets?",
+ "expectedPaths": [
+ "docs/cli-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "packages/ragmir-core/skills/ragmir/SKILL.md"
+ ]
+ },
+ {
+ "id": "cli-5",
+ "query": "Which command audits unsupported files without writing the index?",
+ "expectedPaths": [
+ "docs/cli-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "packages/ragmir-core/skills/ragmir/SKILL.md"
+ ]
+ },
+ {
+ "id": "api-1",
+ "query": "How should a long-running Node process reuse and close a Ragmir client?",
+ "expectedPaths": [
+ "docs/api-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "packages/ragmir-core/src/client.ts",
+ "packages/ragmir-core/src/query.ts"
+ ]
+ },
+ {
+ "id": "api-2",
+ "query": "Which TypeScript API returns cited retrieval context without synthesized prose?",
+ "expectedPaths": [
+ "docs/api-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "packages/ragmir-core/src/client.ts",
+ "packages/ragmir-core/src/query.ts"
+ ]
+ },
+ {
+ "id": "api-3",
+ "query": "How can application code cancel a Ragmir operation or set a timeout?",
+ "expectedPaths": [
+ "docs/api-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "packages/ragmir-core/src/client.ts",
+ "packages/ragmir-core/src/query.ts"
+ ]
+ },
+ {
+ "id": "api-4",
+ "query": "What lifecycle resources are owned by createRagmirClient?",
+ "expectedPaths": [
+ "docs/api-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "packages/ragmir-core/src/client.ts",
+ "packages/ragmir-core/src/query.ts"
+ ]
+ },
+ {
+ "id": "api-5",
+ "query": "Which typed API expands a citation into its source context?",
+ "expectedPaths": [
+ "docs/api-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "packages/ragmir-core/src/client.ts",
+ "packages/ragmir-core/src/query.ts"
+ ]
+ },
+ {
+ "id": "configuration-1",
+ "query": "How are stable source globs configured for a Ragmir knowledge base?",
+ "expectedPaths": [
+ "docs/configuration.md",
+ "README.md",
+ "docs/agent-integration.md",
+ "packages/ragmir-core/src/config.ts",
+ "packages/ragmir-core/src/setup-prompt.ts"
+ ]
+ },
+ {
+ "id": "configuration-2",
+ "query": "Which configuration fields select local-hash or Transformers embeddings?",
+ "expectedPaths": [
+ "docs/configuration.md",
+ "README.md",
+ "docs/agent-integration.md",
+ "packages/ragmir-core/src/config.ts",
+ "packages/ragmir-core/src/setup-prompt.ts"
+ ]
+ },
+ {
+ "id": "configuration-3",
+ "query": "How should private external source folders be authorized?",
+ "expectedPaths": [
+ "docs/configuration.md",
+ "README.md",
+ "docs/agent-integration.md",
+ "packages/ragmir-core/src/config.ts",
+ "packages/ragmir-core/src/setup-prompt.ts"
+ ]
+ },
+ {
+ "id": "configuration-4",
+ "query": "What ingestion windows independently bound files bytes chunks vectors and concurrency?",
+ "expectedPaths": [
+ "docs/configuration.md",
+ "README.md",
+ "docs/agent-integration.md",
+ "packages/ragmir-core/src/config.ts",
+ "packages/ragmir-core/src/setup-prompt.ts"
+ ]
+ },
+ {
+ "id": "configuration-5",
+ "query": "How does the nearest configured ancestor behave in a monorepo?",
+ "expectedPaths": [
+ "docs/configuration.md",
+ "README.md",
+ "docs/agent-integration.md",
+ "packages/ragmir-core/src/config.ts",
+ "packages/ragmir-core/src/setup-prompt.ts"
+ ]
+ },
+ {
+ "id": "upgrade-1",
+ "query": "How should an old Ragmir installation check compatibility after a package update?",
+ "expectedPaths": [
+ "docs/cli-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "packages/ragmir-core/src/upgrade.ts",
+ "packages/ragmir-core/src/upgrade.test.ts",
+ "CHANGELOG.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "upgrade-2",
+ "query": "When does an upgrade require a staged rebuild instead of index reuse?",
+ "expectedPaths": [
+ "docs/cli-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "packages/ragmir-core/src/upgrade.ts",
+ "packages/ragmir-core/src/upgrade.test.ts",
+ "CHANGELOG.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "upgrade-3",
+ "query": "Why does Ragmir never delete the active index before rebuilding?",
+ "expectedPaths": [
+ "docs/cli-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "packages/ragmir-core/src/upgrade.ts",
+ "packages/ragmir-core/src/upgrade.test.ts",
+ "CHANGELOG.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "upgrade-4",
+ "query": "How can a long-running service continue serving during an index upgrade?",
+ "expectedPaths": [
+ "docs/cli-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "packages/ragmir-core/src/upgrade.ts",
+ "packages/ragmir-core/src/upgrade.test.ts",
+ "CHANGELOG.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "upgrade-5",
+ "query": "Which safe defaults are applied to older configuration files?",
+ "expectedPaths": [
+ "docs/cli-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "packages/ragmir-core/src/upgrade.ts",
+ "packages/ragmir-core/src/upgrade.test.ts",
+ "CHANGELOG.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "chat-1",
+ "query": "How is local GGUF answer synthesis kept separate from Ragmir Core retrieval?",
+ "expectedPaths": [
+ "README.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "chat-2",
+ "query": "What are the download sizes for lite fast and quality Chat profiles?",
+ "expectedPaths": [
+ "README.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "chat-3",
+ "query": "How does Ragmir Chat validate visible citation markers in a local answer?",
+ "expectedPaths": [
+ "README.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "chat-4",
+ "query": "Which command verifies a Chat model profile is ready for offline generation?",
+ "expectedPaths": [
+ "README.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "chat-5",
+ "query": "Does installing Ragmir Core require Qwen Gemma or the Chat package?",
+ "expectedPaths": [
+ "README.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "tts-1",
+ "query": "Which languages have an offline Ragmir TTS model?",
+ "expectedPaths": [
+ "README.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "tts-2",
+ "query": "When may narration text be sent to the Edge speech service?",
+ "expectedPaths": [
+ "README.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "tts-3",
+ "query": "How should a TTS model be preloaded with non-sensitive text?",
+ "expectedPaths": [
+ "README.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "tts-4",
+ "query": "Which output format is produced by offline Transformers speech?",
+ "expectedPaths": [
+ "README.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "tts-5",
+ "query": "Does Ragmir TTS retrieve evidence or only render caller-provided text?",
+ "expectedPaths": [
+ "README.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "troubleshooting-1",
+ "query": "What should a user inspect when Ragmir search results are weak?",
+ "expectedPaths": [
+ "docs/troubleshooting.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "docs/cli-reference.md"
+ ]
+ },
+ {
+ "id": "troubleshooting-2",
+ "query": "How can unsupported or oversized files be diagnosed?",
+ "expectedPaths": [
+ "docs/troubleshooting.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "docs/cli-reference.md"
+ ]
+ },
+ {
+ "id": "troubleshooting-3",
+ "query": "What repairs should doctor fix perform without deleting healthy storage?",
+ "expectedPaths": [
+ "docs/troubleshooting.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "docs/cli-reference.md"
+ ]
+ },
+ {
+ "id": "troubleshooting-4",
+ "query": "How should a missing optional Chat or TTS peer package be diagnosed?",
+ "expectedPaths": [
+ "docs/troubleshooting.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "docs/cli-reference.md"
+ ]
+ },
+ {
+ "id": "troubleshooting-5",
+ "query": "Which checks distinguish privacy warnings from operational readiness?",
+ "expectedPaths": [
+ "docs/troubleshooting.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "docs/cli-reference.md"
+ ]
+ },
+ {
+ "id": "security-1",
+ "query": "Which privacy controls harden a Ragmir installation for confidential documents?",
+ "expectedPaths": [
+ "SECURITY-HARDENING.md",
+ "SECURITY.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "docs/configuration.md",
+ "docs/agent-integration.md"
+ ]
+ },
+ {
+ "id": "security-2",
+ "query": "How does security-audit verify Git ignore coverage and local permissions?",
+ "expectedPaths": [
+ "SECURITY-HARDENING.md",
+ "SECURITY.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "docs/configuration.md",
+ "docs/agent-integration.md"
+ ]
+ },
+ {
+ "id": "security-3",
+ "query": "What content must never be committed from the .ragmir directory?",
+ "expectedPaths": [
+ "SECURITY-HARDENING.md",
+ "SECURITY.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "docs/configuration.md",
+ "docs/agent-integration.md"
+ ]
+ },
+ {
+ "id": "security-4",
+ "query": "Why must vulnerabilities leaked secrets and private document disclosures be reported privately instead of in a public issue?",
+ "expectedPaths": [
+ "SECURITY-HARDENING.md",
+ "SECURITY.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "docs/configuration.md",
+ "docs/agent-integration.md"
+ ]
+ },
+ {
+ "id": "security-5",
+ "query": "Why are OCR and external extractors explicit local authority boundaries?",
+ "expectedPaths": [
+ "SECURITY-HARDENING.md",
+ "SECURITY.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "docs/configuration.md",
+ "docs/agent-integration.md"
+ ]
+ },
+ {
+ "id": "release-1",
+ "query": "What validation gate must pass before a Ragmir release?",
+ "expectedPaths": [
+ "RELEASING.md",
+ "README.md",
+ "package.json",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "scripts/semantic-release-smoke.mjs"
+ ]
+ },
+ {
+ "id": "release-2",
+ "query": "How are Core Chat and TTS package compatibility checked before publishing?",
+ "expectedPaths": [
+ "RELEASING.md",
+ "README.md",
+ "package.json",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "scripts/semantic-release-smoke.mjs"
+ ]
+ },
+ {
+ "id": "release-3",
+ "query": "Which release notes sections describe highlights details and verification?",
+ "expectedPaths": [
+ "RELEASING.md",
+ "README.md",
+ "package.json",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "scripts/semantic-release-smoke.mjs"
+ ]
+ },
+ {
+ "id": "release-4",
+ "query": "How is semantic-release used to publish Ragmir packages?",
+ "expectedPaths": [
+ "RELEASING.md",
+ "README.md",
+ "package.json",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "scripts/semantic-release-smoke.mjs"
+ ]
+ },
+ {
+ "id": "release-5",
+ "query": "What should be verified after npm packages and the landing are released?",
+ "expectedPaths": [
+ "RELEASING.md",
+ "README.md",
+ "package.json",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "scripts/semantic-release-smoke.mjs"
+ ]
+ },
+ {
+ "id": "contributing-1",
+ "query": "Which Node version and package manager should contributors use?",
+ "expectedPaths": [
+ "CONTRIBUTING.md",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "README.md"
+ ]
+ },
+ {
+ "id": "contributing-2",
+ "query": "How should a contributor run the repository validation suite?",
+ "expectedPaths": [
+ "CONTRIBUTING.md",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "README.md"
+ ]
+ },
+ {
+ "id": "contributing-3",
+ "query": "What code and documentation expectations apply to a Ragmir change?",
+ "expectedPaths": [
+ "CONTRIBUTING.md",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "README.md"
+ ]
+ },
+ {
+ "id": "contributing-4",
+ "query": "How should commits be formatted in the Ragmir repository?",
+ "expectedPaths": [
+ "CONTRIBUTING.md",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "README.md"
+ ]
+ },
+ {
+ "id": "contributing-5",
+ "query": "Which local generated files must contributors keep out of Git?",
+ "expectedPaths": [
+ "CONTRIBUTING.md",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "README.md"
+ ]
+ },
+ {
+ "id": "scripts-1",
+ "query": "Which root script runs the complete Ragmir validation gate?",
+ "expectedPaths": [
+ "package.json",
+ "README.md",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "RELEASING.md",
+ "scripts/public-surface-smoke.mjs",
+ "scripts/semantic-release-smoke.mjs"
+ ]
+ },
+ {
+ "id": "scripts-2",
+ "query": "Which package scripts build Core Chat TTS and the landing?",
+ "expectedPaths": [
+ "package.json",
+ "README.md",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "RELEASING.md",
+ "scripts/public-surface-smoke.mjs",
+ "scripts/semantic-release-smoke.mjs"
+ ]
+ },
+ {
+ "id": "scripts-3",
+ "query": "What script checks the public repository surface for secrets and generated state?",
+ "expectedPaths": [
+ "package.json",
+ "README.md",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "RELEASING.md",
+ "scripts/public-surface-smoke.mjs",
+ "scripts/semantic-release-smoke.mjs"
+ ]
+ },
+ {
+ "id": "scripts-4",
+ "query": "Which pnpm command runs the landing development server on localhost 4322?",
+ "expectedPaths": [
+ "package.json",
+ "README.md",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "RELEASING.md",
+ "scripts/public-surface-smoke.mjs",
+ "scripts/semantic-release-smoke.mjs",
+ "packages/ragmir-landing/README.md"
+ ]
+ },
+ {
+ "id": "scripts-5",
+ "query": "Which release script verifies semantic-release configuration?",
+ "expectedPaths": [
+ "package.json",
+ "README.md",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "RELEASING.md",
+ "scripts/public-surface-smoke.mjs",
+ "scripts/semantic-release-smoke.mjs"
+ ]
+ },
+ {
+ "id": "mcp-1",
+ "query": "Which read-focused tools are exposed by the Ragmir MCP server?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/mcp.ts",
+ "docs/agent-integration.md",
+ "docs/api-reference.md",
+ "README.md",
+ "llms.txt",
+ "context7.json",
+ "packages/ragmir-core/skills/ragmir/SKILL.md"
+ ]
+ },
+ {
+ "id": "mcp-2",
+ "query": "How are MCP JSON responses bounded when the output budget is small?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/mcp.ts",
+ "docs/agent-integration.md",
+ "docs/api-reference.md",
+ "README.md",
+ "llms.txt",
+ "context7.json",
+ "packages/ragmir-core/skills/ragmir/SKILL.md"
+ ]
+ },
+ {
+ "id": "mcp-3",
+ "query": "When does the MCP server refresh its lazy Ragmir client?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/mcp.ts",
+ "docs/agent-integration.md",
+ "docs/api-reference.md",
+ "README.md",
+ "llms.txt",
+ "context7.json",
+ "packages/ragmir-core/skills/ragmir/SKILL.md"
+ ]
+ },
+ {
+ "id": "mcp-4",
+ "query": "How does MCP advertise destructive and open-world tool behavior?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/mcp.ts",
+ "docs/agent-integration.md",
+ "docs/api-reference.md",
+ "README.md",
+ "llms.txt",
+ "context7.json",
+ "packages/ragmir-core/skills/ragmir/SKILL.md"
+ ]
+ },
+ {
+ "id": "mcp-5",
+ "query": "Which MCP resources orient an agent before it searches?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/mcp.ts",
+ "docs/agent-integration.md",
+ "docs/api-reference.md",
+ "README.md",
+ "llms.txt",
+ "context7.json",
+ "packages/ragmir-core/skills/ragmir/SKILL.md"
+ ]
+ },
+ {
+ "id": "ingestion-1",
+ "query": "How does Ragmir ingestion discover redact chunk embed and commit selected files?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/ingest.ts",
+ "docs/cli-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "docs/configuration.md",
+ "AGENTS.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "ingestion-2",
+ "query": "How are concurrent local index writers serialized?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/ingest.ts",
+ "docs/cli-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "docs/configuration.md",
+ "AGENTS.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "ingestion-3",
+ "query": "What durable progress allows an interrupted ingestion to resume?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/ingest.ts",
+ "docs/cli-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "docs/configuration.md",
+ "AGENTS.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "ingestion-4",
+ "query": "How does a rebuild validate and activate a new generation atomically?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/ingest.ts",
+ "docs/cli-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "docs/configuration.md",
+ "AGENTS.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "ingestion-5",
+ "query": "When can an unchanged file be reused instead of parsed again?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/ingest.ts",
+ "docs/cli-reference.md",
+ "README.md",
+ "packages/ragmir-core/README.md",
+ "docs/configuration.md",
+ "AGENTS.md",
+ "llms.txt"
+ ]
+ },
+ {
+ "id": "evaluation-1",
+ "query": "Which metrics are calculated by the Ragmir golden-query evaluator?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/evaluate.ts",
+ "docs/cli-reference.md",
+ "docs/api-reference.md",
+ "README.md",
+ "packages/ragmir-core/benchmarks/README.md"
+ ]
+ },
+ {
+ "id": "evaluation-2",
+ "query": "What makes a retrieval quality report eligible for verification?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/evaluate.ts",
+ "docs/cli-reference.md",
+ "docs/api-reference.md",
+ "README.md",
+ "packages/ragmir-core/benchmarks/README.md"
+ ]
+ },
+ {
+ "id": "evaluation-3",
+ "query": "How are exact citations graded in a golden query?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/evaluate.ts",
+ "docs/cli-reference.md",
+ "docs/api-reference.md",
+ "README.md",
+ "packages/ragmir-core/benchmarks/README.md"
+ ]
+ },
+ {
+ "id": "evaluation-4",
+ "query": "How does a hard-negative case measure false positives?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/evaluate.ts",
+ "docs/cli-reference.md",
+ "docs/api-reference.md",
+ "README.md",
+ "packages/ragmir-core/benchmarks/README.md"
+ ]
+ },
+ {
+ "id": "evaluation-5",
+ "query": "What invalidates a stored retrieval quality fingerprint?",
+ "expectedPaths": [
+ "packages/ragmir-core/src/evaluate.ts",
+ "docs/cli-reference.md",
+ "docs/api-reference.md",
+ "README.md",
+ "packages/ragmir-core/benchmarks/README.md"
+ ]
+ },
+ {
+ "id": "landing-1",
+ "query": "Which structured data entities describe the Ragmir landing page?",
+ "expectedPaths": [
+ "packages/ragmir-landing/src/pages/[...locale]/index.astro",
+ "packages/ragmir-landing/src/components/library-section.tsx",
+ "packages/ragmir-landing/messages/en.json",
+ "packages/ragmir-landing/messages/fr.json",
+ "packages/ragmir-landing/README.md",
+ "packages/ragmir-landing/src/public-contract.test.ts",
+ "scripts/public-surface-smoke.mjs"
+ ]
+ },
+ {
+ "id": "landing-2",
+ "query": "How does the landing keep English and French metadata aligned?",
+ "expectedPaths": [
+ "packages/ragmir-landing/src/pages/[...locale]/index.astro",
+ "packages/ragmir-landing/src/components/library-section.tsx",
+ "packages/ragmir-landing/messages/en.json",
+ "packages/ragmir-landing/messages/fr.json",
+ "packages/ragmir-landing/README.md",
+ "packages/ragmir-landing/src/public-contract.test.ts",
+ "scripts/public-surface-smoke.mjs"
+ ]
+ },
+ {
+ "id": "landing-3",
+ "query": "Where does the landing present the copyable guided setup prompt?",
+ "expectedPaths": [
+ "packages/ragmir-landing/src/pages/[...locale]/index.astro",
+ "packages/ragmir-landing/src/components/library-section.tsx",
+ "packages/ragmir-landing/messages/en.json",
+ "packages/ragmir-landing/messages/fr.json",
+ "packages/ragmir-landing/README.md",
+ "packages/ragmir-landing/src/public-contract.test.ts",
+ "scripts/public-surface-smoke.mjs"
+ ]
+ },
+ {
+ "id": "landing-4",
+ "query": "Which package-manager tabs are available for manual installation?",
+ "expectedPaths": [
+ "packages/ragmir-landing/src/pages/[...locale]/index.astro",
+ "packages/ragmir-landing/src/components/library-section.tsx",
+ "packages/ragmir-landing/messages/en.json",
+ "packages/ragmir-landing/messages/fr.json",
+ "packages/ragmir-landing/README.md",
+ "packages/ragmir-landing/src/public-contract.test.ts",
+ "scripts/public-surface-smoke.mjs"
+ ]
+ }
+ ],
+ "unrelated": [
+ "What is the weather forecast for Lyon tomorrow?",
+ "Quel temps fera-t-il à Bordeaux ce week-end ?",
+ "How do I make a classic French onion soup?",
+ "Recette de la tarte tatin aux pommes",
+ "Who won the 2018 FIFA World Cup final?",
+ "Qui a remporté le Tour de France 2019 ?",
+ "What is the capital of Australia?",
+ "Combien de lunes possède la planète Jupiter ?",
+ "How tall is the Eiffel Tower?",
+ "Quelle est la meilleure période pour visiter le Japon ?",
+ "What is the boiling point of water at the top of Mount Everest?",
+ "Comment faire pousser des tomates sur un balcon ?",
+ "Who painted the Mona Lisa?",
+ "Quel est le prix moyen d'un billet de train Paris Marseille ?",
+ "How do I change a flat tire on a bicycle?",
+ "Quelle race de chien est la plus calme pour un appartement ?",
+ "What are the rules of cricket?",
+ "Comment détartrer une bouilloire avec du vinaigre ?",
+ "When did the Roman Empire fall?",
+ "Quels sont les symptômes d'un rhume chez le chat ?",
+ "Best hiking trails near Chamonix",
+ "Quelle est la hauteur du Mont Blanc ?",
+ "How does a sourdough starter work?",
+ "qwzx plorbnik vantrel ?",
+ "blorf glimmer tasky snarl",
+ "zxcv asdf qwer uiop",
+ "Lorem ipsum dolor sit amet consectetur",
+ "Où trouver des champignons en forêt en octobre ?",
+ "What year did the Berlin Wall fall?",
+ "Quel vin servir avec une raclette ?"
+ ],
+ "nearDomain": [
+ "How does Ragmir expose a GraphQL API?",
+ "Where is the Helm chart to deploy Ragmir on Kubernetes?",
+ "How does Ragmir sync with a Pinecone index?",
+ "How do I configure the Redis cache for Ragmir search results?",
+ "Which command exports the Ragmir index to Parquet?",
+ "Is there a Windows MSI installer for Ragmir?",
+ "How do I connect Ragmir to a Slack bot?",
+ "How does the Ragmir Discord integration work?",
+ "How do I index Confluence spaces with Ragmir?",
+ "How does the Ragmir SharePoint connector authenticate?",
+ "How do I sync a Notion workspace into Ragmir?",
+ "How does Ragmir encrypt the index with AWS KMS?",
+ "How do I configure SAML single sign-on for Ragmir?",
+ "Which Prometheus metrics does Ragmir export?",
+ "Where is the Ragmir Java SDK documented?",
+ "How do I stream Ragmir search results over server-sent events?",
+ "How do I index AutoCAD DWG drawings with Ragmir?",
+ "How do I index Outlook PST email archives?",
+ "How do I configure the built-in web crawler to index a website?",
+ "How do I index YouTube video transcripts with Ragmir?",
+ "Which Elasticsearch version does Ragmir require?",
+ "How do I configure Ragmir with Azure OpenAI embeddings?",
+ "How does Ragmir index WhatsApp or iMessage chats?",
+ "How does Ragmir handle Git LFS files during ingestion?",
+ "How do I index a MongoDB collection with Ragmir?",
+ "How does the GraphRAG knowledge graph builder in Ragmir work?",
+ "How do I configure the Ragmir Chrome extension?",
+ "How does Ragmir OCR Arabic right-to-left scans?",
+ "How does Ragmir handle Excel macros in .xlsm files?",
+ "How does Ragmir federated search query several remote servers?"
+ ]
+}
diff --git a/packages/ragmir-core/benchmarks/lib/metrics.mjs b/packages/ragmir-core/benchmarks/lib/metrics.mjs
index 2c6e77fa..addcdc38 100644
--- a/packages/ragmir-core/benchmarks/lib/metrics.mjs
+++ b/packages/ragmir-core/benchmarks/lib/metrics.mjs
@@ -24,24 +24,24 @@ export function stableJson(value) {
return JSON.stringify(value)
}
-export function percentile(values, quantile) {
- if (values.length === 0) {
- return 0
- }
- const sorted = [...values].sort((left, right) => left - right)
- const index = Math.max(0, Math.ceil(quantile * sorted.length) - 1)
- return sorted[index] ?? 0
+/**
+ * Nearest-rank percentile of ascending values, the same rule as Core's `percentile`: the smallest
+ * value with at least `quantile` of the values at or below it. Empty input gives 0.
+ */
+export function percentile(sortedValues, quantile) {
+ return sortedValues[Math.max(0, Math.ceil(quantile * sortedValues.length) - 1)] ?? 0
}
-export function summarizeSamples(values) {
+function summarizeSamples(values) {
const total = values.reduce((sum, value) => sum + value, 0)
+ const sorted = [...values].sort((left, right) => left - right)
return {
count: values.length,
minMs: values.length === 0 ? 0 : Math.min(...values),
meanMs: values.length === 0 ? 0 : total / values.length,
- p50Ms: percentile(values, 0.5),
- p95Ms: percentile(values, 0.95),
- p99Ms: percentile(values, 0.99),
+ p50Ms: percentile(sorted, 0.5),
+ p95Ms: percentile(sorted, 0.95),
+ p99Ms: percentile(sorted, 0.99),
maxMs: values.length === 0 ? 0 : Math.max(...values),
}
}
diff --git a/packages/ragmir-core/benchmarks/local-hash-experiment.mjs b/packages/ragmir-core/benchmarks/local-hash-experiment.mjs
index b7c3701d..857b1489 100644
--- a/packages/ragmir-core/benchmarks/local-hash-experiment.mjs
+++ b/packages/ragmir-core/benchmarks/local-hash-experiment.mjs
@@ -4,7 +4,7 @@ import path from "node:path"
import { performance } from "node:perf_hooks"
import { fileURLToPath } from "node:url"
import { tokenize } from "../dist/text.js"
-import { environmentMetadata } from "./lib/metrics.mjs"
+import { environmentMetadata, percentile } from "./lib/metrics.mjs"
const dimensions = 384
const tokenHashSeed = 0x811c9dc5
@@ -219,10 +219,6 @@ function delta(candidate, baseline) {
return candidate - baseline
}
-function percentile(sorted, quantile) {
- return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * quantile) - 1))]
-}
-
function benchmarkTexts() {
const samples = [
"Production approval requires an immutable audit trail and cited evidence.",
diff --git a/packages/ragmir-core/benchmarks/reranker-experiment.mjs b/packages/ragmir-core/benchmarks/reranker-experiment.mjs
index 5763f48f..ae00e7a2 100644
--- a/packages/ragmir-core/benchmarks/reranker-experiment.mjs
+++ b/packages/ragmir-core/benchmarks/reranker-experiment.mjs
@@ -11,7 +11,7 @@ import {
} from "@huggingface/transformers"
import { createRagmirClient } from "../dist/index.js"
import { CORPUS_PRESETS, generateCorpus } from "./lib/corpus.mjs"
-import { environmentMetadata } from "./lib/metrics.mjs"
+import { environmentMetadata, percentile } from "./lib/metrics.mjs"
const benchmarkPath = fileURLToPath(import.meta.url)
const packageRoot = path.resolve(path.dirname(benchmarkPath), "..")
@@ -360,10 +360,6 @@ function latencySummary(values) {
}
}
-function percentile(sorted, quantile) {
- return sorted[Math.max(0, Math.ceil(sorted.length * quantile) - 1)] ?? 0
-}
-
function mean(values) {
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
}
diff --git a/packages/ragmir-core/benchmarks/run.mjs b/packages/ragmir-core/benchmarks/run.mjs
index 094f0a2e..8ad9b960 100644
--- a/packages/ragmir-core/benchmarks/run.mjs
+++ b/packages/ragmir-core/benchmarks/run.mjs
@@ -20,6 +20,7 @@ import {
environmentMetadata,
measureOperation,
measureSeries,
+ percentile,
settleMeasurement,
} from "./lib/metrics.mjs"
@@ -507,9 +508,9 @@ async function measureConcurrency({ client, queries, concurrency, total }) {
total,
wallMs,
throughputPerSecond: wallMs === 0 ? 0 : (total * 1_000) / wallMs,
- p50Ms: percentileFromSorted(durations, 0.5),
- p95Ms: percentileFromSorted(durations, 0.95),
- p99Ms: percentileFromSorted(durations, 0.99),
+ p50Ms: percentile(durations, 0.5),
+ p95Ms: percentile(durations, 0.95),
+ p99Ms: percentile(durations, 0.99),
}
}
@@ -631,13 +632,6 @@ function normalizeProvider(value) {
return value
}
-function percentileFromSorted(values, quantile) {
- if (values.length === 0) {
- return 0
- }
- return values[Math.max(0, Math.ceil(values.length * quantile) - 1)] ?? 0
-}
-
function parseArguments(values) {
const parsed = {}
for (let index = 0; index < values.length; index += 1) {
diff --git a/packages/ragmir-core/benchmarks/status.mjs b/packages/ragmir-core/benchmarks/status.mjs
index 2adc3072..393f62bb 100644
--- a/packages/ragmir-core/benchmarks/status.mjs
+++ b/packages/ragmir-core/benchmarks/status.mjs
@@ -6,7 +6,7 @@ import os from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { performance } from "node:perf_hooks"
-import { environmentMetadata } from "./lib/metrics.mjs"
+import { environmentMetadata, percentile } from "./lib/metrics.mjs"
const SAMPLE_COUNT = 100
const WARMUP_COUNT = 10
@@ -136,7 +136,3 @@ async function replaceChunkCount(projectRoot, chunkCount) {
}
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
}
-
-function percentile(sorted, quantile) {
- return sorted[Math.max(0, Math.ceil(sorted.length * quantile) - 1)] ?? 0
-}
diff --git a/packages/ragmir-core/package.json b/packages/ragmir-core/package.json
index 46b37f1a..7b6f3e2b 100644
--- a/packages/ragmir-core/package.json
+++ b/packages/ragmir-core/package.json
@@ -63,6 +63,7 @@
},
"scripts": {
"bench:compare": "node benchmarks/compare.mjs",
+ "bench:competitors": "pnpm build && node benchmarks/competitors.mjs",
"bench:cli-startup": "pnpm build && node benchmarks/cli-startup.mjs",
"bench:concurrency": "pnpm build && node benchmarks/concurrency.mjs",
"bench:discovery": "pnpm build && node benchmarks/discovery.mjs",
@@ -118,10 +119,14 @@
},
"devDependencies": {
"@huggingface/transformers": "^4.3.1",
+ "@langchain/core": "^1.3.2",
+ "@langchain/textsplitters": "^1.0.2",
+ "@orama/orama": "^3.1.18",
"@types/html-to-text": "^9.0.4",
"@types/node": "^22.20.5",
"@types/picomatch": "^4.0.3",
"apache-arrow": "^18.1.0",
+ "minisearch": "^7.2.0",
"publint": "^0.3.25",
"typescript": "^7.0.2",
"vitest": "^5.0.5"
diff --git a/packages/ragmir-core/skills/ragmir/SKILL.md b/packages/ragmir-core/skills/ragmir/SKILL.md
index 69705ce3..fad04345 100644
--- a/packages/ragmir-core/skills/ragmir/SKILL.md
+++ b/packages/ragmir-core/skills/ragmir/SKILL.md
@@ -30,6 +30,8 @@ Old redaction/privacy settings require this explicit migration. Source text is n
3. Expand one selected citation with `ragmir_expand`. Use `compact: false` only when full search
passages are needed.
4. Iterate when evidence is incomplete. Separate facts backed by citations from inference.
+ Verify a `weakEvidence` passage before relying on it. An empty answer means the base holds no
+ evidence: rephrase once with the project's own terms, then say so instead of guessing.
| Need | CLI or MCP |
| --- | --- |
diff --git a/packages/ragmir-core/src/abstention-calibration.test.ts b/packages/ragmir-core/src/abstention-calibration.test.ts
new file mode 100644
index 00000000..567126c0
--- /dev/null
+++ b/packages/ragmir-core/src/abstention-calibration.test.ts
@@ -0,0 +1,163 @@
+import type { Table } from "@lancedb/lancedb"
+import { describe, expect, it } from "vitest"
+import {
+ ABSTENTION_PROBES,
+ indexNoiseFloor,
+ manifestNoiseFloor,
+ measureAbstentionCalibration,
+ noiseFloorAdvice,
+} from "./abstention-calibration.js"
+import type { Config, IndexManifest } from "./types.js"
+
+const semanticConfig = {
+ embeddingProvider: "transformers",
+ embeddingModel: "intfloat/multilingual-e5-base",
+} as Config
+
+/** A table whose exact nearest distance for probe vector [i] is distances[i]. */
+function tableWithNearest(distances: Array): Table {
+ return {
+ vectorSearch: (vector: number[]) => {
+ const query = {
+ bypassVectorIndex: () => query,
+ select: () => query,
+ limit: () => query,
+ toArray: async () => {
+ const distance = distances[vector[0] ?? 0]
+ return distance === undefined ? [] : [{ id: "row", _distance: distance }]
+ },
+ }
+ return query
+ },
+ } as unknown as Table
+}
+
+const embedByIndex = async (texts: string[]) => texts.map((_, index) => [index])
+
+describe("abstention calibration", () => {
+ it("should keep the nearest off-topic distance as the index noise floor", async () => {
+ const distances = ABSTENTION_PROBES.map((_, index) => 0.5 - index * 0.001)
+ distances[3] = 0.31
+ distances[7] = undefined
+
+ const calibration = await measureAbstentionCalibration(
+ tableWithNearest(distances),
+ semanticConfig,
+ embedByIndex,
+ )
+
+ expect(calibration).toEqual({
+ schemaVersion: 1,
+ embeddingModel: "intfloat/multilingual-e5-base",
+ probeCount: ABSTENTION_PROBES.length - 1,
+ noiseFloorDistance: 0.31,
+ })
+ })
+
+ it("should skip local-hash indexes and empty tables", async () => {
+ const localHash = { ...semanticConfig, embeddingProvider: "local-hash" } as Config
+ expect(
+ await measureAbstentionCalibration(tableWithNearest([0.2]), localHash, embedByIndex),
+ ).toBeNull()
+ expect(
+ await measureAbstentionCalibration(tableWithNearest([]), semanticConfig, embedByIndex),
+ ).toBeNull()
+ })
+
+ it("should read a stored floor only for the configured model", () => {
+ const manifest = {
+ abstentionCalibration: {
+ schemaVersion: 1,
+ embeddingModel: "intfloat/multilingual-e5-base",
+ probeCount: 24,
+ noiseFloorDistance: 0.31,
+ },
+ } as IndexManifest
+
+ expect(manifestNoiseFloor(manifest, semanticConfig)).toBe(0.31)
+ expect(
+ manifestNoiseFloor(manifest, { ...semanticConfig, embeddingModel: "other/model" } as Config),
+ ).toBeNull()
+ expect(manifestNoiseFloor(null, semanticConfig)).toBeNull()
+ })
+
+ it("should advise an ingest only for a semantic index without a stored floor", () => {
+ const run = (args: string[]) => `rgr ${args.join(" ")}`
+ const legacy = { chunkCount: 120 } as IndexManifest
+ const stored = {
+ chunkCount: 120,
+ abstentionCalibration: {
+ schemaVersion: 1,
+ embeddingModel: "intfloat/multilingual-e5-base",
+ probeCount: 24,
+ noiseFloorDistance: 0.31,
+ },
+ } as IndexManifest
+ const localHash = { ...semanticConfig, embeddingProvider: "local-hash" } as Config
+
+ expect(noiseFloorAdvice(legacy, semanticConfig, run)).toContain("Run `rgr ingest` to store it.")
+ expect(noiseFloorAdvice(stored, semanticConfig, run)).toBeNull()
+ expect(noiseFloorAdvice(legacy, localHash, run)).toBeNull()
+ expect(noiseFloorAdvice({ chunkCount: 0 } as IndexManifest, semanticConfig, run)).toBeNull()
+ })
+
+ it("should measure a legacy index once per generation and reuse a stored floor", async () => {
+ const distances = ABSTENTION_PROBES.map(() => 0.42)
+ let embedCalls = 0
+ const countingEmbed = async (texts: string[]) => {
+ embedCalls += 1
+ return embedByIndex(texts)
+ }
+ const legacy = { chunkCount: 12 } as IndexManifest
+ const table = tableWithNearest(distances)
+
+ expect(
+ await indexNoiseFloor(table, semanticConfig, legacy, "generation-a", countingEmbed),
+ ).toBe(0.42)
+ expect(
+ await indexNoiseFloor(table, semanticConfig, legacy, "generation-a", countingEmbed),
+ ).toBe(0.42)
+ expect(embedCalls).toBe(1)
+
+ const stored = {
+ chunkCount: 12,
+ abstentionCalibration: {
+ schemaVersion: 1,
+ embeddingModel: "intfloat/multilingual-e5-base",
+ probeCount: 24,
+ noiseFloorDistance: 0.3,
+ },
+ } as IndexManifest
+ expect(
+ await indexNoiseFloor(table, semanticConfig, stored, "generation-b", countingEmbed),
+ ).toBe(0.3)
+ expect(embedCalls).toBe(1)
+ })
+
+ it("should keep the policy distances when the measurement fails or does not apply", async () => {
+ const failing = async () => {
+ throw new Error("model unavailable")
+ }
+ const legacy = { chunkCount: 12 } as IndexManifest
+ const localHash = { ...semanticConfig, embeddingProvider: "local-hash" } as Config
+
+ expect(
+ await indexNoiseFloor(
+ tableWithNearest([0.2]),
+ semanticConfig,
+ legacy,
+ "generation-c",
+ failing,
+ ),
+ ).toBeNull()
+ expect(
+ await indexNoiseFloor(
+ tableWithNearest([0.2]),
+ localHash,
+ legacy,
+ "generation-d",
+ embedByIndex,
+ ),
+ ).toBeNull()
+ })
+})
diff --git a/packages/ragmir-core/src/abstention-calibration.ts b/packages/ragmir-core/src/abstention-calibration.ts
new file mode 100644
index 00000000..1b4fa076
--- /dev/null
+++ b/packages/ragmir-core/src/abstention-calibration.ts
@@ -0,0 +1,149 @@
+import type { Table } from "@lancedb/lancedb"
+import type { AbstentionCalibration, Config, IndexManifest } from "./types.js"
+
+/**
+ * Off-topic questions in English, French, and nonsense, chosen to share no subject with a software
+ * project. Their nearest passages mark where unrelated questions land in an index, whatever the
+ * embedding model. Changing the list changes every calibration, so keep it stable.
+ */
+export const ABSTENTION_PROBES: readonly string[] = [
+ "Who won the Super Bowl in 1998?",
+ "How long should I boil an egg?",
+ "What is the population of Canada?",
+ "Best beaches in Portugal for surfing",
+ "How to knit a scarf for beginners",
+ "What time is sunset in Tokyo today?",
+ "Who were the members of the Beatles?",
+ "How do volcanoes form?",
+ "Recommend a good fantasy novel",
+ "What is the exchange rate of the euro to the yen?",
+ "Quelle est la capitale de l'Argentine ?",
+ "Comment préparer une pâte à crêpes ?",
+ "Qui a gagné Roland-Garros en 2015 ?",
+ "Quel temps fera-t-il à Nice demain ?",
+ "Comment entretenir un aquarium d'eau douce ?",
+ "Quelle est la distance entre la Terre et la Lune ?",
+ "Quels sont les meilleurs vins de Bourgogne ?",
+ "Comment apprendre à jouer de la guitare ?",
+ "flimbo grastic wuvvle",
+ "trepax zondor kliffum",
+ "asdkj qwpoe zmxn",
+ "lorem quantum frabble",
+ "Combien de temps vit un chat domestique ?",
+ "What is the tallest mountain in Africa?",
+]
+
+/** The embedding function, `embedTexts` in production, so callers pass it without a wrapper. */
+export type ProbeEmbedder = (
+ texts: string[],
+ config: Config,
+ inputType: "query",
+ signal?: AbortSignal,
+) => Promise
+
+const MAX_MEASURED_FLOORS = 4
+// Noise floors measured at search time for indexes built before ingestion stored one.
+const measuredFloors = new Map>()
+
+/**
+ * Measure the noise floor of an index: embed the probes as queries and take the smallest exact
+ * distance to their nearest passage. Returns null for local-hash indexes and empty tables.
+ */
+export async function measureAbstentionCalibration(
+ table: Table,
+ config: Config,
+ embed: ProbeEmbedder,
+ signal?: AbortSignal,
+): Promise {
+ if (config.embeddingProvider !== "transformers") {
+ return null
+ }
+ const vectors = await embed([...ABSTENTION_PROBES], config, "query", signal)
+ const distances: number[] = []
+ for (const vector of vectors) {
+ const [nearest] = await table
+ .vectorSearch(vector)
+ .bypassVectorIndex()
+ .select(["id"])
+ .limit(1)
+ .toArray()
+ if (typeof nearest?._distance === "number" && Number.isFinite(nearest._distance)) {
+ distances.push(nearest._distance)
+ }
+ }
+ if (distances.length === 0) {
+ return null
+ }
+ return {
+ schemaVersion: 1,
+ embeddingModel: config.embeddingModel,
+ probeCount: distances.length,
+ noiseFloorDistance: Math.min(...distances),
+ }
+}
+
+/**
+ * The noise floor search applies: stored by ingestion, or measured once per process and index
+ * generation for an index built before ingestion stored it. A failed measurement keeps the
+ * policy's own distances.
+ */
+export function indexNoiseFloor(
+ table: Table,
+ config: Config,
+ manifest: IndexManifest | null,
+ generationKey: string,
+ embed: ProbeEmbedder,
+): Promise {
+ if (config.embeddingProvider !== "transformers" || !manifest?.chunkCount) {
+ return Promise.resolve(null)
+ }
+ const stored = manifestNoiseFloor(manifest, config)
+ if (stored !== null) {
+ return Promise.resolve(stored)
+ }
+ const cacheKey = `${generationKey}\0${config.embeddingModel}`
+ let measured = measuredFloors.get(cacheKey)
+ if (!measured) {
+ measured = measureAbstentionCalibration(table, config, embed)
+ .then((calibration) => calibration?.noiseFloorDistance ?? null)
+ .catch(() => null)
+ measuredFloors.set(cacheKey, measured)
+ for (const key of measuredFloors.keys()) {
+ if (measuredFloors.size <= MAX_MEASURED_FLOORS) break
+ measuredFloors.delete(key)
+ }
+ }
+ return measured
+}
+
+/**
+ * Advice for a semantic index built before ingestion stored its noise floor: every process then
+ * measures it on its first search, which cost 0.1 to 0.3 s on real bases.
+ */
+export function noiseFloorAdvice(
+ manifest: IndexManifest | null,
+ config: Config,
+ run: (args: string[]) => string,
+): string | null {
+ if (
+ config.embeddingProvider !== "transformers" ||
+ !manifest?.chunkCount ||
+ manifestNoiseFloor(manifest, config) !== null
+ ) {
+ return null
+ }
+ return `The index has no stored noise floor, so each process measures it on its first search. Run \`${run(["ingest"])}\` to store it.`
+}
+
+/** The calibration stored with the index, when it was measured with the configured model. */
+export function manifestNoiseFloor(manifest: IndexManifest | null, config: Config): number | null {
+ const calibration = manifest?.abstentionCalibration
+ if (
+ config.embeddingProvider !== "transformers" ||
+ calibration?.schemaVersion !== 1 ||
+ calibration.embeddingModel !== config.embeddingModel
+ ) {
+ return null
+ }
+ return calibration.noiseFloorDistance
+}
diff --git a/packages/ragmir-core/src/chunk-stats.ts b/packages/ragmir-core/src/chunk-stats.ts
index ea4b6bfc..83046931 100644
--- a/packages/ragmir-core/src/chunk-stats.ts
+++ b/packages/ragmir-core/src/chunk-stats.ts
@@ -1,3 +1,4 @@
+import { percentile } from "./statistics.js"
import type { ChunkStats } from "./types.js"
interface ChunkShape {
@@ -33,8 +34,3 @@ export function summarizeChunkStats(chunks: readonly ChunkShape[]): ChunkStats {
contextualRatio: contextualChunks / chunks.length,
}
}
-
-function percentile(sortedValues: number[], quantile: number): number {
- const index = Math.ceil(quantile * sortedValues.length) - 1
- return sortedValues[Math.max(0, index)] ?? 0
-}
diff --git a/packages/ragmir-core/src/config-migration.test.ts b/packages/ragmir-core/src/config-migration.test.ts
index 37e4296b..66101729 100644
--- a/packages/ragmir-core/src/config-migration.test.ts
+++ b/packages/ragmir-core/src/config-migration.test.ts
@@ -77,6 +77,18 @@ describe("source-preserving migration", { timeout: integrationTimeout(20_000) },
})
})
+ it("should report retired fields with a typed migration code before an upgrade", async () => {
+ const { root, configPath } = await fixture()
+ await writeFile(configPath, JSON.stringify({ privacyProfile: "private", accessLog: true }))
+
+ await expect(loadConfig(root)).rejects.toMatchObject({
+ name: "RagmirError",
+ code: "CONFIG_MIGRATION_REQUIRED",
+ retryable: false,
+ message: expect.stringContaining("Run `rgr upgrade`"),
+ })
+ })
+
it("should keep formerly blocked external operations disabled when migrating strict mode", async () => {
const { root, configPath } = await fixture()
await writeFile(
diff --git a/packages/ragmir-core/src/config.ts b/packages/ragmir-core/src/config.ts
index 92e9e743..a2eb05af 100644
--- a/packages/ragmir-core/src/config.ts
+++ b/packages/ragmir-core/src/config.ts
@@ -32,6 +32,7 @@ import {
MAX_WORKLOAD_QUEUE_TIMEOUT_MS,
SHARED_CONFIG_FILENAME,
} from "./defaults.js"
+import { RagmirError } from "./errors.js"
import { isRecord } from "./guards.js"
import type { Config, WorkloadLimit } from "./types.js"
@@ -198,7 +199,7 @@ const rawConfigSchema = z
type RawConfig = z.infer
-export const RETIRED_CONFIG_KEYS = [
+const RETIRED_CONFIG_KEYS = [
"privacyProfile",
"acceptedRisks",
"redaction",
@@ -304,7 +305,10 @@ export async function loadConfig(start = process.cwd()): Promise {
const raw = await readMergedProjectConfig(projectConfig)
const retired = retiredConfigKeys(raw)
if (retired.length > 0) {
- throw new Error(
+ // A typed code lets agents and MCP clients route to `rgr upgrade` instead of reporting an
+ // internal failure.
+ throw new RagmirError(
+ "CONFIG_MIGRATION_REQUIRED",
`Retired configuration fields: ${retired.join(", ")}. Run \`rgr upgrade\` to back up and migrate the config and rebuild the index. Ragmir now preserves source text without masking; your consuming model controls where retrieved content is sent.`,
)
}
@@ -439,7 +443,7 @@ function configWithModelIdentityDefaults(
}
/** Budgets each retrieval profile chooses for fields the config leaves unset. */
-export const RETRIEVAL_PROFILE_BUDGETS: Readonly<
+const RETRIEVAL_PROFILE_BUDGETS: Readonly<
Record<
Exclude,
{ topK: number; hybridTextScanLimit: number }
diff --git a/packages/ragmir-core/src/defaults.ts b/packages/ragmir-core/src/defaults.ts
index 282d3868..e56f200b 100644
--- a/packages/ragmir-core/src/defaults.ts
+++ b/packages/ragmir-core/src/defaults.ts
@@ -3,7 +3,7 @@ import type { Config } from "./types.js"
export const RAGMIR_DIR = ".ragmir"
export const LEGACY_KB_DIR = ".kb"
export const LEGACY_PRIVATE_DIR = "private"
-export const RAGMIR_RAW_DIR = `${RAGMIR_DIR}/raw`
+const RAGMIR_RAW_DIR = `${RAGMIR_DIR}/raw`
export const CONFIG_PATH = `${RAGMIR_DIR}/config.json`
/** Optional project-level config meant to be committed; `.ragmir/config.json` overrides its keys. */
export const SHARED_CONFIG_FILENAME = "ragmir.config.json"
@@ -23,6 +23,7 @@ export const MAX_INGEST_CHUNK_WINDOW = 8_192
export const MAX_INGEST_CHUNKS_PER_FILE = 65_536
export const MAX_INGEST_VECTOR_BYTES_PER_FILE = 256 * 1_024 * 1_024
export const MAX_SEARCH_TOP_K = 100
+/** A document can fill at most one full result page. @alias */
export const MAX_CHUNKS_PER_DOCUMENT = MAX_SEARCH_TOP_K
export const MAX_HYBRID_TEXT_SCAN_LIMIT = 10_000
export const MAX_CONFIG_ARRAY_ITEMS = 10_000
@@ -39,9 +40,9 @@ export const MAX_WORKLOAD_QUEUE_TIMEOUT_MS = 900_000
export const MAX_CONFIG_NAME_CHARACTERS = 120
export const MAX_SOURCE_WEIGHT = 10
export const MAX_SEARCH_SCOPES = 64
-export const DEFAULT_EMBEDDING_MODEL = "intfloat/multilingual-e5-small"
-export const DEFAULT_EMBEDDING_MODEL_REVISION = "614241f622f53c4eeff9890bdc4f31cfecc418b3"
-export const KNOWN_EMBEDDING_MODEL_REVISIONS: Readonly> = {
+const DEFAULT_EMBEDDING_MODEL = "intfloat/multilingual-e5-small"
+const DEFAULT_EMBEDDING_MODEL_REVISION = "614241f622f53c4eeff9890bdc4f31cfecc418b3"
+const KNOWN_EMBEDDING_MODEL_REVISIONS: Readonly> = {
[DEFAULT_EMBEDDING_MODEL]: DEFAULT_EMBEDDING_MODEL_REVISION,
"mixedbread-ai/mxbai-embed-xsmall-v1": "e6ac24e5d6efb8782b59de1647b3ececb4ece94e",
}
@@ -59,7 +60,6 @@ export function defaultEmbeddingModelRevision(model: string): string {
export const RAGMIR_GITIGNORE_ENTRY = `${RAGMIR_DIR}/`
export const LEGACY_KB_GITIGNORE_ENTRY = `${LEGACY_KB_DIR}/`
export const LEGACY_PRIVATE_GITIGNORE_ENTRY = `${LEGACY_PRIVATE_DIR}/`
-export const LEGACY_PRIVATE_GITIGNORE_FALLBACK_ENTRY = `${LEGACY_PRIVATE_DIR}/**`
export const DEFAULT_CONFIG: Omit = {
retrievalProfile: "balanced",
diff --git a/packages/ragmir-core/src/doctor.ts b/packages/ragmir-core/src/doctor.ts
index a4c2bddb..6a1b67d9 100644
--- a/packages/ragmir-core/src/doctor.ts
+++ b/packages/ragmir-core/src/doctor.ts
@@ -1,5 +1,6 @@
import { existsSync } from "node:fs"
import path from "node:path"
+import { noiseFloorAdvice } from "./abstention-calibration.js"
import {
findProjectConfig,
loadConfig,
@@ -25,8 +26,9 @@ import {
MCP_CONFIG_FILENAME,
SKILL_NAMES,
} from "./skill.js"
+import { fragmentationWarning } from "./storage-maintenance.js"
import { readIndexManifest, readIndexManifestHeader } from "./store.js"
-import type { Config, DoctorOptions, DoctorReport } from "./types.js"
+import type { Config, DoctorOptions, DoctorReport, IndexMaintenanceSnapshot } from "./types.js"
export async function doctor(
cwd = process.cwd(),
@@ -129,6 +131,8 @@ export async function doctorWithConfig(
config.retrievalProfile,
)
: []
+ const runCommand = (args: string[]): string =>
+ command.display + (args.length > 0 ? ` ${args.join(" ")}` : "")
const nextSteps = nextActions({
initialized,
profileOverrides: profileOverrides.map(
@@ -159,7 +163,9 @@ export async function doctorWithConfig(
freshnessWarning,
lexicalIndexCurrent,
lexicalScanWarning,
- run: (args) => command.display + (args.length > 0 ? ` ${args.join(" ")}` : ""),
+ fragments: manifest?.maintenance?.fragments,
+ noiseFloorAdvice: noiseFloorAdvice(manifest, config, runCommand),
+ run: runCommand,
})
throwIfAborted(signal)
@@ -235,6 +241,9 @@ interface NextActionInput {
freshnessWarning: string | null
lexicalIndexCurrent: boolean
lexicalScanWarning: string | null
+ /** Fragment counts from the manifest's last maintenance snapshot. */
+ fragments: IndexMaintenanceSnapshot["fragments"] | undefined
+ noiseFloorAdvice: string | null
run: (args: string[]) => string
}
@@ -308,6 +317,15 @@ function nextActions(input: NextActionInput): string[] {
steps.push(input.lexicalScanWarning)
}
+ const storageWarning = fragmentationWarning(input.fragments, input.run)
+ if (storageWarning) {
+ steps.push(storageWarning)
+ }
+
+ if (input.noiseFloorAdvice) {
+ steps.push(input.noiseFloorAdvice)
+ }
+
if (input.concentrationWarning) {
steps.push(input.concentrationWarning)
}
diff --git a/packages/ragmir-core/src/errors.ts b/packages/ragmir-core/src/errors.ts
index 5982fa1f..bcb456e3 100644
--- a/packages/ragmir-core/src/errors.ts
+++ b/packages/ragmir-core/src/errors.ts
@@ -1,6 +1,7 @@
export type RagmirErrorCode =
| "ABORTED"
| "CLIENT_CLOSED"
+ | "CONFIG_MIGRATION_REQUIRED"
| "EVIDENCE_CHANGED"
| "INDEX_BUSY"
| "INDEX_UNAVAILABLE"
diff --git a/packages/ragmir-core/src/evaluate.ts b/packages/ragmir-core/src/evaluate.ts
index c734860f..8bfa58a3 100644
--- a/packages/ragmir-core/src/evaluate.ts
+++ b/packages/ragmir-core/src/evaluate.ts
@@ -13,6 +13,7 @@ import { fingerprintIndexManifest, fingerprintQualityReport } from "./quality-re
import { searchWithConfig } from "./query.js"
import { rankingPolicyFingerprint, rankingPolicyFor } from "./ranking.js"
import { canonicalUserPath } from "./source-paths.js"
+import { percentile } from "./statistics.js"
import {
closeIndexReadSnapshot,
closeStoreConnection,
@@ -245,7 +246,7 @@ export async function evaluateGoldenQueries(options: EvaluationOptions): Promise
return evaluateGoldenQueriesWithConfig(options, config)
}
-export async function evaluateGoldenQueriesWithConfig(
+async function evaluateGoldenQueriesWithConfig(
options: EvaluationOptions,
config: Config,
): Promise {
@@ -974,14 +975,6 @@ function citationForResult(result: { citation: string }): string {
return result.citation
}
-function percentile(sortedValues: number[], quantile: number): number {
- if (sortedValues.length === 0) {
- return 0
- }
- const index = Math.ceil(quantile * sortedValues.length) - 1
- return sortedValues[Math.max(0, index)] ?? 0
-}
-
function mean(values: number[]): number {
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
}
diff --git a/packages/ragmir-core/src/files.ts b/packages/ragmir-core/src/files.ts
index 05ab7197..6c7015fa 100644
--- a/packages/ragmir-core/src/files.ts
+++ b/packages/ragmir-core/src/files.ts
@@ -109,7 +109,7 @@ const DEFAULT_SUPPORTED_FILE_NAMES = new Set([
"procfile",
"rakefile",
])
-export const DEFAULT_FAST_GLOB_IGNORES = [
+const DEFAULT_FAST_GLOB_IGNORES = [
"**/.git/**",
"**/node_modules/**",
`**/${LEGACY_KB_DIR}/**`,
@@ -119,7 +119,7 @@ export const DEFAULT_FAST_GLOB_IGNORES = [
* Tool state, dependency caches, lockfiles, and generated bundles that drown out project knowledge.
* They are skipped unless `defaultExcludes` is false or a source entry names the directory itself.
*/
-export const DEFAULT_EXCLUDED_DIRECTORIES = [
+const DEFAULT_EXCLUDED_DIRECTORIES = [
".agents",
".angular",
".cache",
@@ -155,7 +155,7 @@ export const DEFAULT_EXCLUDED_DIRECTORIES = [
"DerivedData",
"Pods",
] as const
-export const DEFAULT_EXCLUDED_FILES = [
+const DEFAULT_EXCLUDED_FILES = [
"*.map",
"*.min.css",
"*.min.js",
@@ -221,7 +221,7 @@ export interface SourceInventoryOptions extends OperationOptions {
writeFingerprintCache?: boolean
}
-export const DEFAULT_SUPPORTED_EXTENSIONS = new Set([
+const DEFAULT_SUPPORTED_EXTENSIONS = new Set([
".atom",
".adoc",
".astro",
@@ -778,7 +778,7 @@ async function mapLimit(
await Promise.all(workers)
}
-export function supportedExtensions(config: Config): Set {
+function supportedExtensions(config: Config): Set {
return new Set([
...DEFAULT_SUPPORTED_EXTENSIONS,
...(config.imageOcrCommand.length > 0 ? OCR_IMAGE_EXTENSIONS : []),
@@ -929,7 +929,7 @@ function sourcePattern(projectRoot: string, input: string): string {
return canonicalSourcePath(pattern).replace(/^(?:\.\/)+/u, "")
}
-export function isSensitiveFilePath(absolutePath: string): boolean {
+function isSensitiveFilePath(absolutePath: string): boolean {
const baseName = path.basename(absolutePath).toLowerCase()
const extension = path.extname(absolutePath).toLowerCase()
return (
diff --git a/packages/ragmir-core/src/git-hooks.ts b/packages/ragmir-core/src/git-hooks.ts
index 9248f6d5..34f38f60 100644
--- a/packages/ragmir-core/src/git-hooks.ts
+++ b/packages/ragmir-core/src/git-hooks.ts
@@ -6,12 +6,7 @@ import { RAGMIR_DIR } from "./defaults.js"
import { RGR_RUNNER_FILENAME } from "./package-manager.js"
import { canonicalSourcePath } from "./source-paths.js"
-export const GIT_HOOK_NAMES = [
- "post-checkout",
- "post-commit",
- "post-merge",
- "post-rewrite",
-] as const
+const GIT_HOOK_NAMES = ["post-checkout", "post-commit", "post-merge", "post-rewrite"] as const
export type GitHookName = (typeof GIT_HOOK_NAMES)[number]
const BLOCK_START = "# >>> ragmir auto-ingest >>>"
diff --git a/packages/ragmir-core/src/gitignore.ts b/packages/ragmir-core/src/gitignore.ts
index 8d4df22b..8b12c3f5 100644
--- a/packages/ragmir-core/src/gitignore.ts
+++ b/packages/ragmir-core/src/gitignore.ts
@@ -3,7 +3,7 @@ import { readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import { RAGMIR_GITIGNORE_ENTRY } from "./defaults.js"
-export const RAGMIR_GITIGNORE_ENTRIES = [RAGMIR_GITIGNORE_ENTRY]
+const RAGMIR_GITIGNORE_ENTRIES = [RAGMIR_GITIGNORE_ENTRY]
export async function ensureRagmirGitignore(
cwd = process.cwd(),
diff --git a/packages/ragmir-core/src/index-write-lock.ts b/packages/ragmir-core/src/index-write-lock.ts
index ca3b9c90..0ba9b5f6 100644
--- a/packages/ragmir-core/src/index-write-lock.ts
+++ b/packages/ragmir-core/src/index-write-lock.ts
@@ -8,7 +8,7 @@ import { isRecord } from "./guards.js"
import { throwIfAborted } from "./operation.js"
import { ensurePrivateDirectory, hardenPrivateFile } from "./permissions.js"
-export const INDEX_WRITE_LOCK_DIRECTORY = ".writer-lock"
+const INDEX_WRITE_LOCK_DIRECTORY = ".writer-lock"
const RECOVERY_LOCK_DIRECTORY = ".writer-lock-recovery"
const OWNER_FILENAME = "owner.json"
const DEFAULT_WAIT_TIMEOUT_MS = 30_000
diff --git a/packages/ragmir-core/src/ingest.ts b/packages/ragmir-core/src/ingest.ts
index 9cdcec59..1c8eb84e 100644
--- a/packages/ragmir-core/src/ingest.ts
+++ b/packages/ragmir-core/src/ingest.ts
@@ -1,4 +1,5 @@
import type { Connection } from "@lancedb/lancedb"
+import { measureAbstentionCalibration } from "./abstention-calibration.js"
import { summarizeChunkStats } from "./chunk-stats.js"
import { chunkDocument, chunkLexicalText, chunkSearchText } from "./chunking.js"
import { loadConfig } from "./config.js"
@@ -66,6 +67,7 @@ import {
writeIndexManifest,
} from "./store.js"
import type {
+ AbstentionCalibration,
AuditOptions,
AuditReport,
Config,
@@ -415,6 +417,13 @@ async function ingestUnlocked(
}
storageWarning = combineWarnings(storageWarning, maintenance.warning)
await validateIngestionTable(state, manifest, config, connection)
+ const abstentionCalibration =
+ manifest.chunkCount > 0
+ ? await measureIndexCalibration(tableName, config, connection, signal)
+ : null
+ if (abstentionCalibration) {
+ manifest = { ...manifest, abstentionCalibration }
+ }
await writeEmptyTextFiles(emptyTextRecords(state), config)
await writeIndexManifest(manifest, config, indexedFilesFromState(state))
try {
@@ -478,6 +487,33 @@ async function ingestUnlocked(
}
}
+/**
+ * Store where off-topic questions land in the new index, so search reads its noise floor instead of
+ * measuring it. Calibration is an optimization: a failure keeps the index and the policy distances.
+ */
+async function measureIndexCalibration(
+ tableName: string,
+ config: Config,
+ connection: Connection | undefined,
+ signal: AbortSignal | undefined,
+): Promise {
+ if (config.embeddingProvider !== "transformers") {
+ return null
+ }
+ const table = await openRowsTableByName(tableName, config, connection)
+ if (!table) {
+ return null
+ }
+ try {
+ return await measureAbstentionCalibration(table, config, embedTexts, signal)
+ } catch {
+ throwIfAborted(signal)
+ return null
+ } finally {
+ closeRowsTable(table, config)
+ }
+}
+
function combineWarnings(...warnings: Array): string | null {
const present = warnings.filter((warning): warning is string => warning !== null)
return present.length > 0 ? present.join(" ") : null
@@ -955,6 +991,7 @@ export interface IngestionMetadataValidationOptions {
fileRows: AsyncIterable
}
+/** @public Benchmarks import it from the build. */
export async function validateIngestionMetadata(
options: IngestionMetadataValidationOptions,
): Promise {
diff --git a/packages/ragmir-core/src/ingestion-state.ts b/packages/ragmir-core/src/ingestion-state.ts
index 6a5ab7e9..6cd4cae7 100644
--- a/packages/ragmir-core/src/ingestion-state.ts
+++ b/packages/ragmir-core/src/ingestion-state.ts
@@ -4,10 +4,10 @@ import { open as openFile, readdir, readFile, rm } from "node:fs/promises"
import path from "node:path"
import { syncDirectory, writePrivateFileAtomic } from "./durable-file.js"
import { isRecord } from "./guards.js"
+import { acceptJsonLines } from "./json-lines.js"
import { ensurePrivateDirectory, hardenPrivateFile } from "./permissions.js"
import type {
Config,
- IndexManifest,
IndexManifestFile,
IngestionFileStage,
IngestionProgress,
@@ -305,14 +305,6 @@ export async function compactIngestionState(
runtime.dirtyFiles.clear()
}
-export async function writeStagedIndexManifest(
- manifest: IndexManifest,
- runId: string,
- config: Config,
-): Promise {
- await writePrivateJsonAtomic(stagedManifestPath(runId, config), manifest, config.storageDir)
-}
-
export async function removeStagedIndexManifest(runId: string, config: Config): Promise {
await rm(stagedManifestPath(runId, config), { force: true })
}
@@ -445,6 +437,8 @@ async function applyIngestionJournal(
runtime: IngestionRuntime,
snapshotUpdatedAt: string,
): Promise {
+ // Unlike the file snapshot, the append-only journal skips empty lines and ignores a final line
+ // without a newline: an append interrupted by a crash must not discard the entries before it.
const stream = createReadStream(path.join(config.storageDir, INGESTION_JOURNAL_FILENAME))
stream.setEncoding("utf8")
let buffered = ""
@@ -578,17 +572,7 @@ async function readIngestionFileSnapshot(
): Promise<{ values: IngestionFileState[]; byPath: Map } | null> {
const values: IngestionFileState[] = []
const byPath = new Map()
- const stream = createReadStream(path.join(config.storageDir, header.fileSnapshot))
- stream.setEncoding("utf8")
- let buffered = ""
-
- const applyLine = (line: string): boolean => {
- let value: unknown
- try {
- value = JSON.parse(line) as unknown
- } catch {
- return false
- }
+ const accept = (value: unknown): boolean => {
if (!isIngestionFileState(value, header.policyFingerprint) || byPath.has(value.relativePath)) {
return false
}
@@ -598,22 +582,11 @@ async function readIngestionFileSnapshot(
}
try {
- for await (const chunk of stream) {
- buffered += typeof chunk === "string" ? chunk : chunk.toString("utf8")
- let lineEnd = buffered.indexOf("\n")
- while (lineEnd >= 0) {
- const line = buffered.slice(0, lineEnd)
- buffered = buffered.slice(lineEnd + 1)
- if (!line || !applyLine(line)) {
- return null
- }
- lineEnd = buffered.indexOf("\n")
- }
- }
- if (buffered && !applyLine(buffered)) {
- return null
- }
- return values.length === header.fileCount ? { values, byPath } : null
+ const complete = await acceptJsonLines(
+ path.join(config.storageDir, header.fileSnapshot),
+ accept,
+ )
+ return complete && values.length === header.fileCount ? { values, byPath } : null
} catch (error) {
if (isNodeError(error) && error.code === "ENOENT") {
return null
@@ -627,19 +600,8 @@ function isIngestionRunState(value: unknown, config: Config): value is Ingestion
!isRecord(value) ||
(value.version !== LEGACY_INGESTION_STATE_VERSION &&
value.version !== INGESTION_STATE_VERSION) ||
- !isUuidV4(value.runId) ||
- (value.mode !== "incremental" && value.mode !== "rebuild") ||
- !isIngestionRunStatus(value.status) ||
- typeof value.tableName !== "string" ||
- (value.previousTableName !== null && typeof value.previousTableName !== "string") ||
- typeof value.policyFingerprint !== "string" ||
- value.policyFingerprint.length === 0 ||
- !isPositiveSafeInteger(value.batchSize) ||
- !isIsoTimestamp(value.createdAt) ||
- !isIsoTimestamp(value.updatedAt) ||
- !isIsoTimestamp(value.lastActivityAt) ||
- typeof value.resumed !== "boolean" ||
- !Array.isArray(value.files)
+ !Array.isArray(value.files) ||
+ !isIngestionRunMetadata(value, config)
) {
return false
}
@@ -648,22 +610,7 @@ function isIngestionRunState(value: unknown, config: Config): value is Ingestion
if (!value.files.every((file) => isIngestionFileState(file, policyFingerprint))) {
return false
}
-
- const uniquePaths = new Set(value.files.map((file) => file.relativePath))
- if (uniquePaths.size !== value.files.length || !isManagedTableName(value.tableName, config)) {
- return false
- }
-
- if (value.mode === "incremental") {
- return value.previousTableName === null
- }
-
- return (
- value.tableName === generationTableName(config.tableName, value.runId) &&
- value.previousTableName !== null &&
- value.previousTableName !== value.tableName &&
- isManagedTableName(value.previousTableName, config)
- )
+ return new Set(value.files.map((file) => file.relativePath)).size === value.files.length
}
function isPersistedIngestionRunHeader(
diff --git a/packages/ragmir-core/src/json-lines.ts b/packages/ragmir-core/src/json-lines.ts
new file mode 100644
index 00000000..12670fbd
--- /dev/null
+++ b/packages/ragmir-core/src/json-lines.ts
@@ -0,0 +1,37 @@
+import { createReadStream } from "node:fs"
+
+/**
+ * Read a JSON Lines file and hand each parsed line to `accept`. Returns false as soon as a line is
+ * empty, not JSON, or rejected, so a truncated or tampered file is never half trusted. Errors
+ * opening or reading the file, ENOENT included, reach the caller.
+ */
+export async function acceptJsonLines(
+ filePath: string,
+ accept: (value: unknown) => boolean,
+): Promise {
+ const acceptLine = (line: string): boolean => {
+ let value: unknown
+ try {
+ value = JSON.parse(line) as unknown
+ } catch {
+ return false
+ }
+ return accept(value)
+ }
+ const stream = createReadStream(filePath)
+ stream.setEncoding("utf8")
+ let buffered = ""
+ for await (const chunk of stream) {
+ buffered += typeof chunk === "string" ? chunk : chunk.toString("utf8")
+ let lineEnd = buffered.indexOf("\n")
+ while (lineEnd >= 0) {
+ const line = buffered.slice(0, lineEnd)
+ buffered = buffered.slice(lineEnd + 1)
+ if (!line || !acceptLine(line)) {
+ return false
+ }
+ lineEnd = buffered.indexOf("\n")
+ }
+ }
+ return !buffered || acceptLine(buffered)
+}
diff --git a/packages/ragmir-core/src/ocr-cache.ts b/packages/ragmir-core/src/ocr-cache.ts
index 92b934b9..6366caf1 100644
--- a/packages/ragmir-core/src/ocr-cache.ts
+++ b/packages/ragmir-core/src/ocr-cache.ts
@@ -27,7 +27,7 @@ interface PdfOcrCacheRecord extends PdfOcrCacheIdentity {
text: string
}
-export function pdfOcrCacheKey(identity: PdfOcrCacheIdentity): string {
+function pdfOcrCacheKey(identity: PdfOcrCacheIdentity): string {
return createHash("sha256").update(JSON.stringify(identity)).digest("hex")
}
diff --git a/packages/ragmir-core/src/ocr.ts b/packages/ragmir-core/src/ocr.ts
index f99ff7be..b9eee119 100644
--- a/packages/ragmir-core/src/ocr.ts
+++ b/packages/ragmir-core/src/ocr.ts
@@ -15,7 +15,7 @@ import { mutateProjectConfig } from "./project-config-file.js"
const OCR_COMMAND_TIMEOUT_MS = 120_000
const OCR_PROBE_TIMEOUT_MS = 10_000
const OCR_PROCESS_KILL_GRACE_MS = 1_000
-export const OCR_RENDER_DPI = 300
+const OCR_RENDER_DPI = 300
/** Longest rendered page side in pixels: US Legal at 300 DPI, so standard paper keeps 300 DPI. */
const MAX_OCR_RENDER_PIXELS = 4_200
export const MAX_OCR_BATCH_PAGES = 16
diff --git a/packages/ragmir-core/src/package-manager.ts b/packages/ragmir-core/src/package-manager.ts
index 870d4e7e..c410f4ac 100644
--- a/packages/ragmir-core/src/package-manager.ts
+++ b/packages/ragmir-core/src/package-manager.ts
@@ -66,7 +66,9 @@ function displayRunnerCommand(root: string, runnerPath: string, args: string[]):
return `node ${suffix}`
}
+/** @deprecated Use `rgrCommand`. @alias */
export const ragmirCommand = rgrCommand
+/** @deprecated Use `rgrCommand`. @alias */
export const kbCommand = rgrCommand
async function packageJsonManager(root: string): Promise {
diff --git a/packages/ragmir-core/src/quality-report.ts b/packages/ragmir-core/src/quality-report.ts
index 75c83039..7d756871 100644
--- a/packages/ragmir-core/src/quality-report.ts
+++ b/packages/ragmir-core/src/quality-report.ts
@@ -54,7 +54,7 @@ export function summarizeIndexedCorpus(files: Iterable): {
return { fileCount, chunkCount, corpusFingerprint: digest.digest("hex") }
}
-export async function fingerprintFile(filePath: string, signal?: AbortSignal): Promise {
+async function fingerprintFile(filePath: string, signal?: AbortSignal): Promise {
return sha256(await readFile(filePath, signal ? { signal } : undefined))
}
diff --git a/packages/ragmir-core/src/query-sanitizer.ts b/packages/ragmir-core/src/query-sanitizer.ts
index 8d291448..0b2f4116 100644
--- a/packages/ragmir-core/src/query-sanitizer.ts
+++ b/packages/ragmir-core/src/query-sanitizer.ts
@@ -1,6 +1,6 @@
import { RagmirError } from "./errors.js"
-export const MAX_RETRIEVAL_QUERY_LENGTH = 20_000
+const MAX_RETRIEVAL_QUERY_LENGTH = 20_000
export interface SanitizedQuery {
query: string
diff --git a/packages/ragmir-core/src/query.ts b/packages/ragmir-core/src/query.ts
index 884361cb..5de0e52d 100644
--- a/packages/ragmir-core/src/query.ts
+++ b/packages/ragmir-core/src/query.ts
@@ -11,10 +11,11 @@ import {
Operator,
PhraseQuery,
} from "@lancedb/lancedb"
+import { indexNoiseFloor } from "./abstention-calibration.js"
import { citationForCoordinates, stripCitationCoordinates } from "./citation.js"
import { loadConfig } from "./config.js"
import { MAX_SEARCH_TOP_K, VECTOR_DISTANCE_METRIC } from "./defaults.js"
-import { embedText } from "./embeddings.js"
+import { embedText, embedTexts } from "./embeddings.js"
import { RagmirError } from "./errors.js"
import { acquireGenerationReadLease } from "./generation-retention.js"
import { indexFreshnessWarning } from "./index-diagnostics.js"
@@ -33,6 +34,7 @@ import {
abstentionFilter,
fusionCandidateDepth,
fuzzyMatchPrefilter,
+ isWeakAnswer,
queryEvidence,
queryTermCoverage,
rankHybridRows,
@@ -254,6 +256,13 @@ async function searchWithinGeneration(
snapshot.manifest && lexicalPolicyCurrent(snapshot.manifest)
? queryTermFrequencies(table, queryTokens, snapshot).catch(() => null)
: Promise.resolve(null)
+ const noiseFloorPromise = indexNoiseFloor(
+ table,
+ config,
+ snapshot.manifest,
+ `${snapshot.tableName}\0${snapshot.manifestFingerprint ?? ""}`,
+ embedTexts,
+ )
const lexicalCandidatesPromise = lexicalCandidateRows(table, sanitized.query, {
ftsLimit: lexicalCandidateLimit(candidateDemand, config.retrievalProfile),
fallbackBatchSize: config.hybridTextScanLimit,
@@ -327,8 +336,10 @@ async function searchWithinGeneration(
)
const isExactPathMatch = (row: SearchRow): boolean =>
lexicalCandidates.exactPathMatches.has(rowKey(row))
- const passesAbstention = abstentionFilter(evidence, rankingPolicy, termStatistics)
- const hasWeakEvidence = weakEvidenceFilter(evidence, rankingPolicy, termStatistics)
+ const noiseFloor = await noiseFloorPromise
+ throwIfAborted(signal)
+ const passesAbstention = abstentionFilter(evidence, rankingPolicy, termStatistics, noiseFloor)
+ const hasWeakEvidence = weakEvidenceFilter(evidence, rankingPolicy, termStatistics, noiseFloor)
const coverageOf =
options.explain && termStatistics ? queryTermCoverage(evidence, termStatistics) : null
const orderedRows = rankedRows.sort(
@@ -343,7 +354,15 @@ async function searchWithinGeneration(
: {}),
isRelevant: (row) => isExactPathMatch(row) || passesAbstention(row),
})
- const rows = diversity.rows
+ // A semantic answer whose leading passages are all weak holds no evidence: answer nothing rather
+ // than noise. An exact path match is never weak.
+ const rows = isWeakAnswer(
+ diversity.rows,
+ rankingPolicy,
+ (row) => !isExactPathMatch(row.row) && hasWeakEvidence(row.row),
+ )
+ ? []
+ : diversity.rows
const [contextByRow, sourceStatuses] = await Promise.all([
contextChunksByRow(table, rows, contextRadius, retrievalPredicate),
sourceStatusesForRows(config.projectRoot, rows),
@@ -399,6 +418,7 @@ async function searchWithinGeneration(
lexicalIndexedRows: lexicalCandidates.indexedRows,
lexicalUnindexedRows: lexicalCandidates.unindexedRows,
lexicalCoverage: lexicalCandidates.coverage,
+ noiseFloorDistance: noiseFloor,
lexicalScanBatches: lexicalCandidates.scanBatches,
diversityStrategy: "document-cap" as const,
maxChunksPerDocument,
@@ -508,9 +528,11 @@ async function queryTermFrequencies(
measured
.filter((token) => !generationCache.has(token))
.map(async (token) => {
+ // Selecting `_score` keeps LanceDB from projecting it implicitly, which logs a deprecation
+ // warning on stderr for every measured query word.
const rows = await table
.search(new MatchQuery(token, "searchText"), "fts", "searchText")
- .select(["id"])
+ .select(["id", "_score"])
.limit(frequencyLimit)
.toArray()
generationCache.set(token, rows.length)
diff --git a/packages/ragmir-core/src/ranking.test.ts b/packages/ragmir-core/src/ranking.test.ts
index d23e6a6b..a35c3129 100644
--- a/packages/ragmir-core/src/ranking.test.ts
+++ b/packages/ragmir-core/src/ranking.test.ts
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"
import {
abstentionFilter,
candidatePassesAbstention,
+ effectiveVectorDistances,
fusionCandidateDepth,
fuzzyMatchPrefilter,
+ isWeakAnswer,
type QueryTermStatistics,
queryEvidence,
queryTermCoverage,
@@ -11,6 +13,7 @@ import {
rankingPolicyFor,
tokensAreFuzzyRelated,
tokensAreLexicallyRelated,
+ weakEvidenceFilter,
} from "./ranking.js"
import { identifierParts, normalizeForMatch, tokenize } from "./text.js"
@@ -27,7 +30,7 @@ describe("hybrid ranking", () => {
const alpha = row("alpha.md", "Shared policy evidence.", { distance: 0.5, score: 3 })
const beta = row("beta.md", "Shared policy evidence.", { distance: 0.5, score: 3 })
const policy = rankingPolicyFor("local-hash", "balanced", 1)
- expect(policy.version).toBe(7)
+ expect(policy.version).toBe(8)
const forward = rankHybridRows("policy evidence", [beta, alpha], [alpha, beta], policy)
const reversed = rankHybridRows("policy evidence", [alpha, beta], [beta, alpha], policy)
@@ -295,6 +298,78 @@ function row(
}
}
+describe("noise floor abstention", () => {
+ const e5 = rankingPolicyFor("transformers", "quality", 2, "intfloat/multilingual-e5-small")
+ const uncalibrated = rankingPolicyFor(
+ "transformers",
+ "quality",
+ 2,
+ "intfloat/multilingual-e5-base",
+ )
+ const terms: QueryTermStatistics = {
+ documentCount: 100,
+ documentFrequencies: new Map([
+ ["weather", 1],
+ ["forecast", 0],
+ ["tomorrow", 3],
+ ]),
+ }
+
+ it("should cap strong and vocabulary-free distances with the index noise floor", () => {
+ expect(effectiveVectorDistances(e5, 0.27)).toEqual({
+ maximumVectorDistance: 0.4,
+ strongVectorDistance: 0.27,
+ vocabularyFreeDistance: 0.24,
+ })
+ expect(effectiveVectorDistances(uncalibrated, 0.31)).toEqual({
+ maximumVectorDistance: 1.1,
+ strongVectorDistance: 0.31,
+ vocabularyFreeDistance: 0.31,
+ })
+ expect(effectiveVectorDistances(e5, null)).toMatchObject({ maximumVectorDistance: 0.4 })
+ const localHash = rankingPolicyFor("local-hash", "balanced", 1)
+ expect(effectiveVectorDistances(localHash, 0.2)).toMatchObject({ maximumVectorDistance: null })
+ })
+
+ it("should require a passage nearer than any off-topic question when no word is known", () => {
+ const nonsense = queryEvidence("flimbo grastic wuvvle")
+ const unknown: QueryTermStatistics = { documentCount: 100, documentFrequencies: new Map() }
+ const near = row("near.md", "Release notes.", { distance: 0.28 })
+ const noise = row("noise.md", "Release notes.", { distance: 0.6 })
+ const passes = abstentionFilter(nonsense, uncalibrated, unknown, 0.31)
+
+ expect(passes(near)).toBe(true)
+ expect(passes(noise)).toBe(false)
+ expect(abstentionFilter(nonsense, uncalibrated, unknown)(noise)).toBe(true)
+ })
+
+ it("should keep the policy admission distance for questions with known words", () => {
+ const evidence = queryEvidence("weather forecast tomorrow")
+ const semantic = row("semantic.md", "Unrelated release notes.", { distance: 0.33 })
+
+ expect(abstentionFilter(evidence, e5, terms, 0.3)(semantic)).toBe(true)
+ })
+
+ it("should treat a passage nearer than the floor as strong evidence", () => {
+ const evidence = queryEvidence("weather forecast tomorrow")
+ const weak = weakEvidenceFilter(evidence, uncalibrated, terms, 0.32)
+
+ expect(weak(row("close.md", "Daily operations.", { distance: 0.2 }))).toBe(false)
+ expect(weak(row("far.md", "Daily operations.", { distance: 0.4 }))).toBe(true)
+ })
+
+ it("should refuse a semantic answer only when every leading result is weak", () => {
+ const flags = (values: boolean[]) => values.map((weak, index) => ({ index, weak }))
+ const isWeak = (entry: { weak: boolean }) => entry.weak
+
+ expect(isWeakAnswer(flags([true, true, true, true, true, false]), e5, isWeak)).toBe(true)
+ expect(isWeakAnswer(flags([true, false, true]), e5, isWeak)).toBe(false)
+ expect(isWeakAnswer([], e5, isWeak)).toBe(false)
+ const localHash = rankingPolicyFor("local-hash", "balanced", 1)
+ expect(isWeakAnswer(flags([true, true]), localHash, isWeak)).toBe(false)
+ })
+})
+
function rankedKeys(rows: Array<{ row: FixtureRow }>): string[] {
return rows.map(({ row: value }) => `${value.relativePath}\0#${value.chunkIndex}`)
}
diff --git a/packages/ragmir-core/src/ranking.ts b/packages/ragmir-core/src/ranking.ts
index 32080045..44fa56a1 100644
--- a/packages/ragmir-core/src/ranking.ts
+++ b/packages/ragmir-core/src/ranking.ts
@@ -29,7 +29,7 @@ export interface RankedRow {
}
export interface RankingPolicy {
- version: 7
+ version: 8
embeddingProvider: EmbeddingProvider
/** Semantic model whose calibrated evidence profile applies; null for local-hash. */
embeddingModel: string | null
@@ -90,7 +90,8 @@ const TRANSFORMERS_MAXIMUM_VECTOR_DISTANCE = 1.1
// Calibrated on French and English golden sets plus out-of-scope and nonsense questions. e5 packs
// every text into a narrow distance band (relevant passages up to 0.36, unrelated ones from 0.26),
// so its vector channel stays permissive and weak evidence relies more on query-word coverage.
-// mxbai separates them (relevant passages mostly under 1.1, unrelated ones above 1.0).
+// mxbai separates them (relevant passages mostly under 1.1, unrelated ones above 1.0). These
+// absolute distances are upper bounds: each index also caps them with its own noise floor.
const MODEL_EVIDENCE_PROFILES: Readonly> = {
"intfloat/multilingual-e5-small": {
maximumVectorDistance: 0.4,
@@ -99,15 +100,18 @@ const MODEL_EVIDENCE_PROFILES: Readonly> = {
minimumLexicalCoverage: 0.3,
weakEvidenceCoverage: 0.3,
},
+ // A coverage of 0.5 flagged a third of the correct passages of a TypeScript repository as weak:
+ // natural-language questions share few words with code. 0.3 flagged 6 of 96.
"mixedbread-ai/mxbai-embed-xsmall-v1": {
maximumVectorDistance: TRANSFORMERS_MAXIMUM_VECTOR_DISTANCE,
strongVectorDistance: 0.9,
vocabularyFreeDistance: 0.85,
minimumLexicalCoverage: 0.3,
- weakEvidenceCoverage: 0.5,
+ weakEvidenceCoverage: 0.3,
},
}
// Uncalibrated models keep the historical rule: any related query word, or a distance up to 1.1.
+// The index's noise floor then decides which of their passages count as strong evidence.
const DEFAULT_TRANSFORMERS_PROFILE: EvidenceProfile = {
maximumVectorDistance: TRANSFORMERS_MAXIMUM_VECTOR_DISTANCE,
strongVectorDistance: null,
@@ -149,7 +153,7 @@ export function rankingPolicyFor(
DEFAULT_TRANSFORMERS_PROFILE)
: LOCAL_HASH_PROFILE
return {
- version: 7,
+ version: 8,
embeddingProvider,
embeddingModel: semantic ? (embeddingModel ?? null) : null,
retrievalProfile,
@@ -287,20 +291,64 @@ export function candidatePassesAbstention(
row: RankingRow,
policy: RankingPolicy,
terms: QueryTermStatistics | null = null,
+ noiseFloor: number | null = null,
): boolean {
const evidence =
typeof evidenceOrQuery === "string" ? queryEvidence(evidenceOrQuery) : evidenceOrQuery
- return abstentionFilter(evidence, policy, terms)(row)
+ return abstentionFilter(evidence, policy, terms, noiseFloor)(row)
+}
+
+/**
+ * The policy's vector distances with the index's noise floor applied: the distance at which the
+ * nearest passage of an off-topic probe question sits. A model packs every text into its own
+ * band, and a corpus shifts it, so only a passage nearer than any off-topic question counts as
+ * strong evidence, and a question with no known word needs one. The admission distance stays the
+ * policy's: capping it removed correct passages from real developer questions. Local-hash vectors
+ * carry no meaning and keep the policy.
+ */
+export function effectiveVectorDistances(
+ policy: RankingPolicy,
+ noiseFloor: number | null,
+): Pick<
+ RankingPolicy,
+ "maximumVectorDistance" | "strongVectorDistance" | "vocabularyFreeDistance"
+> {
+ if (noiseFloor === null || policy.embeddingProvider === "local-hash") {
+ return policy
+ }
+ const capped = (distance: number | null): number =>
+ distance === null ? noiseFloor : Math.min(distance, noiseFloor)
+ return {
+ maximumVectorDistance: policy.maximumVectorDistance,
+ strongVectorDistance: capped(policy.strongVectorDistance),
+ vocabularyFreeDistance: capped(policy.vocabularyFreeDistance ?? policy.maximumVectorDistance),
+ }
+}
+
+/** How many leading results must all be weak before semantic search answers nothing. */
+const WEAK_ANSWER_DEPTH = 5
+
+/**
+ * Whether a semantic answer holds no real evidence: every leading result is weak. Off-topic and
+ * near-domain questions without an answer typically land here. Local-hash search keeps such
+ * results, since its weak flag was never calibrated to refuse.
+ */
+export function isWeakAnswer(
+ rows: readonly Row[],
+ policy: RankingPolicy,
+ isWeak: (row: Row) => boolean,
+): boolean {
+ if (policy.embeddingProvider === "local-hash" || rows.length === 0) {
+ return false
+ }
+ return rows.slice(0, WEAK_ANSWER_DEPTH).every(isWeak)
}
/**
* Whether no query word occurs in the corpus, exactly or through a fuzzy keyword match. Only
* vector evidence can then support a passage, and nonsense or unrelated questions get none.
*/
-export function vocabularyFree(
- evidence: QueryEvidence,
- terms: QueryTermStatistics | null,
-): boolean {
+function vocabularyFree(evidence: QueryEvidence, terms: QueryTermStatistics | null): boolean {
return (
terms !== null &&
evidence.anchors.length === 0 &&
@@ -324,6 +372,7 @@ export function abstentionFilter(
evidence: QueryEvidence,
policy: RankingPolicy,
terms: QueryTermStatistics | null = null,
+ noiseFloor: number | null = null,
): (row: RankingRow) => boolean {
const hasLexicalEvidence = lexicalEvidenceMatcher(evidence)
if (evidence.anchors.length > 0) {
@@ -333,9 +382,10 @@ export function abstentionFilter(
if (policy.embeddingProvider === "local-hash") {
return noKnownWords ? () => false : (row) => hasLexicalEvidence(row.searchText)
}
- const maximumVectorDistance = policy.maximumVectorDistance
+ const distances = effectiveVectorDistances(policy, noiseFloor)
+ const maximumVectorDistance = distances.maximumVectorDistance
if (noKnownWords) {
- const required = policy.vocabularyFreeDistance ?? maximumVectorDistance
+ const required = distances.vocabularyFreeDistance ?? maximumVectorDistance
return (row) => required !== null && rowDistance(row) <= required
}
const coverage =
@@ -355,12 +405,13 @@ export function weakEvidenceFilter(
evidence: QueryEvidence,
policy: RankingPolicy,
terms: QueryTermStatistics | null,
+ noiseFloor: number | null = null,
): (row: RankingRow) => boolean {
if (terms === null || evidence.anchors.length > 0 || evidence.tokens.length === 0) {
return () => false
}
const coverage = queryTermCoverage(evidence, terms)
- const strong = policy.strongVectorDistance
+ const strong = effectiveVectorDistances(policy, noiseFloor).strongVectorDistance
return (row) =>
coverage(row.searchText) < policy.weakEvidenceCoverage &&
!(strong !== null && rowDistance(row) <= strong)
diff --git a/packages/ragmir-core/src/skill.ts b/packages/ragmir-core/src/skill.ts
index b1345208..51674807 100644
--- a/packages/ragmir-core/src/skill.ts
+++ b/packages/ragmir-core/src/skill.ts
@@ -96,7 +96,7 @@ const MCP_SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]+$/u
const MANAGED_SKILL_METADATA_FILENAME = ".ragmir-managed.json"
export const SKILL_NAMES = [PRIMARY_SKILL_NAME] as const
/** Skills of retired commands (audio, reports, legal dossiers); their instructions now fail. */
-export const RETIRED_SKILL_NAMES = [
+const RETIRED_SKILL_NAMES = [
"ragmir-audio-summary",
"ragmir-legal-dossier",
"ragmir-markdown-report",
diff --git a/packages/ragmir-core/src/source-fingerprint-cache.ts b/packages/ragmir-core/src/source-fingerprint-cache.ts
index abcec664..307d9161 100644
--- a/packages/ragmir-core/src/source-fingerprint-cache.ts
+++ b/packages/ragmir-core/src/source-fingerprint-cache.ts
@@ -1,9 +1,9 @@
import { randomUUID } from "node:crypto"
-import { createReadStream } from "node:fs"
import { open as openFile, rm } from "node:fs/promises"
import path from "node:path"
import { renameWithRetry } from "./fs-retry.js"
import { isRecord } from "./guards.js"
+import { acceptJsonLines } from "./json-lines.js"
import { ensurePrivateDirectory, hardenPrivateFile } from "./permissions.js"
import type { Config, SourceFingerprintMode } from "./types.js"
@@ -31,18 +31,9 @@ export async function readSourceFingerprintCache(
config: Config,
): Promise