ragx-cli 0.1.0__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.0 → ragx_cli-0.2.0}/.gitignore +2 -0
  2. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/CLAUDE.md +12 -6
  3. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/CONTRACTS-PHASE23.md +5 -5
  4. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/CONTRACTS.md +5 -5
  5. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/PKG-INFO +92 -4
  6. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/README.md +91 -3
  7. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/docs/feature-temporal-weighting.md +2 -2
  8. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/pyproject.toml +1 -1
  9. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/ragx-cli-plan.md +12 -12
  10. ragx_cli-0.2.0/src/ragx/__init__.py +1 -0
  11. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/cli/app.py +24 -4
  12. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/cli/eval_cmd.py +1 -1
  13. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/cli/inspect_cmd.py +1 -1
  14. ragx_cli-0.2.0/src/ragx/core/config.py +200 -0
  15. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/indexer.py +1 -1
  16. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/query.py +1 -1
  17. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/providers/registry.py +1 -1
  18. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/providers/st_reranker.py +1 -1
  19. ragx_cli-0.2.0/tests/conftest.py +13 -0
  20. ragx_cli-0.2.0/tests/test_config_rc.py +118 -0
  21. ragx_cli-0.1.0/src/ragx/__init__.py +0 -1
  22. ragx_cli-0.1.0/src/ragx/core/config.py +0 -130
  23. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/.github/workflows/publish.yml +0 -0
  24. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/AGENTS.md +0 -0
  25. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/LICENSE +0 -0
  26. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/cli/__init__.py +0 -0
  27. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/cli/output.py +0 -0
  28. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/cli/pipeline.py +0 -0
  29. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/__init__.py +0 -0
  30. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/chunking.py +0 -0
  31. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/discovery.py +0 -0
  32. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/errors.py +0 -0
  33. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/eval.py +0 -0
  34. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/expansion.py +0 -0
  35. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/fusion.py +0 -0
  36. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/graph.py +0 -0
  37. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/models.py +0 -0
  38. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/scoring.py +0 -0
  39. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/store.py +0 -0
  40. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/traversal.py +0 -0
  41. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/vectors.py +0 -0
  42. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/providers/__init__.py +0 -0
  43. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/providers/base.py +0 -0
  44. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/providers/openai_compat.py +0 -0
  45. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/__init__.py +0 -0
  46. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_chunking.py +0 -0
  47. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_cli.py +0 -0
  48. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_discovery.py +0 -0
  49. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_eval.py +0 -0
  50. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_expansion.py +0 -0
  51. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_fusion.py +0 -0
  52. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_graph.py +0 -0
  53. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_inspect.py +0 -0
  54. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_integration.py +0 -0
  55. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_providers.py +0 -0
  56. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_store.py +0 -0
  57. {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_traversal.py +0 -0
  58. {ragx_cli-0.1.0 → 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.
@@ -92,8 +96,8 @@ unreachable by vector search or rerank-alone, surfaced only via a hop-1 edge the
92
96
  - `Config.set` rejects keys not in `DEFAULTS` — add new config keys to DEFAULTS first.
93
97
  - Tests must not hit the network; one provider test self-skips when sentence-transformers
94
98
  is installed. Pyright import errors in editors = interpreter not set to `.venv`.
95
- - When running ragx against the wiki corpus: `cd` there, then
96
- `uv run --project /Users/albert/projects/alber70g/ragx ragx-cli …` — and NEVER `git add`/commit
99
+ - When running ragx-cli against the wiki corpus: `cd` there, then
100
+ `uv run --project /Users/albert/projects/alber70g/ragx-cli ragx-cli …` — and NEVER `git add`/commit
97
101
  from that directory (it's Albert's personal notes repo; `.ragx/` is gitignored there).
98
102
 
99
103
  ## What's next / deferred
@@ -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,4 +1,4 @@
1
- # ragx module contracts — Phase 2–3 build
1
+ # ragx-cli module contracts — Phase 2–3 build
2
2
 
3
3
  Same ground rules as CONTRACTS.md (read its "Global rules" and "Report protocol" — they apply
4
4
  verbatim). Own ONLY your listed files; never modify shared/core files or other modules' files.
@@ -131,7 +131,7 @@ def evaluate(queries: list[dict], configs: list[tuple[str, QueryOptions]], query
131
131
 
132
132
  # eval_cmd.py
133
133
  def register(app: typer.Typer) -> None: ...
134
- # ragx eval QUERIES_FILE [--json] [--top N] [--configs csv]
134
+ # ragx-cli eval QUERIES_FILE [--json] [--top N] [--configs csv]
135
135
  # built-in configs: baseline (expand/graph/rerank all off), graph (only graph on),
136
136
  # full (all on). Default: run all three.
137
137
  # Constructs QueryOptions accordingly and query_fn from run_query + make_embedder(cfg) —
@@ -151,10 +151,10 @@ a monkeypatched evaluate/query_fn (no embeddings, no network).
151
151
 
152
152
  ```python
153
153
  def register(app: typer.Typer) -> None: # app.add_typer(inspect_app, name="inspect")
154
- # ragx inspect chunk ID [--json] -> chunk text, file, line/byte range + its edges
154
+ # ragx-cli inspect chunk ID [--json] -> chunk text, file, line/byte range + its edges
155
155
  # (neighbor id, weight, neighbor's file path)
156
- # ragx inspect file PATH [--json] -> file record + its chunks (id, line range, first 80 chars)
157
- # ragx inspect neighbors ID [--json] -> store.neighbors(ID) enriched with each neighbor's file/lines
156
+ # ragx-cli inspect file PATH [--json] -> file record + its chunks (id, line range, first 80 chars)
157
+ # ragx-cli inspect neighbors ID [--json] -> store.neighbors(ID) enriched with each neighbor's file/lines
158
158
  ```
159
159
 
160
160
  All read-only over Store (find root via require_root; no embeddings, no vectors file needed).
@@ -1,4 +1,4 @@
1
- # ragx module contracts (Phase 0–1 build)
1
+ # ragx-cli module contracts (Phase 0–1 build)
2
2
 
3
3
  Ground truth for the module-parallel build. Each module owns exactly the files listed for it.
4
4
  **Do not create or edit files outside your module** — shared types live in `core/models.py`,
@@ -93,7 +93,7 @@ class OpenAICompatGenerator: # implements providers.base.Generator
93
93
  # returns choices[0].message.content
94
94
 
95
95
  # st_reranker.py — lazy `import sentence_transformers` inside __init__;
96
- # ImportError -> RagxError("... install with: uv pip install 'ragx[rerank]'")
96
+ # ImportError -> RagxError("... install with: uv tool install ragx-cli --with ragx-cli[rerank]")
97
97
  class STReranker: # implements providers.base.Reranker
98
98
  def __init__(self, model: str): ...
99
99
  def score(self, query: str, texts: Sequence[str]) -> list[float]: ... # CrossEncoder.predict
@@ -205,11 +205,11 @@ def fail(msg: str, code: int = 2) -> NoReturn: ...
205
205
 
206
206
  # app.py
207
207
  app = typer.Typer(...)
208
- # ragx init [path] -> write_default_config at path (default cwd); error (exit 2) if .ragx exists;
208
+ # ragx-cli init [path] -> write_default_config at path (default cwd); error (exit 2) if .ragx-cli exists;
209
209
  # prints created config path
210
- # ragx status [--json] -> root path, embedding model/provider, counts (files/chunks/edges) read via
210
+ # ragx-cli status [--json] -> root path, embedding model/provider, counts (files/chunks/edges) read via
211
211
  # Store if index.db exists else zeros, schema "ragx.status.v1" in JSON mode
212
- # ragx config get KEY / ragx config set KEY VALUE -> Config.get / Config.set + save
212
+ # ragx-cli config get KEY / ragx-cli config set KEY VALUE -> Config.get / Config.set + save
213
213
  def main() -> None: ... # entry point declared in pyproject [project.scripts]
214
214
  ```
215
215
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ragx-cli
3
- Version: 0.1.0
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
@@ -45,7 +45,7 @@ JSON schemas, deterministic exit codes, byte-exact source locations, and an `--e
45
45
  that can justify every result via the exact graph path that produced it.
46
46
 
47
47
  ```bash
48
- uv tool install ragx-cli # or run one-off without installing: uvx ragx-cli ...
48
+ uv tool install ragx-cli --with ragx-cli[rerank] # install and use `ragx-cli`
49
49
  ragx-cli init # create .ragx/ with config.toml next to your corpus
50
50
  ragx-cli index # chunk -> embed -> HNSW + kNN similarity graph
51
51
  ragx-cli query "why did we switch build tools?" --json --files-only
@@ -63,11 +63,13 @@ 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
- - [Features & roadmap](#features--roadmap)
72
+ - [Features \& roadmap](#features--roadmap)
71
73
 
72
74
 
73
75
  ## How it works
@@ -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
  |---|---|
@@ -19,7 +19,7 @@ JSON schemas, deterministic exit codes, byte-exact source locations, and an `--e
19
19
  that can justify every result via the exact graph path that produced it.
20
20
 
21
21
  ```bash
22
- uv tool install ragx-cli # or run one-off without installing: uvx ragx-cli ...
22
+ uv tool install ragx-cli --with ragx-cli[rerank] # install and use `ragx-cli`
23
23
  ragx-cli init # create .ragx/ with config.toml next to your corpus
24
24
  ragx-cli index # chunk -> embed -> HNSW + kNN similarity graph
25
25
  ragx-cli query "why did we switch build tools?" --json --files-only
@@ -37,11 +37,13 @@ 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
- - [Features & roadmap](#features--roadmap)
46
+ - [Features \& roadmap](#features--roadmap)
45
47
 
46
48
 
47
49
  ## How it works
@@ -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
  |---|---|
@@ -6,7 +6,7 @@ exists, so its effect on recall/MRR is measured against a stable baseline before
6
6
  ## Decisions
7
7
 
8
8
  1. **Opt-in only.** Temporal signals never influence ranking by default. No δ-term in the default
9
- scoring formula. All behavior is activated by explicit `ragx query` flags.
9
+ scoring formula. All behavior is activated by explicit `ragx-cli query` flags.
10
10
  2. **Capabilities** (all three):
11
11
  - **Hard filters:** `--since <date>` / `--until <date>` constrain candidates by document date.
12
12
  - **Recency bias, directional:** a decay boost that is *configurable in direction* —
@@ -30,7 +30,7 @@ exists, so its effect on recall/MRR is measured against a stable baseline before
30
30
 
31
31
  - Schema: `files.doc_date` (ISO date, nullable) + `files.date_source` → SQLite `user_version = 2`
32
32
  migration. Backfill needs only a metadata pass (re-run date extraction per file) — **no
33
- re-embedding required**; add e.g. `ragx index --refresh-dates`.
33
+ re-embedding required**; add e.g. `ragx-cli index --refresh-dates`.
34
34
  - Chunks inherit their file's date (chunk-level dates from in-text mentions were considered and
35
35
  parked — noisy, and most of the corpus is single-date documents).
36
36
  - Filters apply before traversal (constrain seeds and admitted neighbors); bias applies after
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ragx-cli"
3
- version = "0.1.0"
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"
@@ -53,13 +53,13 @@ Rationale: the entire retrieval ecosystem (FAISS/HNSW, sentence-transformers cro
53
53
  ## 3. CLI surface
54
54
 
55
55
  ```
56
- ragx init [path] # create .ragx/, write default config.toml
57
- ragx index [path] [--changed] # chunk, embed, build/update kNN graph
58
- ragx query "..." [--json] [--top N] [--no-expand] [--no-graph] [--no-rerank]
56
+ ragx-cli init [path] # create .ragx/, write default config.toml
57
+ ragx-cli index [path] [--changed] # chunk, embed, build/update kNN graph
58
+ ragx-cli query "..." [--json] [--top N] [--no-expand] [--no-graph] [--no-rerank]
59
59
  [--files-only] [--hops H] [--explain]
60
- ragx inspect chunk <id> | file <path> | neighbors <id> # debug the graph
61
- ragx status # index freshness, counts, config summary
62
- ragx config get|set <key> [value]
60
+ ragx-cli inspect chunk <id> | file <path> | neighbors <id> # debug the graph
61
+ ragx-cli status # index freshness, counts, config summary
62
+ ragx-cli config get|set <key> [value]
63
63
  ```
64
64
 
65
65
  Conventions for agent use: `--json` emits a single JSON document on stdout (schema versioned, `"schema": "ragx.query.v1"`); logs go to stderr; exit code 0 = success with results, 1 = success but empty, 2 = error. `query` also accepts the query on stdin when the argument is `-`.
@@ -74,7 +74,7 @@ Conventions for agent use: `--json` emits a single JSON document on stdout (sche
74
74
  2. **Chunking.** Structure-aware where cheap: split Markdown on headings, code on function boundaries (tree-sitter optional, regex fallback), plain text by recursive character splitting. Defaults: ~800 tokens, 15% overlap. Every chunk records `file, byte_start, byte_end, line_start, line_end` so agents can jump to exact locations.
75
75
  3. **Embedding.** Batch through the configured provider, applying document prefixes if the model requires them. Store vectors in HNSW; store chunk metadata in SQLite.
76
76
  4. **Graph construction.** For every chunk, query HNSW for top-`k` neighbors (default k=8) above a similarity floor (default cosine ≥ 0.55, configurable). Write undirected weighted edges to SQLite (`edges(src, dst, weight)`), deduplicated. This is the entire "graph build" — one ANN pass over data already in memory.
77
- 5. **Incremental updates.** New/changed chunks: embed, insert, compute their edges, and recompute edges only for chunks that appear in the new chunks' neighbor lists. Deleted files: remove chunks, vectors (HNSW soft-delete + periodic `ragx index --compact`), and incident edges.
77
+ 5. **Incremental updates.** New/changed chunks: embed, insert, compute their edges, and recompute edges only for chunks that appear in the new chunks' neighbor lists. Deleted files: remove chunks, vectors (HNSW soft-delete + periodic `ragx-cli index --compact`), and incident edges.
78
78
 
79
79
  ### 4.2 Query
80
80
 
@@ -96,11 +96,11 @@ Stages are individually skippable via flags so agents can trade quality for late
96
96
 
97
97
  **Phase 2 — Similarity graph (1–2 days).** Edge construction during indexing, `inspect` commands, heat-propagation traversal in `query`, `--explain` output, incremental edge maintenance. Evaluate: recall@10 with graph on vs. off on a small labeled query set (~20 queries is enough to steer).
98
98
 
99
- **Phase 3 — Expansion + rerank (1–2 days).** Multi-query/HyDE expansion stage, RRF fusion, cross-encoder reranker, combined scoring, `--no-*` flags. Add a tiny built-in eval harness: `ragx eval queries.jsonl` reporting recall@k / MRR per pipeline configuration, so parameter tuning (k, decay, floors, α/β/γ) is measurable instead of vibes.
99
+ **Phase 3 — Expansion + rerank (1–2 days).** Multi-query/HyDE expansion stage, RRF fusion, cross-encoder reranker, combined scoring, `--no-*` flags. Add a tiny built-in eval harness: `ragx-cli eval queries.jsonl` reporting recall@k / MRR per pipeline configuration, so parameter tuning (k, decay, floors, α/β/γ) is measurable instead of vibes.
100
100
 
101
101
  **Phase 4 — Scale + communities (optional, 2–3 days).** Leiden community detection over the edge list; per-community extractive labels (top-TF-IDF terms — still no LLM required) or optional LLM summaries; `query --global` mode that routes through communities for corpus-level questions. Performance work: batch/parallel embedding, HNSW `ef` tuning, compaction.
102
102
 
103
- **Phase 5 — MCP (later, deliberately deferred).** Because all query logic lives in a `ragx.core` library layer with the CLI as a thin shell, the MCP server is a second thin shell: `ragx serve --mcp` exposing `query`, `inspect`, `status` as tools over stdio. No refactor needed if the core/CLI split is respected from Phase 0 — that split is the one architectural rule to enforce early.
103
+ **Phase 5 — MCP (later, deliberately deferred).** Because all query logic lives in a `ragx.core` library layer with the CLI as a thin shell, the MCP server is a second thin shell: `ragx-cli serve --mcp` exposing `query`, `inspect`, `status` as tools over stdio. No refactor needed if the core/CLI split is respected from Phase 0 — that split is the one architectural rule to enforce early.
104
104
 
105
105
  ---
106
106
 
@@ -124,15 +124,15 @@ Stages are individually skippable via flags so agents can trade quality for late
124
124
  1. **Hub chunks** (boilerplate, headers) attract many edges and dominate traversal. Mitigate: max-not-sum heat aggregation, per-node degree cap (drop edges beyond top-k both directions), optional stopword-density filter at chunking time.
125
125
  2. **Similarity ≠ relevance.** Graph neighbors of a relevant chunk can be topically adjacent but useless. The query-floor filter during traversal and the cross-encoder rerank are the two guards; the eval harness (Phase 3) verifies they're working.
126
126
  3. **HNSW deletes** degrade the index over time. Soft-delete + `--compact` rebuild; acceptable for corpora that change incrementally.
127
- 4. **Embedding model changes** invalidate everything. Store model name + dimension in the manifest; refuse to mix, offer `ragx index --rebuild`.
127
+ 4. **Embedding model changes** invalidate everything. Store model name + dimension in the manifest; refuse to mix, offer `ragx-cli index --rebuild`.
128
128
  5. **Agent misuse of long output.** Default `--top 8`, chunk text truncated to a configurable max in JSON output with `byte_start/byte_end` so agents fetch full text from the file themselves.
129
129
 
130
130
  ---
131
131
 
132
132
  ## 8. Definition of done (pre-MCP)
133
133
 
134
- - `uvx ragx init && ragx index && ragx query "..." --json --files-only` works on a fresh repo with only Ollama running.
135
- - Incremental `ragx index --changed` after editing one file touches only affected chunks/edges.
134
+ - `uvx ragx-cli init && ragx-cli index && ragx-cli query "..." --json --files-only` works on a fresh repo with only Ollama running.
135
+ - Incremental `ragx-cli index --changed` after editing one file touches only affected chunks/edges.
136
136
  - Eval harness shows graph expansion improving recall@10 over the Phase 1 baseline on the reference query set, with rerank recovering precision.
137
137
  - `--explain` can justify every returned chunk via seed → edge path → scores.
138
138
 
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -1,4 +1,4 @@
1
- """ragx CLI shell: init, status, config. index/query are wired in a later pass."""
1
+ """ragx-cli CLI shell: init, status, config. index/query are wired in a later pass."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -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:
@@ -1,4 +1,4 @@
1
- """`ragx eval` command. Registered onto the main app by app.py (integration-owned)."""
1
+ """`ragx-cli eval` command. Registered onto the main app by app.py (integration-owned)."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -1,4 +1,4 @@
1
- """ragx inspect: read-only introspection into the store (chunks, files, neighbors).
1
+ """ragx-cli inspect: read-only introspection into the store (chunks, files, neighbors).
2
2
 
3
3
  No embeddings or vector index needed — everything here reads Store directly.
4
4
  """
@@ -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)
@@ -109,7 +109,7 @@ def _check_manifest(store: Store, embedder: Embedder, *, allow_rewrite: bool) ->
109
109
  if prev and prev != embedder.model and not allow_rewrite:
110
110
  raise ManifestMismatchError(
111
111
  f"index was built with {prev!r} but config says {embedder.model!r}; "
112
- "run a full `ragx index` to rebuild"
112
+ "run a full `ragx-cli index` to rebuild"
113
113
  )
114
114
 
115
115
 
@@ -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 index` to rebuild"
45
+ "run `ragx-cli index` to rebuild"
46
46
  )
47
47
 
48
48
 
@@ -33,7 +33,7 @@ def _resolve_api_key(cfg: Config, section: str) -> str | None:
33
33
 
34
34
  def _resolve_base_url(cfg: Config, section: str) -> str:
35
35
  """Honor the conventional OPENAI_BASE_URL env var, but only while `<section>.base_url`
36
- is still the built-in default — an explicit `ragx config set` always wins."""
36
+ is still the built-in default — an explicit `ragx-cli config set` always wins."""
37
37
  base_url = cfg.get(f"{section}.base_url")
38
38
  env_url = os.environ.get("OPENAI_BASE_URL")
39
39
  if env_url and base_url == _DEFAULT_OPENAI_BASE_URL:
@@ -17,7 +17,7 @@ class STReranker:
17
17
  from sentence_transformers import CrossEncoder
18
18
  except ImportError as exc:
19
19
  raise RagxError(
20
- "sentence-transformers not installed — install with: uv pip install 'ragx[rerank]'"
20
+ "sentence-transformers not installed — install with: uv tool install ragx-cli --with ragx-cli[rerank]"
21
21
  ) from exc
22
22
  self.model = model
23
23
  self._encoder = CrossEncoder(model)
@@ -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.0"
@@ -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 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 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