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.
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/CHANGELOG.md +29 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/PKG-INFO +18 -9
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/README.md +17 -8
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/pyproject.toml +1 -1
- ragx_cli-0.4.0/skills/ragx-cli/SKILL.md +84 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/cli/app.py +9 -1
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/cli/pipeline.py +10 -3
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/indexer.py +57 -23
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/query.py +1 -1
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/providers/st_reranker.py +31 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_cli.py +22 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_integration.py +54 -8
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_providers.py +36 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/SKILL.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/api-cheatsheet.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/collection-management/python.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/collection-management/typescript.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/configuration.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/data-model.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/data-operations/python.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/data-operations/typescript.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/embedding.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/hybrid-search/python.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/hybrid-search/typescript.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/multimodal-search/python.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/multimodal-search/typescript.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/quick-start/python.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/quick-start/typescript.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/rag-system/python.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/rag-system/typescript.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/reranker.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/troubleshooting.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/vector-search/python.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.agents/skills/zvec/vector-search/typescript.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.github/workflows/publish.yml +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/.gitignore +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/AGENTS.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/CLAUDE.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/CONTRACTS-PHASE23.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/CONTRACTS.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/LICENSE +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/docs/feature-temporal-weighting.md +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/ragx-cli-plan.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/skills-lock.json +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/__init__.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/cli/__init__.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/cli/detect.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/cli/eval_cmd.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/cli/init_prompt.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/cli/inspect_cmd.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/cli/output.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/__init__.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/chunking.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/communities.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/config.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/discovery.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/errors.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/eval.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/expansion.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/fusion.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/graph.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/models.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/scoring.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/store.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/traversal.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/core/vectors.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/providers/__init__.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/providers/base.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/providers/openai_compat.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/src/ragx/providers/registry.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/__init__.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/conftest.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_chunking.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_communities.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_config_rc.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_discovery.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_eval.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_expansion.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_fusion.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_graph.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_inspect.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_store.py +0 -0
- {ragx_cli-0.3.2 → ragx_cli-0.4.0}/tests/test_traversal.py +0 -0
- {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
|
+
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 --
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
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
|
|
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
|
|
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`.
|
|
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 --
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
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
|
|
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`.
|
|
497
|
+
Development: `uv sync --group dev && uv run pytest`. 183 tests; module contracts live in
|
|
489
498
|
`CONTRACTS.md` / `CONTRACTS-PHASE23.md`.
|
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
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),
|
|
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, *,
|
|
39
|
-
"""Index the corpus at `root`.
|
|
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(
|
|
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
|
|
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,
|
|
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 = [
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|