ragx-cli 0.3.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/CHANGELOG.md +29 -0
  2. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/PKG-INFO +18 -9
  3. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/README.md +17 -8
  4. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/pyproject.toml +1 -1
  5. ragx_cli-0.4.0/skills/ragx-cli/SKILL.md +84 -0
  6. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/cli/app.py +9 -1
  7. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/cli/pipeline.py +10 -3
  8. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/indexer.py +57 -23
  9. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/query.py +1 -1
  10. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/providers/st_reranker.py +31 -0
  11. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_cli.py +22 -0
  12. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_integration.py +54 -8
  13. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_providers.py +36 -0
  14. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/SKILL.md +0 -0
  15. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/api-cheatsheet.md +0 -0
  16. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/collection-management/python.md +0 -0
  17. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/collection-management/typescript.md +0 -0
  18. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/configuration.md +0 -0
  19. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/data-model.md +0 -0
  20. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/data-operations/python.md +0 -0
  21. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/data-operations/typescript.md +0 -0
  22. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/embedding.md +0 -0
  23. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/hybrid-search/python.md +0 -0
  24. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/hybrid-search/typescript.md +0 -0
  25. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/multimodal-search/python.md +0 -0
  26. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/multimodal-search/typescript.md +0 -0
  27. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/quick-start/python.md +0 -0
  28. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/quick-start/typescript.md +0 -0
  29. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/rag-system/python.md +0 -0
  30. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/rag-system/typescript.md +0 -0
  31. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/reranker.md +0 -0
  32. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/troubleshooting.md +0 -0
  33. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/vector-search/python.md +0 -0
  34. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/vector-search/typescript.md +0 -0
  35. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.github/workflows/publish.yml +0 -0
  36. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.gitignore +0 -0
  37. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/AGENTS.md +0 -0
  38. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/CLAUDE.md +0 -0
  39. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/CONTRACTS-PHASE23.md +0 -0
  40. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/CONTRACTS.md +0 -0
  41. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/LICENSE +0 -0
  42. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/docs/feature-temporal-weighting.md +0 -0
  43. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/ragx-cli-plan.md +0 -0
  44. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/research/bge-m3-colbert-multi-vector-late-interaction-for-ragx-rerank-alternative-and-token-level-graph-edges.md +0 -0
  45. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/research/bge-m3-dense-embeddings-as-ragx-provider-multilingual-quality-and-threshold-calibration.md +0 -0
  46. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/research/bge-m3-dense-q8-vs-nomic-q4-benchmark-2026-07-12-worktree-eval-results.md +0 -0
  47. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/research/bge-m3-sparse-lexical-weights-hybrid-retrieval-leg-for-ragx-vs-sqlite-fts5-bm25.md +0 -0
  48. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/research/bge-m3-subchunk-edge-ablation-2026-07-13-ties-again-and-beta-zero-shortlist-mechanism.md +0 -0
  49. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/research/fine-grained-sub-chunk-edges-with-coarse-chunk-nodes-multi-granularity-graph-rag-literature-validation.md +0 -0
  50. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/research/subchunk-edge-ablation-2026-07-12-two-round-experiment-protocol-results-and-verdict.md +0 -0
  51. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/skills-lock.json +0 -0
  52. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/__init__.py +0 -0
  53. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/cli/__init__.py +0 -0
  54. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/cli/detect.py +0 -0
  55. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/cli/eval_cmd.py +0 -0
  56. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/cli/init_prompt.py +0 -0
  57. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/cli/inspect_cmd.py +0 -0
  58. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/cli/output.py +0 -0
  59. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/__init__.py +0 -0
  60. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/chunking.py +0 -0
  61. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/communities.py +0 -0
  62. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/config.py +0 -0
  63. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/discovery.py +0 -0
  64. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/errors.py +0 -0
  65. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/eval.py +0 -0
  66. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/expansion.py +0 -0
  67. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/fusion.py +0 -0
  68. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/graph.py +0 -0
  69. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/models.py +0 -0
  70. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/scoring.py +0 -0
  71. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/store.py +0 -0
  72. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/traversal.py +0 -0
  73. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/vectors.py +0 -0
  74. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/providers/__init__.py +0 -0
  75. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/providers/base.py +0 -0
  76. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/providers/openai_compat.py +0 -0
  77. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/providers/registry.py +0 -0
  78. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/__init__.py +0 -0
  79. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/conftest.py +0 -0
  80. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_chunking.py +0 -0
  81. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_communities.py +0 -0
  82. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_config_rc.py +0 -0
  83. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_discovery.py +0 -0
  84. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_eval.py +0 -0
  85. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_expansion.py +0 -0
  86. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_fusion.py +0 -0
  87. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_graph.py +0 -0
  88. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_inspect.py +0 -0
  89. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_store.py +0 -0
  90. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_traversal.py +0 -0
  91. {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_vectors.py +0 -0
@@ -4,6 +4,34 @@ All notable changes to `ragx-cli` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.4.0] — 2026-07-15
8
+
9
+ ### Changed (BREAKING)
10
+
11
+ - **`ragx-cli index` is now incremental by default** (previously a full rebuild).
12
+ It hash-diffs the corpus and re-processes only new/modified/deleted files. The old
13
+ behavior moved to `ragx-cli index --full`; `--changed` remains as a deprecated,
14
+ hidden alias for the (now default) incremental mode.
15
+ - **Chunking params joined the index manifest**: changing `chunking.size_tokens` or
16
+ `chunking.overlap` now fails loud on an incremental run (previously it silently left
17
+ old files chunked under the old params). Fix is `ragx-cli index --full`.
18
+
19
+ ### Added
20
+
21
+ - **`ragx-cli status` reports corpus drift**: a `drift` object
22
+ (`{"new": N, "changed": N, "deleted": N}`, same in text output) diffing the corpus
23
+ on disk against the stored file hashes, so agents and humans can see when a
24
+ re-index is due without running one.
25
+
26
+ ## [0.3.3] — 2026-07-15
27
+
28
+ ### Added
29
+
30
+ - **Early hint when the reranker model download stalls**: the huggingface_hub retry
31
+ loop inside the cross-encoder load can spin for minutes before failing. The first
32
+ retry warning now also prints a one-shot stderr pointer to the README's "Reranker on
33
+ restricted networks" section (manual copy, mirror, or `rerank.enabled false`).
34
+
7
35
  ## [0.3.2] — 2026-07-15
8
36
 
9
37
  _0.3.1 was tagged but never published — its release workflow referenced a nonexistent
@@ -95,6 +123,7 @@ Initial release: Phases 0–3 of [the plan](ragx-cli-plan.md), built and validat
95
123
  on stderr, exit codes `0` results / `1` empty / `2` error.
96
124
  - MIT license; PyPI packaging as `ragx-cli` with trusted-publishing release workflow.
97
125
 
126
+ [0.3.3]: https://github.com/alber70g/ragx-cli/compare/v0.3.2...v0.3.3
98
127
  [0.3.2]: https://github.com/alber70g/ragx-cli/compare/v0.3.0...v0.3.2
99
128
  [0.3.0]: https://github.com/alber70g/ragx-cli/compare/v0.2.0...v0.3.0
100
129
  [0.2.0]: https://github.com/alber70g/ragx-cli/compare/v0.1.1...v0.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ragx-cli
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Similarity-graph RAG CLI: index a corpus into a chunk-level embedding graph and query it
5
5
  Project-URL: Repository, https://github.com/alber70g/ragx-cli
6
6
  Project-URL: Issues, https://github.com/alber70g/ragx-cli/issues
@@ -49,9 +49,9 @@ that can justify every result via the exact graph path that produced it.
49
49
  ```bash
50
50
  uv tool install ragx-cli --with ragx-cli[rerank] # install and use `ragx-cli`
51
51
  ragx-cli init # create ragx.toml next to your corpus (interactive on a TTY; --yes for defaults)
52
- ragx-cli index # chunk -> embed -> HNSW + kNN similarity graph
52
+ ragx-cli index # chunk -> embed -> HNSW + kNN similarity graph (incremental after the first run)
53
53
  ragx-cli query "why did we switch build tools?" --json --files-only
54
- ragx-cli index --changed # incremental: only new/modified/deleted files
54
+ ragx-cli index --full # full rebuild: re-chunk + re-embed everything
55
55
  ```
56
56
 
57
57
  Runs against any OpenAI-compatible embedding endpoint (LM Studio, Ollama, OpenAI). Reranking
@@ -93,7 +93,7 @@ mixing several concepts gets one sharp edge per concept instead of a diluted ave
93
93
  between near-duplicate chunks (whole-chunk cosine ≥ `near_dup_sim`) are dropped — those are the
94
94
  measured precision-killers. Costs roughly 5–10× more embedding calls at index time; retrieval
95
95
  units, traversal, and query flow are unchanged. Switching `edge_source` (or the sub-chunk size)
96
- requires a full re-index; `--changed` fails loud on the mismatch. Literature grounding:
96
+ requires `index --full`; incremental runs fail loud on the mismatch. Literature grounding:
97
97
  `research/fine-grained-sub-chunk-edges-with-coarse-chunk-nodes-multi-granularity-graph-rag-literature-validation.md`.
98
98
  In this mode `k` counts links per *sub-chunk* with no per-chunk cap, so the graph is denser and
99
99
  shallower traversal suffices. Measured on the tuned eval corpus (2026-07): at `hops=2` it ties
@@ -116,8 +116,13 @@ flowchart TD
116
116
  G --> F
117
117
  ```
118
118
 
119
- Incremental runs (`--changed`) re-embed only changed files and repair only the edge lists that
120
- those chunks touch. Content hashes make `touch`-ed but unchanged files free.
119
+ `ragx-cli index` is **incremental by default** (0.4.0; `--changed` is a deprecated alias): it
120
+ re-embeds only new/modified files, drops deleted ones, and repairs only the edge lists those
121
+ chunks touch. Content hashes make `touch`-ed but unchanged files free, and include/exclude glob
122
+ changes converge to the same file set a rebuild would produce. `--full` rebuilds from scratch;
123
+ changing `embeddings.model`, `chunking.*`, or `graph.edge_source` requires it — incremental runs
124
+ fail loud on the manifest mismatch. `ragx-cli status` reports **drift** (files new/changed/deleted
125
+ since the last index) so you can see when a re-index is due without running one.
121
126
 
122
127
  ### Querying
123
128
 
@@ -322,6 +327,8 @@ Full write-ups: `research/bge-m3-dense-q8-vs-nomic-q4-benchmark-2026-07-12-workt
322
327
  - Every chunk carries `file`, `line_start/line_end`, `byte_start/byte_end` — agents jump to the
323
328
  exact source location and read the full text themselves (JSON chunk text is truncated).
324
329
  - `--files-only` aggregates chunk scores per file (sum of top-3) — the mode coding agents use most.
330
+ - `ragx-cli status` includes a `drift` object (`new`/`changed`/`deleted` file counts vs the index) —
331
+ agents check it to decide whether to run `ragx-cli index` before querying.
325
332
  - `ragx-cli query -` reads the query from stdin; `ragx-cli inspect chunk|file|neighbors|communities|community`
326
333
  debugs the graph.
327
334
 
@@ -341,7 +348,8 @@ questions; fall back to grep for exact identifiers.
341
348
  `file` + `line_start/line_end`; the JSON `text` is truncated, so read the file yourself
342
349
  for full context.
343
350
  - Fast mode (no LLM call, no cross-encoder): add `--no-expand --no-rerank`.
344
- - After adding or editing files: `ragx-cli index --changed` (cheap, hash-based).
351
+ - After adding or editing files: `ragx-cli index` (incremental and cheap — hash-based).
352
+ `ragx-cli status --json` shows `drift` counts if you want to check staleness first.
345
353
  - stdout is exactly one JSON document; logs are on stderr.
346
354
  Exit codes: 0 = results, 1 = no results (not an error), 2 = error.
347
355
  - Why did this result appear? `ragx-cli query "..." --explain`.
@@ -499,7 +507,7 @@ Checked features are built and validated per [the implementation plan](ragx-cli-
499
507
  unchecked ones are next up. Release history: [CHANGELOG.md](CHANGELOG.md).
500
508
 
501
509
  - [x] **CLI & storage**: typer CLI, SQLite schema, provider abstraction (Embedder/Generator/Reranker)
502
- - [x] **Baseline vector RAG**: discovery, chunking, embeddings, HNSW search, incremental `--changed`
510
+ - [x] **Baseline vector RAG**: discovery, chunking, embeddings, HNSW search, incremental indexing
503
511
  - [x] **Similarity graph**: kNN edge construction, heat-propagation traversal, `inspect`, `--explain`
504
512
  - [x] **Quality & measurement**: multi-query/HyDE expansion, RRF fusion, cross-encoder rerank, `eval` harness
505
513
  - [x] **Communities**: Leiden detection over the edge list (index-time, read-only via status/inspect)
@@ -507,11 +515,12 @@ unchecked ones are next up. Release history: [CHANGELOG.md](CHANGELOG.md).
507
515
  - [x] **Committable config**: `ragx.toml` at the corpus root (0.3.0; was `.ragx/config.toml`), index data stays gitignored in `.ragx/`
508
516
  - [x] **Offline-friendly rerank**: graceful degradation + pre-seeding docs when huggingface.co is unreachable
509
517
  - [x] **Sub-chunk edges** (opt-in `graph.edge_source="subchunk"`): edge weight = max cosine over sentence-aligned sub-chunk pairs, for corpora with long multi-concept chunks
518
+ - [x] **Incremental by default** (0.4.0): `index` hash-diffs, `--full` rebuilds, `status` reports corpus drift, chunking params are manifest-guarded
510
519
  - [ ] **Community labels**: name the Leiden communities so they're browsable without reading member chunks
511
520
  - [ ] **query --global** for corpus-level questions (answer from community summaries, not individual chunks)
512
521
  - [ ] **MCP server**: a second thin shell over `ragx.core` (the core/CLI split it needs is already enforced)
513
522
  - [ ] **[Temporal weighting](docs/feature-temporal-weighting.md)**: opt-in `--since`/`--until`/`--temporal recent|oldest`, date cascade filename/frontmatter → git → mtime
514
523
  - [x] **Release**: publish to PyPI as `ragx-cli` (plain `ragx` is name-blocked, too similar to an existing project) so `uvx ragx-cli` works out of the box
515
524
 
516
- Development: `uv sync --group dev && uv run pytest`. 177 tests; module contracts live in
525
+ Development: `uv sync --group dev && uv run pytest`. 183 tests; module contracts live in
517
526
  `CONTRACTS.md` / `CONTRACTS-PHASE23.md`.
@@ -21,9 +21,9 @@ that can justify every result via the exact graph path that produced it.
21
21
  ```bash
22
22
  uv tool install ragx-cli --with ragx-cli[rerank] # install and use `ragx-cli`
23
23
  ragx-cli init # create ragx.toml next to your corpus (interactive on a TTY; --yes for defaults)
24
- ragx-cli index # chunk -> embed -> HNSW + kNN similarity graph
24
+ ragx-cli index # chunk -> embed -> HNSW + kNN similarity graph (incremental after the first run)
25
25
  ragx-cli query "why did we switch build tools?" --json --files-only
26
- ragx-cli index --changed # incremental: only new/modified/deleted files
26
+ ragx-cli index --full # full rebuild: re-chunk + re-embed everything
27
27
  ```
28
28
 
29
29
  Runs against any OpenAI-compatible embedding endpoint (LM Studio, Ollama, OpenAI). Reranking
@@ -65,7 +65,7 @@ mixing several concepts gets one sharp edge per concept instead of a diluted ave
65
65
  between near-duplicate chunks (whole-chunk cosine ≥ `near_dup_sim`) are dropped — those are the
66
66
  measured precision-killers. Costs roughly 5–10× more embedding calls at index time; retrieval
67
67
  units, traversal, and query flow are unchanged. Switching `edge_source` (or the sub-chunk size)
68
- requires a full re-index; `--changed` fails loud on the mismatch. Literature grounding:
68
+ requires `index --full`; incremental runs fail loud on the mismatch. Literature grounding:
69
69
  `research/fine-grained-sub-chunk-edges-with-coarse-chunk-nodes-multi-granularity-graph-rag-literature-validation.md`.
70
70
  In this mode `k` counts links per *sub-chunk* with no per-chunk cap, so the graph is denser and
71
71
  shallower traversal suffices. Measured on the tuned eval corpus (2026-07): at `hops=2` it ties
@@ -88,8 +88,13 @@ flowchart TD
88
88
  G --> F
89
89
  ```
90
90
 
91
- Incremental runs (`--changed`) re-embed only changed files and repair only the edge lists that
92
- those chunks touch. Content hashes make `touch`-ed but unchanged files free.
91
+ `ragx-cli index` is **incremental by default** (0.4.0; `--changed` is a deprecated alias): it
92
+ re-embeds only new/modified files, drops deleted ones, and repairs only the edge lists those
93
+ chunks touch. Content hashes make `touch`-ed but unchanged files free, and include/exclude glob
94
+ changes converge to the same file set a rebuild would produce. `--full` rebuilds from scratch;
95
+ changing `embeddings.model`, `chunking.*`, or `graph.edge_source` requires it — incremental runs
96
+ fail loud on the manifest mismatch. `ragx-cli status` reports **drift** (files new/changed/deleted
97
+ since the last index) so you can see when a re-index is due without running one.
93
98
 
94
99
  ### Querying
95
100
 
@@ -294,6 +299,8 @@ Full write-ups: `research/bge-m3-dense-q8-vs-nomic-q4-benchmark-2026-07-12-workt
294
299
  - Every chunk carries `file`, `line_start/line_end`, `byte_start/byte_end` — agents jump to the
295
300
  exact source location and read the full text themselves (JSON chunk text is truncated).
296
301
  - `--files-only` aggregates chunk scores per file (sum of top-3) — the mode coding agents use most.
302
+ - `ragx-cli status` includes a `drift` object (`new`/`changed`/`deleted` file counts vs the index) —
303
+ agents check it to decide whether to run `ragx-cli index` before querying.
297
304
  - `ragx-cli query -` reads the query from stdin; `ragx-cli inspect chunk|file|neighbors|communities|community`
298
305
  debugs the graph.
299
306
 
@@ -313,7 +320,8 @@ questions; fall back to grep for exact identifiers.
313
320
  `file` + `line_start/line_end`; the JSON `text` is truncated, so read the file yourself
314
321
  for full context.
315
322
  - Fast mode (no LLM call, no cross-encoder): add `--no-expand --no-rerank`.
316
- - After adding or editing files: `ragx-cli index --changed` (cheap, hash-based).
323
+ - After adding or editing files: `ragx-cli index` (incremental and cheap — hash-based).
324
+ `ragx-cli status --json` shows `drift` counts if you want to check staleness first.
317
325
  - stdout is exactly one JSON document; logs are on stderr.
318
326
  Exit codes: 0 = results, 1 = no results (not an error), 2 = error.
319
327
  - Why did this result appear? `ragx-cli query "..." --explain`.
@@ -471,7 +479,7 @@ Checked features are built and validated per [the implementation plan](ragx-cli-
471
479
  unchecked ones are next up. Release history: [CHANGELOG.md](CHANGELOG.md).
472
480
 
473
481
  - [x] **CLI & storage**: typer CLI, SQLite schema, provider abstraction (Embedder/Generator/Reranker)
474
- - [x] **Baseline vector RAG**: discovery, chunking, embeddings, HNSW search, incremental `--changed`
482
+ - [x] **Baseline vector RAG**: discovery, chunking, embeddings, HNSW search, incremental indexing
475
483
  - [x] **Similarity graph**: kNN edge construction, heat-propagation traversal, `inspect`, `--explain`
476
484
  - [x] **Quality & measurement**: multi-query/HyDE expansion, RRF fusion, cross-encoder rerank, `eval` harness
477
485
  - [x] **Communities**: Leiden detection over the edge list (index-time, read-only via status/inspect)
@@ -479,11 +487,12 @@ unchecked ones are next up. Release history: [CHANGELOG.md](CHANGELOG.md).
479
487
  - [x] **Committable config**: `ragx.toml` at the corpus root (0.3.0; was `.ragx/config.toml`), index data stays gitignored in `.ragx/`
480
488
  - [x] **Offline-friendly rerank**: graceful degradation + pre-seeding docs when huggingface.co is unreachable
481
489
  - [x] **Sub-chunk edges** (opt-in `graph.edge_source="subchunk"`): edge weight = max cosine over sentence-aligned sub-chunk pairs, for corpora with long multi-concept chunks
490
+ - [x] **Incremental by default** (0.4.0): `index` hash-diffs, `--full` rebuilds, `status` reports corpus drift, chunking params are manifest-guarded
482
491
  - [ ] **Community labels**: name the Leiden communities so they're browsable without reading member chunks
483
492
  - [ ] **query --global** for corpus-level questions (answer from community summaries, not individual chunks)
484
493
  - [ ] **MCP server**: a second thin shell over `ragx.core` (the core/CLI split it needs is already enforced)
485
494
  - [ ] **[Temporal weighting](docs/feature-temporal-weighting.md)**: opt-in `--since`/`--until`/`--temporal recent|oldest`, date cascade filename/frontmatter → git → mtime
486
495
  - [x] **Release**: publish to PyPI as `ragx-cli` (plain `ragx` is name-blocked, too similar to an existing project) so `uvx ragx-cli` works out of the box
487
496
 
488
- Development: `uv sync --group dev && uv run pytest`. 177 tests; module contracts live in
497
+ Development: `uv sync --group dev && uv run pytest`. 183 tests; module contracts live in
489
498
  `CONTRACTS.md` / `CONTRACTS-PHASE23.md`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ragx-cli"
3
- version = "0.3.2"
3
+ version = "0.4.0"
4
4
  description = "Similarity-graph RAG CLI: index a corpus into a chunk-level embedding graph and query it"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: ragx-cli
3
+ description: |
4
+ Query a ragx-cli semantic index instead of grepping. Use when the user asks a conceptual or
5
+ natural-language question about a corpus ("where is X discussed/decided?", "find notes about Y"),
6
+ when you are about to grep/rg for a concept (not an exact identifier) in a directory that contains
7
+ a `.ragx/` folder or `ragx.toml`, or when the user says `/ragx`, "search my notes", or "semantic
8
+ search". Covers running queries, interpreting the JSON output, and keeping the index fresh.
9
+ ---
10
+
11
+ # ragx-cli — semantic search over an indexed corpus
12
+
13
+ Indexes files into a chunk-level embedding similarity graph; answers queries via vector search +
14
+ graph traversal + cross-encoder rerank. Agent-first: one JSON doc on stdout, logs on stderr,
15
+ deterministic exit codes, byte-exact source locations.
16
+
17
+ ## When
18
+
19
+ A corpus is ragx-indexed if its root has `ragx.toml` and `.ragx/`.
20
+
21
+ - Use ragx-cli for conceptual, natural-language questions ("where do we discuss auth tradeoffs?",
22
+ cross-language queries). Full questions beat keywords.
23
+ - Use grep/rg for exact identifiers and literal strings. ragx does not replace it.
24
+ - Not on PATH: `uvx ragx-cli …` works (PyPI name is `ragx-cli`). Run from anywhere inside the
25
+ corpus — root is found upward.
26
+
27
+ ## Commands
28
+
29
+ ```bash
30
+ ragx-cli status --json # root, model, counts, and drift (new/changed/deleted vs index)
31
+ ragx-cli query "question" --json --files-only # which files are relevant — most-used mode
32
+ ragx-cli query "..." --json --top 8 # ranked chunks with exact locations
33
+ ragx-cli query "..." --json --no-expand --no-rerank # fast: skip LLM call + cross-encoder
34
+ echo "..." | ragx-cli query - --json # long/multiline query from stdin
35
+ ragx-cli index # incremental reindex (hash-based, cheap) — the default
36
+ ragx-cli index --full # full rebuild: re-chunks + re-embeds everything
37
+ ```
38
+
39
+ Other query flags: `--no-graph`, `--hops N`, `--explain`.
40
+
41
+ ## Output
42
+
43
+ - stdout is exactly one JSON doc (`ragx.query.v1` / `ragx.files.v1` / `ragx.status.v1`); logs go
44
+ to stderr. Never parse stderr.
45
+ - Exit codes: `0` results, `1` success-but-empty (report "nothing found", don't retry as failure),
46
+ `2` error.
47
+ - Chunk results carry `file`, `line_start/end`, `byte_start/end`, `score`, `breakdown`. JSON
48
+ `text` is truncated — read the file at the line range for full context.
49
+ - Typical flow: `--files-only` to pick files → `--top 8` for locations → read files at ranges.
50
+
51
+ ## Freshness
52
+
53
+ - `status` includes `drift` counts; nonzero means the index is stale → run `ragx-cli index`
54
+ (incremental, safe, converges even after include/exclude glob changes).
55
+ - Changing `embeddings.model`, `chunking.*`, or `graph.edge_source` invalidates the index:
56
+ commands fail loud on the manifest mismatch, and the fix is `ragx-cli index --full` —
57
+ re-embeds everything, so confirm before running on a large corpus.
58
+
59
+ ## Why did/didn't a result appear?
60
+
61
+ - `ragx-cli query "..." --explain` — per-result trace: seed, graph edge, hop.
62
+ - `ragx-cli inspect neighbors <chunk_id>` / `inspect chunk <id>` / `inspect file <path>` /
63
+ `inspect communities` / `inspect community <id>`.
64
+
65
+ ## Gotchas
66
+
67
+ - The embedding provider must be reachable (default: LM Studio at `localhost:1234`; Ollama and
68
+ any OpenAI-compatible endpoint via config). On connection error, report it and suggest starting
69
+ the provider — never silently fall back to grep.
70
+ - Query expansion degrades gracefully (LLM down → no-op); embeddings do not.
71
+ - `--changed` is a deprecated alias — incremental is the default now.
72
+ - The corpus may be someone's personal notes repo: never `git add`/commit there. Querying and
73
+ `ragx-cli index` are safe.
74
+
75
+ ## New corpus (only when asked)
76
+
77
+ ```bash
78
+ ragx-cli init
79
+ ragx-cli config set embeddings.model <model-id> # match what the provider serves
80
+ ragx-cli index
81
+ ```
82
+
83
+ Provider recipes are in the ragx-cli README. Check `ragx-cli status` and
84
+ `curl -s http://localhost:1234/v1/models` before guessing model ids.
@@ -93,10 +93,13 @@ def _counts(root: Path) -> tuple[int, int, int, int]:
93
93
 
94
94
  @app.command()
95
95
  def status(json_out: bool = typer.Option(False, "--json")) -> None:
96
- """Show corpus root, embedding config, and index counts."""
96
+ """Show corpus root, embedding config, index counts, and drift vs the corpus on disk."""
97
+ from ragx.core.indexer import corpus_drift
98
+
97
99
  root = _require_root()
98
100
  cfg = _load_config(root)
99
101
  files, chunks, edges, communities = _counts(root)
102
+ drift = corpus_drift(root, cfg)
100
103
  doc = {
101
104
  "schema": "ragx.status.v1",
102
105
  "root": str(root),
@@ -106,6 +109,7 @@ def status(json_out: bool = typer.Option(False, "--json")) -> None:
106
109
  "chunks": chunks,
107
110
  "edges": edges,
108
111
  "communities": communities,
112
+ "drift": drift,
109
113
  }
110
114
  if json_out:
111
115
  emit_json(doc)
@@ -113,6 +117,10 @@ def status(json_out: bool = typer.Option(False, "--json")) -> None:
113
117
  typer.echo(f"root: {doc['root']}")
114
118
  typer.echo(f"embedding: {doc['embedding_provider']}/{doc['embedding_model']}")
115
119
  typer.echo(f"files: {files} chunks: {chunks} edges: {edges} communities: {communities}")
120
+ line = f"drift: {drift['new']} new, {drift['changed']} changed, {drift['deleted']} deleted"
121
+ if any(drift.values()):
122
+ line += " (run `ragx-cli index`)"
123
+ typer.echo(line)
116
124
  if files == 0 and chunks == 0 and edges == 0:
117
125
  raise typer.Exit(code=1)
118
126
 
@@ -22,14 +22,21 @@ def register(app: typer.Typer) -> None:
22
22
 
23
23
  def index(
24
24
  path: Path | None = typer.Argument(None),
25
- changed: bool = typer.Option(False, "--changed", help="only re-process new/modified/deleted files"),
25
+ full: bool = typer.Option(False, "--full", help="full rebuild: re-chunk and re-embed everything"),
26
+ changed: bool = typer.Option(
27
+ False, "--changed", hidden=True, help="deprecated: incremental is now the default"
28
+ ),
26
29
  json_out: bool = typer.Option(False, "--json"),
27
30
  ) -> None:
28
- """Chunk, embed, and index the corpus (full rebuild unless --changed)."""
31
+ """Chunk, embed, and index the corpus (incremental by default; --full rebuilds)."""
32
+ if changed and full:
33
+ fail("--changed and --full are mutually exclusive")
34
+ if changed:
35
+ print("warning: --changed is deprecated; incremental is now the default", file=sys.stderr)
29
36
  try:
30
37
  root = require_root(path)
31
38
  cfg = Config.load(root, confirm=migrate_confirm())
32
- stats = run_index(root, cfg, make_embedder(cfg), changed_only=changed)
39
+ stats = run_index(root, cfg, make_embedder(cfg), full=full)
33
40
  except RagxError as exc:
34
41
  fail(str(exc))
35
42
  doc = {"schema": "ragx.index.v1", **stats.__dict__}
@@ -35,42 +35,40 @@ class IndexStats:
35
35
  communities_total: int = 0
36
36
 
37
37
 
38
- def run_index(root: Path, cfg: Config, embedder: Embedder, *, changed_only: bool = False) -> IndexStats:
39
- """Index the corpus at `root`. Full rebuild by default; hash-diff when changed_only."""
38
+ def run_index(root: Path, cfg: Config, embedder: Embedder, *, full: bool = False) -> IndexStats:
39
+ """Index the corpus at `root`. Hash-diff incremental by default; rebuild when full."""
40
40
  edge_source = cfg.get("graph.edge_source")
41
41
  if edge_source not in ("chunk", "subchunk"):
42
42
  raise RagxError(f"graph.edge_source must be 'chunk' or 'subchunk', got {edge_source!r}")
43
43
  sub_size = cfg.get("graph.subchunk_size_tokens")
44
+ size_tokens = cfg.get("chunking.size_tokens")
45
+ overlap = cfg.get("chunking.overlap")
44
46
  db_path(root).parent.mkdir(parents=True, exist_ok=True)
45
47
  with Store(db_path(root)) as store:
46
- _check_manifest(store, embedder, edge_source, sub_size, allow_rewrite=not changed_only)
48
+ _check_manifest(
49
+ store, embedder, edge_source, sub_size, size_tokens, overlap, allow_rewrite=full
50
+ )
47
51
  dim = embedder.dimension()
48
52
  store.set_meta("embedding_model", embedder.model)
49
53
  store.set_meta("embedding_dim", str(dim))
50
54
  store.set_meta("edge_source", edge_source)
51
55
  store.set_meta("subchunk_size_tokens", str(sub_size))
56
+ store.set_meta("chunk_size_tokens", str(size_tokens))
57
+ store.set_meta("chunk_overlap", str(overlap))
52
58
 
53
- current = {
54
- rel: hash_file(root / rel)
55
- for rel in discover_files(
56
- root,
57
- cfg.get("corpus.include"),
58
- cfg.get("corpus.exclude"),
59
- cfg.get("corpus.respect_gitignore"),
60
- )
61
- }
59
+ current = _discover_hashes(root, cfg)
62
60
  known = store.get_file_hashes()
63
61
 
64
- if changed_only:
65
- to_index = [p for p, h in current.items() if known.get(p) != h]
66
- to_delete = [p for p in known if p not in current]
67
- index = _open_vectors(root, dim)
68
- else:
62
+ if full:
69
63
  to_index, to_delete = list(current), []
70
64
  for p in known:
71
65
  store.delete_file(p)
72
66
  vectors_path(root).unlink(missing_ok=True)
73
67
  index = _open_vectors(root, dim)
68
+ else:
69
+ to_index = [p for p, h in current.items() if known.get(p) != h]
70
+ to_delete = [p for p in known if p not in current]
71
+ index = _open_vectors(root, dim)
74
72
 
75
73
  stats = IndexStats(files_unchanged=len(current) - len(to_index))
76
74
 
@@ -80,8 +78,6 @@ def run_index(root: Path, cfg: Config, embedder: Embedder, *, changed_only: bool
80
78
  stats.files_deleted += 1
81
79
  stats.chunks_deleted += len(removed)
82
80
 
83
- size_tokens = cfg.get("chunking.size_tokens")
84
- overlap = cfg.get("chunking.overlap")
85
81
  new_ids: list[int] = []
86
82
  for path in sorted(to_index):
87
83
  if path in known: # changed file: drop old chunks first
@@ -134,9 +130,21 @@ def run_index(root: Path, cfg: Config, embedder: Embedder, *, changed_only: bool
134
130
 
135
131
 
136
132
  def _check_manifest(
137
- store: Store, embedder: Embedder, edge_source: str, sub_size: int, *, allow_rewrite: bool
133
+ store: Store,
134
+ embedder: Embedder,
135
+ edge_source: str,
136
+ sub_size: int,
137
+ size_tokens: int,
138
+ overlap: float,
139
+ *,
140
+ allow_rewrite: bool,
138
141
  ) -> None:
139
- checks = [("embedding_model", embedder.model), ("edge_source", edge_source)]
142
+ checks = [
143
+ ("embedding_model", embedder.model),
144
+ ("edge_source", edge_source),
145
+ ("chunk_size_tokens", str(size_tokens)),
146
+ ("chunk_overlap", str(overlap)),
147
+ ]
140
148
  if edge_source == "subchunk": # size only shapes the graph when subchunk edges are on
141
149
  checks.append(("subchunk_size_tokens", str(sub_size)))
142
150
  for key, current in checks:
@@ -144,10 +152,36 @@ def _check_manifest(
144
152
  if prev and prev != str(current) and not allow_rewrite:
145
153
  raise ManifestMismatchError(
146
154
  f"index was built with {key}={prev!r} but config says {current!r}; "
147
- "run a full `ragx-cli index` to rebuild"
155
+ "run `ragx-cli index --full` to rebuild"
148
156
  )
149
157
 
150
158
 
159
+ def _discover_hashes(root: Path, cfg: Config) -> dict[str, str]:
160
+ return {
161
+ rel: hash_file(root / rel)
162
+ for rel in discover_files(
163
+ root,
164
+ cfg.get("corpus.include"),
165
+ cfg.get("corpus.exclude"),
166
+ cfg.get("corpus.respect_gitignore"),
167
+ )
168
+ }
169
+
170
+
171
+ def corpus_drift(root: Path, cfg: Config) -> dict[str, int]:
172
+ """Diff the corpus on disk against the stored file hashes. Read-only."""
173
+ current = _discover_hashes(root, cfg)
174
+ known: dict[str, str] = {}
175
+ if db_path(root).exists():
176
+ with Store(db_path(root)) as store:
177
+ known = store.get_file_hashes()
178
+ return {
179
+ "new": sum(1 for p in current if p not in known),
180
+ "changed": sum(1 for p, h in current.items() if p in known and known[p] != h),
181
+ "deleted": sum(1 for p in known if p not in current),
182
+ }
183
+
184
+
151
185
  def _unit(vec: Sequence[float]) -> list[float]:
152
186
  norm = math.sqrt(sum(v * v for v in vec)) or 1.0
153
187
  return [v / norm for v in vec]
@@ -193,7 +227,7 @@ def _subchunk_edge_fn(
193
227
  if len(chunk_ids) != store.chunk_count():
194
228
  raise RagxError(
195
229
  "sub-chunk vectors missing for some chunks (index predates graph.edge_source="
196
- "'subchunk'); run a full `ragx-cli index` to rebuild"
230
+ "'subchunk'); run `ragx-cli index --full` to rebuild"
197
231
  )
198
232
  chunk_vectors = dict(zip(chunk_ids, index.get_vectors(chunk_ids)))
199
233
 
@@ -42,7 +42,7 @@ def _check_model(store: Store, embedder: Embedder) -> None:
42
42
  if built_with != embedder.model:
43
43
  raise RagxError(
44
44
  f"index built with {built_with!r}, config says {embedder.model!r}; "
45
- "run `ragx-cli index` to rebuild"
45
+ "run `ragx-cli index --full` to rebuild"
46
46
  )
47
47
 
48
48
 
@@ -2,10 +2,36 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
6
+ import sys
5
7
  from typing import Sequence
6
8
 
7
9
  from ragx.core.errors import RagxError
8
10
 
11
+ _MANUAL_INSTALL_HINT = (
12
+ "downloading the reranker model from huggingface.co is failing — to install the "
13
+ "model manually, use a mirror, or disable reranking, see "
14
+ "https://github.com/alber70g/ragx-cli#reranker-on-restricted-networks-no-huggingfaceco"
15
+ )
16
+
17
+
18
+ class _DownloadHintHandler(logging.Handler):
19
+ """Emits the manual-install hint once, on the first huggingface_hub retry warning."""
20
+
21
+ def __init__(self) -> None:
22
+ super().__init__(level=logging.WARNING)
23
+ self._fired = False
24
+
25
+ def emit(self, record: logging.LogRecord) -> None:
26
+ if self._fired:
27
+ return
28
+ message = record.getMessage().lower()
29
+ if "retry" in message or "thrown while requesting" in message:
30
+ self._fired = True
31
+ # logging drops re-entrant calls made while a record is being
32
+ # handled, so the hint must go to stderr directly.
33
+ print(f"WARNING: {_MANUAL_INSTALL_HINT}", file=sys.stderr)
34
+
9
35
 
10
36
  class STReranker:
11
37
  """Reranker implementing providers.base.Reranker via a sentence-transformers CrossEncoder."""
@@ -20,6 +46,9 @@ class STReranker:
20
46
  "sentence-transformers not installed — install with: uv tool install ragx-cli --with ragx-cli[rerank]"
21
47
  ) from exc
22
48
  self.model = model
49
+ hint = _DownloadHintHandler()
50
+ hub_logger = logging.getLogger("huggingface_hub")
51
+ hub_logger.addHandler(hint)
23
52
  try:
24
53
  self._encoder = CrossEncoder(model)
25
54
  except Exception as exc:
@@ -30,6 +59,8 @@ class STReranker:
30
59
  "reranking (rerank.enabled false); see README 'Reranker on restricted "
31
60
  "networks (no huggingface.co)'"
32
61
  ) from exc
62
+ finally:
63
+ hub_logger.removeHandler(hint)
33
64
 
34
65
  def score(self, query: str, texts: Sequence[str]) -> list[float]:
35
66
  pairs = [[query, text] for text in texts]
@@ -174,6 +174,7 @@ def test_status_json_shape_and_stdout_purity(tmp_path, monkeypatch):
174
174
  assert doc["files"] == 0
175
175
  assert doc["chunks"] == 0
176
176
  assert doc["edges"] == 0
177
+ assert doc["drift"] == {"new": 0, "changed": 0, "deleted": 0}
177
178
 
178
179
 
179
180
  def test_status_human_readable(tmp_path, monkeypatch):
@@ -186,6 +187,27 @@ def test_status_human_readable(tmp_path, monkeypatch):
186
187
  assert "embedding:" in result.stdout
187
188
 
188
189
 
190
+ def test_status_reports_drift(tmp_path, monkeypatch):
191
+ runner.invoke(app, ["init", str(tmp_path)])
192
+ (tmp_path / "a.md").write_text("# A\n\nSome text.\n")
193
+ monkeypatch.chdir(tmp_path)
194
+
195
+ result = runner.invoke(app, ["status", "--json"])
196
+ doc = json.loads(result.stdout)
197
+ assert doc["drift"] == {"new": 1, "changed": 0, "deleted": 0}
198
+
199
+ result = runner.invoke(app, ["status"])
200
+ assert "drift: 1 new, 0 changed, 0 deleted" in result.stdout
201
+ assert "ragx-cli index" in result.stdout
202
+
203
+
204
+ def test_index_changed_and_full_mutually_exclusive(tmp_path, monkeypatch):
205
+ runner.invoke(app, ["init", str(tmp_path)])
206
+ monkeypatch.chdir(tmp_path)
207
+ result = runner.invoke(app, ["index", "--changed", "--full"])
208
+ assert result.exit_code == 2
209
+
210
+
189
211
  def test_status_without_init(tmp_path, monkeypatch):
190
212
  monkeypatch.chdir(tmp_path)
191
213
  result = runner.invoke(app, ["status"])
@@ -10,7 +10,7 @@ import pytest
10
10
  from ragx.core.config import Config, db_path, write_default_config
11
11
  from ragx.core.errors import ManifestMismatchError
12
12
  from ragx.core.store import Store
13
- from ragx.core.indexer import run_index
13
+ from ragx.core.indexer import corpus_drift, run_index
14
14
  from ragx.core.query import QueryOptions, run_query, to_files_json, to_query_json
15
15
 
16
16
 
@@ -74,15 +74,15 @@ def test_incremental_reindex(tmp_path):
74
74
  emb = FakeEmbedder()
75
75
  run_index(tmp_path, cfg, emb)
76
76
 
77
- # unchanged corpus: nothing re-processed
78
- stats = run_index(tmp_path, cfg, emb, changed_only=True)
77
+ # unchanged corpus: nothing re-processed (incremental is the default)
78
+ stats = run_index(tmp_path, cfg, emb)
79
79
  assert stats.files_indexed == 0
80
80
  assert stats.files_unchanged == 3
81
81
 
82
82
  # edit one file, delete another
83
83
  (tmp_path / "pets.md").write_text("# Pets\n\nDogs bark. Hamsters run on wheels.\n")
84
84
  (tmp_path / "space.md").unlink()
85
- stats = run_index(tmp_path, cfg, emb, changed_only=True)
85
+ stats = run_index(tmp_path, cfg, emb)
86
86
  assert stats.files_indexed == 1
87
87
  assert stats.files_deleted == 1
88
88
 
@@ -154,7 +154,7 @@ def test_subchunk_incremental_reindex(tmp_path):
154
154
 
155
155
  (tmp_path / "pets.md").write_text("# Pets\n\nDogs bark. Hamsters run on wheels.\n")
156
156
  (tmp_path / "space.md").unlink()
157
- stats = run_index(tmp_path, cfg, emb, changed_only=True)
157
+ stats = run_index(tmp_path, cfg, emb)
158
158
  assert stats.files_indexed == 1
159
159
  assert stats.files_deleted == 1
160
160
 
@@ -166,7 +166,7 @@ def test_subchunk_incremental_reindex(tmp_path):
166
166
  assert out.results[0].chunk.file_path == "pets.md"
167
167
 
168
168
 
169
- def test_changed_only_fails_loud_on_edge_source_flip(tmp_path):
169
+ def test_incremental_fails_loud_on_edge_source_flip(tmp_path):
170
170
  make_corpus(tmp_path)
171
171
  write_default_config(tmp_path)
172
172
  cfg = Config.load(tmp_path)
@@ -175,7 +175,53 @@ def test_changed_only_fails_loud_on_edge_source_flip(tmp_path):
175
175
 
176
176
  cfg.set("graph.edge_source", "subchunk")
177
177
  with pytest.raises(ManifestMismatchError):
178
- run_index(tmp_path, cfg, emb, changed_only=True)
178
+ run_index(tmp_path, cfg, emb)
179
+ stats = run_index(tmp_path, cfg, emb, full=True) # explicit rebuild clears the mismatch
180
+ assert stats.files_indexed == 3
181
+
182
+
183
+ def test_incremental_fails_loud_on_chunking_change(tmp_path):
184
+ make_corpus(tmp_path)
185
+ write_default_config(tmp_path)
186
+ cfg = Config.load(tmp_path)
187
+ emb = FakeEmbedder()
188
+ run_index(tmp_path, cfg, emb)
189
+
190
+ cfg.set("chunking.size_tokens", "400")
191
+ with pytest.raises(ManifestMismatchError):
192
+ run_index(tmp_path, cfg, emb)
193
+ stats = run_index(tmp_path, cfg, emb, full=True)
194
+ assert stats.files_indexed == 3
195
+
196
+
197
+ def test_corpus_drift(tmp_path):
198
+ make_corpus(tmp_path)
199
+ write_default_config(tmp_path)
200
+ cfg = Config.load(tmp_path)
201
+ emb = FakeEmbedder()
202
+
203
+ assert corpus_drift(tmp_path, cfg) == {"new": 3, "changed": 0, "deleted": 0}
204
+ run_index(tmp_path, cfg, emb)
205
+ assert corpus_drift(tmp_path, cfg) == {"new": 0, "changed": 0, "deleted": 0}
206
+
207
+ (tmp_path / "pets.md").write_text("# Pets\n\nParrots repeat words.\n")
208
+ (tmp_path / "space.md").unlink()
209
+ (tmp_path / "gardens.md").write_text("# Gardens\n\nTomatoes ripen in summer sun.\n")
210
+ assert corpus_drift(tmp_path, cfg) == {"new": 1, "changed": 1, "deleted": 1}
211
+
212
+
213
+ def test_drift_reflects_exclude_change(tmp_path):
214
+ make_corpus(tmp_path)
215
+ write_default_config(tmp_path)
216
+ cfg = Config.load(tmp_path)
217
+ emb = FakeEmbedder()
218
+ run_index(tmp_path, cfg, emb)
219
+
220
+ cfg.set("corpus.exclude", "cooking.md")
221
+ assert corpus_drift(tmp_path, cfg) == {"new": 0, "changed": 0, "deleted": 1}
222
+ stats = run_index(tmp_path, cfg, emb) # incremental converges on the new glob set
223
+ assert stats.files_deleted == 1
224
+ assert corpus_drift(tmp_path, cfg) == {"new": 0, "changed": 0, "deleted": 0}
179
225
 
180
226
 
181
227
  def test_index_persists_communities(tmp_path):
@@ -210,7 +256,7 @@ def test_changed_reindex_recomputes_communities(tmp_path):
210
256
  with Store(db_path(tmp_path)) as store:
211
257
  deleted_ids = set(store.chunk_ids_for_file("space.md"))
212
258
  (tmp_path / "space.md").unlink()
213
- run_index(tmp_path, cfg, emb, changed_only=True)
259
+ run_index(tmp_path, cfg, emb)
214
260
 
215
261
  with Store(db_path(tmp_path)) as store:
216
262
  members = set()
@@ -200,6 +200,42 @@ def test_make_reranker_model_load_failure_returns_none_and_warns(monkeypatch, ca
200
200
  assert "HF_ENDPOINT" in err
201
201
 
202
202
 
203
+ def test_st_reranker_first_retry_warning_emits_manual_install_hint(monkeypatch, capsys):
204
+ """The first HF download-retry warning triggers a one-shot README pointer."""
205
+ import logging
206
+ import sys
207
+ import types
208
+
209
+ class _RetryingCrossEncoder:
210
+ def __init__(self, model):
211
+ hub_log = logging.getLogger("huggingface_hub.utils._http")
212
+ for _ in range(2):
213
+ hub_log.warning(
214
+ "'_ssl.c:1015: The handshake operation timed out' thrown while "
215
+ "requesting HEAD https://huggingface.co/x/resolve/main/modules.json"
216
+ )
217
+ hub_log.warning("Retrying in 1s [Retry 1/5].")
218
+ raise OSError("timed out")
219
+
220
+ fake = types.ModuleType("sentence_transformers")
221
+ fake.CrossEncoder = _RetryingCrossEncoder
222
+ monkeypatch.setitem(sys.modules, "sentence_transformers", fake)
223
+
224
+ from ragx.core.errors import RagxError
225
+ from ragx.providers.st_reranker import STReranker, _DownloadHintHandler
226
+
227
+ with pytest.raises(RagxError):
228
+ STReranker("BAAI/bge-reranker-v2-m3")
229
+
230
+ err = capsys.readouterr().err
231
+ assert err.count("install the model manually") == 1
232
+ assert "#reranker-on-restricted-networks" in err
233
+ assert not any(
234
+ isinstance(h, _DownloadHintHandler)
235
+ for h in logging.getLogger("huggingface_hub").handlers
236
+ )
237
+
238
+
203
239
  def test_api_key_env_resolved_and_sent(monkeypatch):
204
240
  monkeypatch.setenv("RAGX_TEST_KEY", "sk-test-123")
205
241
  cfg = _config({"embeddings": {"api_key_env": "RAGX_TEST_KEY"}})
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes