ragx-cli 0.1.1__tar.gz → 0.2.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 (58) hide show
  1. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/.gitignore +2 -0
  2. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/CLAUDE.md +10 -4
  3. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/PKG-INFO +90 -2
  4. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/README.md +89 -1
  5. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/pyproject.toml +1 -1
  6. ragx_cli-0.2.0/src/ragx/__init__.py +1 -0
  7. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/cli/app.py +23 -3
  8. ragx_cli-0.2.0/src/ragx/core/config.py +200 -0
  9. ragx_cli-0.2.0/tests/conftest.py +13 -0
  10. ragx_cli-0.2.0/tests/test_config_rc.py +118 -0
  11. ragx_cli-0.1.1/src/ragx/__init__.py +0 -1
  12. ragx_cli-0.1.1/src/ragx/core/config.py +0 -130
  13. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/.github/workflows/publish.yml +0 -0
  14. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/AGENTS.md +0 -0
  15. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/CONTRACTS-PHASE23.md +0 -0
  16. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/CONTRACTS.md +0 -0
  17. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/LICENSE +0 -0
  18. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/docs/feature-temporal-weighting.md +0 -0
  19. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/ragx-cli-plan.md +0 -0
  20. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/cli/__init__.py +0 -0
  21. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/cli/eval_cmd.py +0 -0
  22. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/cli/inspect_cmd.py +0 -0
  23. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/cli/output.py +0 -0
  24. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/cli/pipeline.py +0 -0
  25. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/core/__init__.py +0 -0
  26. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/core/chunking.py +0 -0
  27. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/core/discovery.py +0 -0
  28. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/core/errors.py +0 -0
  29. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/core/eval.py +0 -0
  30. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/core/expansion.py +0 -0
  31. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/core/fusion.py +0 -0
  32. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/core/graph.py +0 -0
  33. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/core/indexer.py +0 -0
  34. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/core/models.py +0 -0
  35. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/core/query.py +0 -0
  36. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/core/scoring.py +0 -0
  37. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/core/store.py +0 -0
  38. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/core/traversal.py +0 -0
  39. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/core/vectors.py +0 -0
  40. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/providers/__init__.py +0 -0
  41. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/providers/base.py +0 -0
  42. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/providers/openai_compat.py +0 -0
  43. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/providers/registry.py +0 -0
  44. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/src/ragx/providers/st_reranker.py +0 -0
  45. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/tests/__init__.py +0 -0
  46. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/tests/test_chunking.py +0 -0
  47. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/tests/test_cli.py +0 -0
  48. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/tests/test_discovery.py +0 -0
  49. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/tests/test_eval.py +0 -0
  50. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/tests/test_expansion.py +0 -0
  51. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/tests/test_fusion.py +0 -0
  52. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/tests/test_graph.py +0 -0
  53. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/tests/test_inspect.py +0 -0
  54. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/tests/test_integration.py +0 -0
  55. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/tests/test_providers.py +0 -0
  56. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/tests/test_store.py +0 -0
  57. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/tests/test_traversal.py +0 -0
  58. {ragx_cli-0.1.1 → ragx_cli-0.2.0}/tests/test_vectors.py +0 -0
@@ -7,3 +7,5 @@ dist/
7
7
  .ragx/
8
8
  uv.lock
9
9
  .claude/worktrees/
10
+ .lab/
11
+ run.log
@@ -23,6 +23,11 @@ API contracts for every core module. Deferred feature specs live in `docs/`.
23
23
  `http://localhost:1234/v1`. `provider="ollama"` auto-switches to `:11434/v1`.
24
24
  Env fallbacks: `OPENAI_BASE_URL` (only while base_url is still the default) and
25
25
  `OPENAI_API_KEY` (only when `api_key_env` is unset); explicit config always wins.
26
+ - Machine-level provider settings live in `~/.ragxrc` (TOML; ONLY the embeddings/
27
+ expansion/rerank sections, unknown keys fail loud). Precedence: DEFAULTS < corpus
28
+ config.toml < ~/.ragxrc — the rc OVERRIDES corpus values and logs a stderr warning
29
+ per overridden key. Write via `ragx-cli config set --global <key> <value>`.
30
+ `Config.save` never bakes rc values into the corpus file.
26
31
  - Reranker is always local: sentence-transformers CrossEncoder `BAAI/bge-reranker-v2-m3`
27
32
  (optional extra `ragx-cli[rerank]`). LM Studio has NO /v1/rerank endpoint — verified; don't try.
28
33
 
@@ -31,7 +36,7 @@ API contracts for every core module. Deferred feature specs live in `docs/`.
31
36
  ```
32
37
  src/ragx/
33
38
  core/ # all logic lives here, CLI-independent
34
- config.py # .ragx/config.toml load/save, DEFAULTS dict, find_root/require_root
39
+ config.py # .ragx/config.toml + ~/.ragxrc (provider sections; rc wins, warns), DEFAULTS, find_root
35
40
  models.py # Chunk, ChunkDraft, FileRecord, Edge, ScoredChunk, QueryOutput
36
41
  store.py # SQLite: files/chunks/edges/meta; replace_edges normalizes src<dst
37
42
  vectors.py # hnswlib wrapper; soft-delete via JSON sidecar; get_vectors for graph
@@ -82,9 +87,8 @@ unreachable by vector search or rerank-alone, surfaced only via a hop-1 edge the
82
87
 
83
88
  ## Gotchas
84
89
 
85
- - Ornith (`mlx-community/Ornith-1.0-35B-3bit`, Albert's required expansion LLM) is a
86
- *reasoning* model: answers land in `content` only after thinking; expansion uses
87
- max_tokens=4096 and takes ~40 s/call. Don't lower it.
90
+ - Reasoning models as expansion LLM: answers land in `content` only after thinking;
91
+ expansion uses max_tokens=4096 to leave room for that. Don't lower it.
88
92
  - Changing `embeddings.model` invalidates the index; run_query fails loud on manifest
89
93
  mismatch; full `ragx-cli index` rebuilds.
90
94
  - Chunk ids are SQLite AUTOINCREMENT rowids AND hnswlib labels — never reused after delete.
@@ -110,3 +114,5 @@ unreachable by vector search or rerank-alone, surfaced only via a hop-1 edge the
110
114
  `uv sync --group dev --extra rerank` · `uv run pytest -q` (126 pass, ~5 s) ·
111
115
  `uv run ruff check src tests` · file soft cap ~150 lines · expected failures raise `RagxError`
112
116
  (CLI maps to exit 2). Live smoke: LM Studio must be running with the configured embedding model.
117
+ Changes that impact usage (CLI flags, config keys/precedence, output schemas, install steps)
118
+ must get their representation in README.md in the same change.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ragx-cli
3
- Version: 0.1.1
3
+ Version: 0.2.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
@@ -63,9 +63,11 @@ uses a local sentence-transformers cross-encoder (`ragx-cli[rerank]` extra). Eve
63
63
  - [Indexing (LLM-free)](#indexing-llm-free)
64
64
  - [Querying](#querying)
65
65
  - [Does it actually help? (benchmarks)](#does-it-actually-help-benchmarks)
66
+ - [Parameter study: what each knob actually does (2026-07)](#parameter-study-what-each-knob-actually-does-2026-07)
66
67
  - [Agent-first conventions](#agent-first-conventions)
67
68
  - [Using ragx-cli from a coding agent (CLAUDE.md / AGENTS.md)](#using-ragx-cli-from-a-coding-agent-claudemd--agentsmd)
68
69
  - [Pointing ragx-cli at your LLM — local or online](#pointing-ragx-cli-at-your-llm--local-or-online)
70
+ - [Machine-level settings: `~/.ragxrc`](#machine-level-settings-ragxrc)
69
71
  - [Configuration](#configuration)
70
72
  - [Features \& roadmap](#features--roadmap)
71
73
 
@@ -178,6 +180,73 @@ sweep over decay/floor/weights plateaued below baseline MRR, so this is a proper
178
180
  similarity-only edges, not a tuning miss. Conclusion baked into the defaults: **graph and
179
181
  rerank ship together**. Use `--no-graph --no-rerank` as the explicit fast mode.
180
182
 
183
+ The parameter study below explains the mechanism behind this caveat — and shows that once the
184
+ scoring weights stop putting heat into the final score, the graph win gets much bigger.
185
+
186
+ ### Parameter study: what each knob actually does (2026-07)
187
+
188
+ A follow-up study swept every traversal, scoring, and graph-build parameter to map sensitivity
189
+ and find better settings. Setup: a scoped subset of the same wiki (`clients/` + `workstreams/`:
190
+ 383 files → 720 chunks → 2,848 edges) with a fresh 26-query EN+NL label set.
191
+
192
+ **Process.** Sweeping through real `eval` runs costs ~9 minutes each, so the study used an
193
+ offline harness (`.lab/harness.py`): one retrieval pass per query plus a text-keyed cross-encoder
194
+ score cache lets any traversal × scoring × edge-filter combination be re-evaluated in seconds —
195
+ the combination step is deterministic post-processing, and rerank scores depend only on
196
+ (query, chunk text). The harness was validated **digit-exact** against the real pipeline before
197
+ use, ~125 configurations were measured in staged rounds (scoring simplex → refinement →
198
+ traversal one-factor-at-a-time → interaction grid → edge-filter simulations), the winning config
199
+ was re-verified with a real `eval` run (again digit-exact), and finally replicated on an
200
+ independently rebuilt index.
201
+
202
+ **Result** — same-index comparisons, `rerank` config (no expansion) unless noted:
203
+
204
+ | config | MRR | recall@5 | recall@10 |
205
+ |---|---:|---:|---:|
206
+ | defaults (`hops=2`, α/β/γ = .6/.25/.15) | 0.665 | 0.885 | 0.923 |
207
+ | **tuned (`hops=3`, α/β/γ = .9/0/.1)** | **0.755** | 0.885 | **0.962** |
208
+
209
+ The +13.7% MRR delta replicated as +14.5% on an independently rebuilt index (absolute numbers
210
+ differ per build — see the measurement caveat below).
211
+
212
+ Apply the tuned settings to a corpus with:
213
+
214
+ ```bash
215
+ ragx-cli config set traversal.hops 3
216
+ ragx-cli config set scoring.alpha_rerank 0.9
217
+ ragx-cli config set scoring.beta_heat 0.0
218
+ ragx-cli config set scoring.gamma_vector 0.1
219
+ ```
220
+
221
+ **Discoveries**, in decreasing order of impact:
222
+
223
+ - **α (rerank weight) is the dominant knob.** MRR rises monotonically from α=.6 to a plateau at
224
+ α≈.85–.9 (+9% relative), with recall flat across the whole range. Heat belongs at **β=0**:
225
+ once the cross-encoder is trusted, heat in the final score only adds near-duplicate noise —
226
+ which is exactly why the earlier graph-only sweeps plateaued.
227
+ - **But never α=1.0.** With β=γ=0 the pre-rerank shortlist selector degenerates (it renormalizes
228
+ β:γ, so every pre-score becomes 0) and MRR collapses to 0.32. Vector + heat pick *which* 100
229
+ candidates the cross-encoder sees at all — they are load-bearing for shortlist selection even
230
+ at near-zero final weight. Keep γ > 0.
231
+ - **`hops=3` is the second win, but only after fixing the scoring.** Under default weights,
232
+ traversal depth is completely inert (heat dilution cancels the candidate gains); under
233
+ rerank-heavy weights it adds both MRR and recall@10. **`hops=4` degrades**: ~570 candidates
234
+ overwhelm the fixed rerank shortlist (`RERANK_CAP=100`) and good candidates get evicted before
235
+ the cross-encoder ever sees them — the cap, not the graph, becomes the binding constraint.
236
+ - **Graph = recall channel, cross-encoder = precision channel.** At identical tuned scoring,
237
+ removing the graph keeps MRR (0.71) but drops recall@10 by 7.7 points. The graph's job is to
238
+ put reachable targets in front of the reranker; the reranker's job is to rank them.
239
+ - **Everything else is inert or already optimal**: `decay` (.3–.7), `query_floor` (.2–.5),
240
+ `max_frontier` (50–300), and `min_edge_sim` (up to .7) don't move metrics; `k=8` is bracketed
241
+ optimal from both sides (k=4/6 lose recall, a real k=12 rebuild was no better).
242
+ - **LLM expansion buys recall, not ranking.** The full pipeline at default params reached the
243
+ same recall@10 that `hops=3` provides for free, at ~40 s/query for a local reasoning model.
244
+ On tuned params expansion still stacked (+.03 MRR) — worth it only when latency doesn't matter.
245
+ - **Measurement caveat: index rebuilds are not reproducible across embedding-server sessions.**
246
+ Rebuilding is deterministic within a session, but embeddings drift between LM Studio sessions
247
+ (~.04 MRR at identical params). Compare configs on the same build only; the tuned-vs-default
248
+ delta replicated across two independent builds (+13.7% / +14.5% MRR).
249
+
181
250
  ---
182
251
 
183
252
  ## Agent-first conventions
@@ -264,9 +333,28 @@ Mixed setups are normal — e.g. local Ollama embeddings + online expansion via
264
333
  download is unwanted. **Note:** changing the embedding model invalidates the index — ragx-cli
265
334
  detects the mismatch and asks you to run a full `ragx-cli index`.
266
335
 
336
+ ### Machine-level settings: `~/.ragxrc`
337
+
338
+ Provider settings that belong to the machine rather than the corpus — which embedding
339
+ model, which LLM, which base URL — can live in `~/.ragxrc` (TOML, same shape as
340
+ `config.toml`, restricted to the `[embeddings]`, `[expansion]`, and `[rerank]` sections):
341
+
342
+ ```bash
343
+ ragx-cli config set --global embeddings.model text-embedding-nomic-embed-text-v1.5
344
+ ragx-cli config set --global expansion.model llama3.1
345
+ ```
346
+
347
+ Precedence: built-in defaults < corpus `.ragx/config.toml` < `~/.ragxrc`. The rc
348
+ **overrides** corpus values, and every command warns on stderr when it does, so a
349
+ corpus config never loses silently. Set corpus-specific values without `--global`
350
+ as usual. Other sections (chunking, graph, …) are corpus-level and rejected from
351
+ the rc. The index-invalidation note above applies equally when the rc changes the
352
+ effective embedding model.
353
+
267
354
  ## Configuration
268
355
 
269
- `.ragx/config.toml`, managed via `ragx-cli config get|set`. Key defaults:
356
+ `.ragx/config.toml`, managed via `ragx-cli config get|set` (add `--global` to write
357
+ provider settings to `~/.ragxrc` instead — see above). Key defaults:
270
358
 
271
359
  | section | defaults |
272
360
  |---|---|
@@ -37,9 +37,11 @@ uses a local sentence-transformers cross-encoder (`ragx-cli[rerank]` extra). Eve
37
37
  - [Indexing (LLM-free)](#indexing-llm-free)
38
38
  - [Querying](#querying)
39
39
  - [Does it actually help? (benchmarks)](#does-it-actually-help-benchmarks)
40
+ - [Parameter study: what each knob actually does (2026-07)](#parameter-study-what-each-knob-actually-does-2026-07)
40
41
  - [Agent-first conventions](#agent-first-conventions)
41
42
  - [Using ragx-cli from a coding agent (CLAUDE.md / AGENTS.md)](#using-ragx-cli-from-a-coding-agent-claudemd--agentsmd)
42
43
  - [Pointing ragx-cli at your LLM — local or online](#pointing-ragx-cli-at-your-llm--local-or-online)
44
+ - [Machine-level settings: `~/.ragxrc`](#machine-level-settings-ragxrc)
43
45
  - [Configuration](#configuration)
44
46
  - [Features \& roadmap](#features--roadmap)
45
47
 
@@ -152,6 +154,73 @@ sweep over decay/floor/weights plateaued below baseline MRR, so this is a proper
152
154
  similarity-only edges, not a tuning miss. Conclusion baked into the defaults: **graph and
153
155
  rerank ship together**. Use `--no-graph --no-rerank` as the explicit fast mode.
154
156
 
157
+ The parameter study below explains the mechanism behind this caveat — and shows that once the
158
+ scoring weights stop putting heat into the final score, the graph win gets much bigger.
159
+
160
+ ### Parameter study: what each knob actually does (2026-07)
161
+
162
+ A follow-up study swept every traversal, scoring, and graph-build parameter to map sensitivity
163
+ and find better settings. Setup: a scoped subset of the same wiki (`clients/` + `workstreams/`:
164
+ 383 files → 720 chunks → 2,848 edges) with a fresh 26-query EN+NL label set.
165
+
166
+ **Process.** Sweeping through real `eval` runs costs ~9 minutes each, so the study used an
167
+ offline harness (`.lab/harness.py`): one retrieval pass per query plus a text-keyed cross-encoder
168
+ score cache lets any traversal × scoring × edge-filter combination be re-evaluated in seconds —
169
+ the combination step is deterministic post-processing, and rerank scores depend only on
170
+ (query, chunk text). The harness was validated **digit-exact** against the real pipeline before
171
+ use, ~125 configurations were measured in staged rounds (scoring simplex → refinement →
172
+ traversal one-factor-at-a-time → interaction grid → edge-filter simulations), the winning config
173
+ was re-verified with a real `eval` run (again digit-exact), and finally replicated on an
174
+ independently rebuilt index.
175
+
176
+ **Result** — same-index comparisons, `rerank` config (no expansion) unless noted:
177
+
178
+ | config | MRR | recall@5 | recall@10 |
179
+ |---|---:|---:|---:|
180
+ | defaults (`hops=2`, α/β/γ = .6/.25/.15) | 0.665 | 0.885 | 0.923 |
181
+ | **tuned (`hops=3`, α/β/γ = .9/0/.1)** | **0.755** | 0.885 | **0.962** |
182
+
183
+ The +13.7% MRR delta replicated as +14.5% on an independently rebuilt index (absolute numbers
184
+ differ per build — see the measurement caveat below).
185
+
186
+ Apply the tuned settings to a corpus with:
187
+
188
+ ```bash
189
+ ragx-cli config set traversal.hops 3
190
+ ragx-cli config set scoring.alpha_rerank 0.9
191
+ ragx-cli config set scoring.beta_heat 0.0
192
+ ragx-cli config set scoring.gamma_vector 0.1
193
+ ```
194
+
195
+ **Discoveries**, in decreasing order of impact:
196
+
197
+ - **α (rerank weight) is the dominant knob.** MRR rises monotonically from α=.6 to a plateau at
198
+ α≈.85–.9 (+9% relative), with recall flat across the whole range. Heat belongs at **β=0**:
199
+ once the cross-encoder is trusted, heat in the final score only adds near-duplicate noise —
200
+ which is exactly why the earlier graph-only sweeps plateaued.
201
+ - **But never α=1.0.** With β=γ=0 the pre-rerank shortlist selector degenerates (it renormalizes
202
+ β:γ, so every pre-score becomes 0) and MRR collapses to 0.32. Vector + heat pick *which* 100
203
+ candidates the cross-encoder sees at all — they are load-bearing for shortlist selection even
204
+ at near-zero final weight. Keep γ > 0.
205
+ - **`hops=3` is the second win, but only after fixing the scoring.** Under default weights,
206
+ traversal depth is completely inert (heat dilution cancels the candidate gains); under
207
+ rerank-heavy weights it adds both MRR and recall@10. **`hops=4` degrades**: ~570 candidates
208
+ overwhelm the fixed rerank shortlist (`RERANK_CAP=100`) and good candidates get evicted before
209
+ the cross-encoder ever sees them — the cap, not the graph, becomes the binding constraint.
210
+ - **Graph = recall channel, cross-encoder = precision channel.** At identical tuned scoring,
211
+ removing the graph keeps MRR (0.71) but drops recall@10 by 7.7 points. The graph's job is to
212
+ put reachable targets in front of the reranker; the reranker's job is to rank them.
213
+ - **Everything else is inert or already optimal**: `decay` (.3–.7), `query_floor` (.2–.5),
214
+ `max_frontier` (50–300), and `min_edge_sim` (up to .7) don't move metrics; `k=8` is bracketed
215
+ optimal from both sides (k=4/6 lose recall, a real k=12 rebuild was no better).
216
+ - **LLM expansion buys recall, not ranking.** The full pipeline at default params reached the
217
+ same recall@10 that `hops=3` provides for free, at ~40 s/query for a local reasoning model.
218
+ On tuned params expansion still stacked (+.03 MRR) — worth it only when latency doesn't matter.
219
+ - **Measurement caveat: index rebuilds are not reproducible across embedding-server sessions.**
220
+ Rebuilding is deterministic within a session, but embeddings drift between LM Studio sessions
221
+ (~.04 MRR at identical params). Compare configs on the same build only; the tuned-vs-default
222
+ delta replicated across two independent builds (+13.7% / +14.5% MRR).
223
+
155
224
  ---
156
225
 
157
226
  ## Agent-first conventions
@@ -238,9 +307,28 @@ Mixed setups are normal — e.g. local Ollama embeddings + online expansion via
238
307
  download is unwanted. **Note:** changing the embedding model invalidates the index — ragx-cli
239
308
  detects the mismatch and asks you to run a full `ragx-cli index`.
240
309
 
310
+ ### Machine-level settings: `~/.ragxrc`
311
+
312
+ Provider settings that belong to the machine rather than the corpus — which embedding
313
+ model, which LLM, which base URL — can live in `~/.ragxrc` (TOML, same shape as
314
+ `config.toml`, restricted to the `[embeddings]`, `[expansion]`, and `[rerank]` sections):
315
+
316
+ ```bash
317
+ ragx-cli config set --global embeddings.model text-embedding-nomic-embed-text-v1.5
318
+ ragx-cli config set --global expansion.model llama3.1
319
+ ```
320
+
321
+ Precedence: built-in defaults < corpus `.ragx/config.toml` < `~/.ragxrc`. The rc
322
+ **overrides** corpus values, and every command warns on stderr when it does, so a
323
+ corpus config never loses silently. Set corpus-specific values without `--global`
324
+ as usual. Other sections (chunking, graph, …) are corpus-level and rejected from
325
+ the rc. The index-invalidation note above applies equally when the rc changes the
326
+ effective embedding model.
327
+
241
328
  ## Configuration
242
329
 
243
- `.ragx/config.toml`, managed via `ragx-cli config get|set`. Key defaults:
330
+ `.ragx/config.toml`, managed via `ragx-cli config get|set` (add `--global` to write
331
+ provider settings to `~/.ragxrc` instead — see above). Key defaults:
244
332
 
245
333
  | section | defaults |
246
334
  |---|---|
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ragx-cli"
3
- version = "0.1.1"
3
+ version = "0.2.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 @@
1
+ __version__ = "0.2.0"
@@ -9,7 +9,15 @@ from pathlib import Path
9
9
  import typer
10
10
 
11
11
  from ragx.cli.output import emit_json, fail
12
- from ragx.core.config import Config, RAGX_DIR, db_path, require_root, write_default_config
12
+ from ragx.core.config import (
13
+ Config,
14
+ RAGX_DIR,
15
+ db_path,
16
+ rc_path,
17
+ require_root,
18
+ write_default_config,
19
+ write_rc_value,
20
+ )
13
21
  from ragx.core.errors import RagxError
14
22
 
15
23
  app = typer.Typer(add_completion=False, no_args_is_help=True)
@@ -89,7 +97,18 @@ def config_get(key: str) -> None:
89
97
 
90
98
 
91
99
  @config_app.command("set")
92
- def config_set(key: str, value: str) -> None:
100
+ def config_set(
101
+ key: str,
102
+ value: str,
103
+ global_: bool = typer.Option(False, "--global", help="write to ~/.ragxrc (provider settings only)"),
104
+ ) -> None:
105
+ if global_:
106
+ try:
107
+ coerced = write_rc_value(key, value)
108
+ except RagxError as exc:
109
+ fail(str(exc))
110
+ typer.echo(f"{key} = {coerced} ({rc_path()})")
111
+ return
93
112
  root = _require_root()
94
113
  cfg = Config.load(root)
95
114
  try:
@@ -97,7 +116,8 @@ def config_set(key: str, value: str) -> None:
97
116
  except RagxError as exc:
98
117
  fail(str(exc))
99
118
  cfg.save(root)
100
- typer.echo(f"{key} = {cfg.get(key)}")
119
+ # reload so the echoed value is the effective one (an rc override warns and wins)
120
+ typer.echo(f"{key} = {Config.load(root).get(key)}")
101
121
 
102
122
 
103
123
  def main() -> None:
@@ -0,0 +1,200 @@
1
+ """Config: .ragx/config.toml per corpus, plus ~/.ragxrc for machine-level provider
2
+ settings (embeddings/expansion/rerank). The rc overrides corpus values — with a warning."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import tomllib
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import tomli_w
12
+
13
+ from ragx.core.errors import NotInitializedError, RagxError
14
+
15
+ RAGX_DIR = ".ragx"
16
+ PROVIDER_SECTIONS = ("embeddings", "expansion", "rerank")
17
+
18
+ log = logging.getLogger("ragx.config")
19
+
20
+
21
+ def rc_path() -> Path:
22
+ return Path.home() / ".ragxrc"
23
+
24
+ DEFAULTS: dict[str, dict[str, Any]] = {
25
+ "corpus": {"include": ["**/*"], "exclude": [], "respect_gitignore": True},
26
+ "chunking": {"size_tokens": 800, "overlap": 0.15},
27
+ "graph": {"k": 8, "min_edge_sim": 0.55},
28
+ "traversal": {"hops": 2, "decay": 0.5, "query_floor": 0.35, "max_frontier": 150},
29
+ "fusion": {"rrf_k": 60, "per_query_top": 20},
30
+ "scoring": {"alpha_rerank": 0.6, "beta_heat": 0.25, "gamma_vector": 0.15},
31
+ "embeddings": {
32
+ "provider": "openai",
33
+ "base_url": "http://localhost:1234/v1",
34
+ "model": "text-embedding-nomic-embed-text-v1.5@q4_k_m",
35
+ "doc_prefix": "search_document: ",
36
+ "query_prefix": "search_query: ",
37
+ "batch_size": 32,
38
+ "api_key_env": "", # NAME of an env var holding the API key; empty = no auth header
39
+ },
40
+ "expansion": {
41
+ "enabled": True,
42
+ "provider": "openai",
43
+ "base_url": "http://localhost:1234/v1",
44
+ "model": "qwen3.5-9b",
45
+ "variants": 3,
46
+ "hyde": True,
47
+ "api_key_env": "",
48
+ },
49
+ "rerank": {"enabled": True, "provider": "sentence-transformers", "model": "BAAI/bge-reranker-v2-m3"},
50
+ "query": {"top": 8, "max_chunk_chars": 1200},
51
+ }
52
+
53
+
54
+ def find_root(start: Path | None = None) -> Path | None:
55
+ """Walk up from `start` (default cwd) looking for a .ragx/ directory."""
56
+ cur = (start or Path.cwd()).resolve()
57
+ for p in [cur, *cur.parents]:
58
+ if (p / RAGX_DIR).is_dir():
59
+ return p
60
+ return None
61
+
62
+
63
+ def require_root(start: Path | None = None) -> Path:
64
+ root = find_root(start)
65
+ if root is None:
66
+ raise NotInitializedError("no .ragx/ found — run `ragx-cli init` first")
67
+ return root
68
+
69
+
70
+ def config_path(root: Path) -> Path:
71
+ return root / RAGX_DIR / "config.toml"
72
+
73
+
74
+ def db_path(root: Path) -> Path:
75
+ return root / RAGX_DIR / "index.db"
76
+
77
+
78
+ def vectors_path(root: Path) -> Path:
79
+ return root / RAGX_DIR / "vectors.hnsw"
80
+
81
+
82
+ def _deep_merge(base: dict, override: dict) -> dict:
83
+ out = {k: dict(v) for k, v in base.items()}
84
+ for section, values in override.items():
85
+ out.setdefault(section, {}).update(values)
86
+ return out
87
+
88
+
89
+ def _coerce(section: str, key: str, value: Any) -> Any:
90
+ """Coerce a (usually string) value to the type of its DEFAULTS entry."""
91
+ current = DEFAULTS[section][key]
92
+ if isinstance(current, bool):
93
+ return str(value).lower() in ("1", "true", "yes", "on")
94
+ if isinstance(current, int) and not isinstance(current, bool):
95
+ return int(value)
96
+ if isinstance(current, float):
97
+ return float(value)
98
+ if isinstance(current, list) and isinstance(value, str):
99
+ return [v.strip() for v in value.split(",") if v.strip()]
100
+ return value
101
+
102
+
103
+ def load_rc(rc: Path | None = None) -> dict[str, dict[str, Any]]:
104
+ """Parse ~/.ragxrc. Only provider sections with known keys are allowed — fails loud."""
105
+ path = rc if rc is not None else rc_path()
106
+ if not path.exists():
107
+ return {}
108
+ with path.open("rb") as f:
109
+ try:
110
+ data = tomllib.load(f)
111
+ except tomllib.TOMLDecodeError as exc:
112
+ raise RagxError(f"malformed {path}: {exc}") from exc
113
+ for section, values in data.items():
114
+ if section not in PROVIDER_SECTIONS:
115
+ raise RagxError(
116
+ f"{path}: section [{section}] not allowed — only {', '.join(PROVIDER_SECTIONS)}"
117
+ )
118
+ for key in values:
119
+ if key not in DEFAULTS[section]:
120
+ raise RagxError(f"{path}: unknown key {section}.{key}")
121
+ return data
122
+
123
+
124
+ def write_rc_value(dotted: str, value: Any, rc: Path | None = None) -> Any:
125
+ """Set one provider key in ~/.ragxrc (created if missing). Returns the coerced value."""
126
+ section, _, key = dotted.partition(".")
127
+ if not key or section not in DEFAULTS or key not in DEFAULTS[section]:
128
+ raise RagxError(f"unknown config key: {dotted!r}")
129
+ if section not in PROVIDER_SECTIONS:
130
+ raise RagxError(
131
+ f"{dotted!r} is corpus-level — only {', '.join(PROVIDER_SECTIONS)} keys go in ~/.ragxrc"
132
+ )
133
+ path = rc if rc is not None else rc_path()
134
+ data = load_rc(path)
135
+ coerced = _coerce(section, key, value)
136
+ data.setdefault(section, {})[key] = coerced
137
+ with path.open("wb") as f:
138
+ tomli_w.dump(data, f)
139
+ return coerced
140
+
141
+
142
+ class Config:
143
+ """Two-level (section.key) config: DEFAULTS < corpus config.toml < ~/.ragxrc.
144
+
145
+ `data` is the effective merged view; `file_data` is the raw corpus file so that
146
+ `save` never bakes rc overrides into the corpus config.
147
+ """
148
+
149
+ def __init__(self, data: dict[str, dict[str, Any]], file_data: dict | None = None):
150
+ self.data = data
151
+ self.file_data = file_data if file_data is not None else data
152
+
153
+ @classmethod
154
+ def load(cls, root: Path, rc: Path | None = None) -> Config:
155
+ path = config_path(root)
156
+ file_data: dict = {}
157
+ if path.exists():
158
+ with path.open("rb") as f:
159
+ file_data = tomllib.load(f)
160
+ rc_data = load_rc(rc)
161
+ for section, values in rc_data.items():
162
+ for key, rc_value in values.items():
163
+ corpus_value = file_data.get(section, {}).get(key)
164
+ if corpus_value is not None and corpus_value != rc_value:
165
+ log.warning(
166
+ "~/.ragxrc overrides %s.%s: %r (corpus) -> %r (rc)",
167
+ section, key, corpus_value, rc_value,
168
+ )
169
+ return cls(_deep_merge(DEFAULTS, _deep_merge(file_data, rc_data)), file_data)
170
+
171
+ def get(self, dotted: str) -> Any:
172
+ section, _, key = dotted.partition(".")
173
+ if not key:
174
+ raise RagxError(f"config keys are 'section.key', got: {dotted!r}")
175
+ try:
176
+ return self.data[section][key]
177
+ except KeyError:
178
+ raise RagxError(f"unknown config key: {dotted!r}") from None
179
+
180
+ def set(self, dotted: str, value: Any) -> None:
181
+ section, _, key = dotted.partition(".")
182
+ if not key or section not in DEFAULTS or key not in DEFAULTS[section]:
183
+ raise RagxError(f"unknown config key: {dotted!r}")
184
+ coerced = _coerce(section, key, value)
185
+ self.data.setdefault(section, {})[key] = coerced
186
+ if self.file_data is not self.data:
187
+ self.file_data.setdefault(section, {})[key] = coerced
188
+
189
+ def save(self, root: Path) -> None:
190
+ path = config_path(root)
191
+ path.parent.mkdir(parents=True, exist_ok=True)
192
+ with path.open("wb") as f:
193
+ tomli_w.dump(_deep_merge(DEFAULTS, self.file_data), f)
194
+
195
+
196
+ def write_default_config(root: Path) -> Path:
197
+ """Create .ragx/ with a default config.toml. Used by `ragx-cli init`."""
198
+ cfg = Config({k: dict(v) for k, v in DEFAULTS.items()})
199
+ cfg.save(root)
200
+ return config_path(root)
@@ -0,0 +1,13 @@
1
+ """Shared fixtures. HOME is isolated per-test so a developer's real ~/.ragxrc
2
+ can never leak into the suite (the rc overrides corpus config by design)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import pytest
7
+
8
+
9
+ @pytest.fixture(autouse=True)
10
+ def _isolate_home(tmp_path_factory, monkeypatch):
11
+ home = tmp_path_factory.mktemp("home")
12
+ monkeypatch.setenv("HOME", str(home))
13
+ return home
@@ -0,0 +1,118 @@
1
+ """~/.ragxrc: machine-level provider settings that override corpus config (with a warning)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import tomllib
7
+
8
+ import pytest
9
+ from typer.testing import CliRunner
10
+
11
+ from ragx.cli.app import app
12
+ from ragx.core.config import Config, config_path, load_rc, write_default_config, write_rc_value
13
+ from ragx.core.errors import RagxError
14
+
15
+ runner = CliRunner()
16
+
17
+
18
+ @pytest.fixture
19
+ def rc(_isolate_home):
20
+ return _isolate_home / ".ragxrc"
21
+
22
+
23
+ def test_no_rc_is_a_noop(tmp_path, rc):
24
+ write_default_config(tmp_path)
25
+ cfg = Config.load(tmp_path)
26
+ assert cfg.get("embeddings.model") == "text-embedding-nomic-embed-text-v1.5@q4_k_m"
27
+
28
+
29
+ def test_rc_overrides_corpus_and_warns(tmp_path, rc, caplog):
30
+ write_default_config(tmp_path)
31
+ rc.write_text('[embeddings]\nmodel = "rc-model"\n')
32
+ with caplog.at_level(logging.WARNING, logger="ragx.config"):
33
+ cfg = Config.load(tmp_path)
34
+ assert cfg.get("embeddings.model") == "rc-model"
35
+ assert any("~/.ragxrc overrides embeddings.model" in r.getMessage() for r in caplog.records)
36
+
37
+
38
+ def test_rc_matching_corpus_value_does_not_warn(tmp_path, rc, caplog):
39
+ write_default_config(tmp_path)
40
+ rc.write_text('[rerank]\nmodel = "BAAI/bge-reranker-v2-m3"\n') # same as default/corpus
41
+ with caplog.at_level(logging.WARNING, logger="ragx.config"):
42
+ cfg = Config.load(tmp_path)
43
+ assert cfg.get("rerank.model") == "BAAI/bge-reranker-v2-m3"
44
+ assert not caplog.records
45
+
46
+
47
+ def test_rc_applies_without_corpus_file(tmp_path, rc, caplog):
48
+ # .ragx/ exists but no config.toml was written: rc applies silently
49
+ (tmp_path / ".ragx").mkdir()
50
+ rc.write_text('[expansion]\nmodel = "rc-llm"\nenabled = false\n')
51
+ with caplog.at_level(logging.WARNING, logger="ragx.config"):
52
+ cfg = Config.load(tmp_path)
53
+ assert cfg.get("expansion.model") == "rc-llm"
54
+ assert cfg.get("expansion.enabled") is False
55
+ assert not caplog.records
56
+
57
+
58
+ def test_rc_rejects_non_provider_section(tmp_path, rc):
59
+ write_default_config(tmp_path)
60
+ rc.write_text("[graph]\nk = 12\n")
61
+ with pytest.raises(RagxError, match=r"section \[graph\] not allowed"):
62
+ Config.load(tmp_path)
63
+
64
+
65
+ def test_rc_rejects_unknown_key(tmp_path, rc):
66
+ write_default_config(tmp_path)
67
+ rc.write_text('[embeddings]\nbogus = "x"\n')
68
+ with pytest.raises(RagxError, match="unknown key embeddings.bogus"):
69
+ Config.load(tmp_path)
70
+
71
+
72
+ def test_rc_malformed_toml(tmp_path, rc):
73
+ write_default_config(tmp_path)
74
+ rc.write_text("not [valid toml")
75
+ with pytest.raises(RagxError, match="malformed"):
76
+ Config.load(tmp_path)
77
+
78
+
79
+ def test_write_rc_value_creates_coerces_and_rejects(rc):
80
+ coerced = write_rc_value("embeddings.batch_size", "64")
81
+ assert coerced == 64
82
+ assert load_rc(rc) == {"embeddings": {"batch_size": 64}}
83
+ with pytest.raises(RagxError, match="corpus-level"):
84
+ write_rc_value("graph.k", "12")
85
+ with pytest.raises(RagxError, match="unknown config key"):
86
+ write_rc_value("embeddings.bogus", "x")
87
+
88
+
89
+ def test_config_set_does_not_bake_rc_into_corpus(tmp_path, rc):
90
+ write_default_config(tmp_path)
91
+ rc.write_text('[embeddings]\nmodel = "rc-model"\n')
92
+ cfg = Config.load(tmp_path)
93
+ cfg.set("graph.k", "12")
94
+ cfg.save(tmp_path)
95
+ saved = tomllib.loads(config_path(tmp_path).read_text())
96
+ assert saved["graph"]["k"] == 12
97
+ assert saved["embeddings"]["model"] == "text-embedding-nomic-embed-text-v1.5@q4_k_m"
98
+
99
+
100
+ def test_cli_set_global_and_effective_get(tmp_path, rc, monkeypatch):
101
+ runner.invoke(app, ["init", str(tmp_path)])
102
+ monkeypatch.chdir(tmp_path)
103
+
104
+ result = runner.invoke(app, ["config", "set", "--global", "embeddings.model", "rc-model"])
105
+ assert result.exit_code == 0
106
+ assert "embeddings.model = rc-model" in result.stdout
107
+ assert load_rc(rc) == {"embeddings": {"model": "rc-model"}}
108
+
109
+ result = runner.invoke(app, ["config", "get", "embeddings.model"])
110
+ assert result.exit_code == 0
111
+ assert result.stdout.strip() == "rc-model"
112
+
113
+
114
+ def test_cli_set_global_rejects_corpus_key(tmp_path, monkeypatch):
115
+ runner.invoke(app, ["init", str(tmp_path)])
116
+ monkeypatch.chdir(tmp_path)
117
+ result = runner.invoke(app, ["config", "set", "--global", "graph.k", "12"])
118
+ assert result.exit_code == 2
@@ -1 +0,0 @@
1
- __version__ = "0.1.1"
@@ -1,130 +0,0 @@
1
- """Config: .ragx/config.toml is the single source of truth. Written by `init`, read everywhere."""
2
-
3
- from __future__ import annotations
4
-
5
- import tomllib
6
- from pathlib import Path
7
- from typing import Any
8
-
9
- import tomli_w
10
-
11
- from ragx.core.errors import NotInitializedError, RagxError
12
-
13
- RAGX_DIR = ".ragx"
14
-
15
- DEFAULTS: dict[str, dict[str, Any]] = {
16
- "corpus": {"include": ["**/*"], "exclude": [], "respect_gitignore": True},
17
- "chunking": {"size_tokens": 800, "overlap": 0.15},
18
- "graph": {"k": 8, "min_edge_sim": 0.55},
19
- "traversal": {"hops": 2, "decay": 0.5, "query_floor": 0.35, "max_frontier": 150},
20
- "fusion": {"rrf_k": 60, "per_query_top": 20},
21
- "scoring": {"alpha_rerank": 0.6, "beta_heat": 0.25, "gamma_vector": 0.15},
22
- "embeddings": {
23
- "provider": "openai",
24
- "base_url": "http://localhost:1234/v1",
25
- "model": "text-embedding-nomic-embed-text-v1.5@q4_k_m",
26
- "doc_prefix": "search_document: ",
27
- "query_prefix": "search_query: ",
28
- "batch_size": 32,
29
- "api_key_env": "", # NAME of an env var holding the API key; empty = no auth header
30
- },
31
- "expansion": {
32
- "enabled": True,
33
- "provider": "openai",
34
- "base_url": "http://localhost:1234/v1",
35
- "model": "mlx-community/Ornith-1.0-35B-3bit",
36
- "variants": 3,
37
- "hyde": True,
38
- "api_key_env": "",
39
- },
40
- "rerank": {"enabled": True, "provider": "sentence-transformers", "model": "BAAI/bge-reranker-v2-m3"},
41
- "query": {"top": 8, "max_chunk_chars": 1200},
42
- }
43
-
44
-
45
- def find_root(start: Path | None = None) -> Path | None:
46
- """Walk up from `start` (default cwd) looking for a .ragx/ directory."""
47
- cur = (start or Path.cwd()).resolve()
48
- for p in [cur, *cur.parents]:
49
- if (p / RAGX_DIR).is_dir():
50
- return p
51
- return None
52
-
53
-
54
- def require_root(start: Path | None = None) -> Path:
55
- root = find_root(start)
56
- if root is None:
57
- raise NotInitializedError("no .ragx/ found — run `ragx-cli init` first")
58
- return root
59
-
60
-
61
- def config_path(root: Path) -> Path:
62
- return root / RAGX_DIR / "config.toml"
63
-
64
-
65
- def db_path(root: Path) -> Path:
66
- return root / RAGX_DIR / "index.db"
67
-
68
-
69
- def vectors_path(root: Path) -> Path:
70
- return root / RAGX_DIR / "vectors.hnsw"
71
-
72
-
73
- def _deep_merge(base: dict, override: dict) -> dict:
74
- out = {k: dict(v) for k, v in base.items()}
75
- for section, values in override.items():
76
- out.setdefault(section, {}).update(values)
77
- return out
78
-
79
-
80
- class Config:
81
- """Two-level (section.key) config with defaults merged under the file's values."""
82
-
83
- def __init__(self, data: dict[str, dict[str, Any]]):
84
- self.data = data
85
-
86
- @classmethod
87
- def load(cls, root: Path) -> Config:
88
- path = config_path(root)
89
- file_data: dict = {}
90
- if path.exists():
91
- with path.open("rb") as f:
92
- file_data = tomllib.load(f)
93
- return cls(_deep_merge(DEFAULTS, file_data))
94
-
95
- def get(self, dotted: str) -> Any:
96
- section, _, key = dotted.partition(".")
97
- if not key:
98
- raise RagxError(f"config keys are 'section.key', got: {dotted!r}")
99
- try:
100
- return self.data[section][key]
101
- except KeyError:
102
- raise RagxError(f"unknown config key: {dotted!r}") from None
103
-
104
- def set(self, dotted: str, value: Any) -> None:
105
- section, _, key = dotted.partition(".")
106
- if not key or section not in DEFAULTS or key not in DEFAULTS[section]:
107
- raise RagxError(f"unknown config key: {dotted!r}")
108
- current = DEFAULTS[section][key]
109
- if isinstance(current, bool):
110
- value = str(value).lower() in ("1", "true", "yes", "on")
111
- elif isinstance(current, int) and not isinstance(current, bool):
112
- value = int(value)
113
- elif isinstance(current, float):
114
- value = float(value)
115
- elif isinstance(current, list) and isinstance(value, str):
116
- value = [v.strip() for v in value.split(",") if v.strip()]
117
- self.data.setdefault(section, {})[key] = value
118
-
119
- def save(self, root: Path) -> None:
120
- path = config_path(root)
121
- path.parent.mkdir(parents=True, exist_ok=True)
122
- with path.open("wb") as f:
123
- tomli_w.dump(self.data, f)
124
-
125
-
126
- def write_default_config(root: Path) -> Path:
127
- """Create .ragx/ with a default config.toml. Used by `ragx-cli init`."""
128
- cfg = Config({k: dict(v) for k, v in DEFAULTS.items()})
129
- cfg.save(root)
130
- return config_path(root)
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