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.
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/.gitignore +2 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/CLAUDE.md +12 -6
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/CONTRACTS-PHASE23.md +5 -5
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/CONTRACTS.md +5 -5
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/PKG-INFO +92 -4
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/README.md +91 -3
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/docs/feature-temporal-weighting.md +2 -2
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/pyproject.toml +1 -1
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/ragx-cli-plan.md +12 -12
- ragx_cli-0.2.0/src/ragx/__init__.py +1 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/cli/app.py +24 -4
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/cli/eval_cmd.py +1 -1
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/cli/inspect_cmd.py +1 -1
- ragx_cli-0.2.0/src/ragx/core/config.py +200 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/indexer.py +1 -1
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/query.py +1 -1
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/providers/registry.py +1 -1
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/providers/st_reranker.py +1 -1
- ragx_cli-0.2.0/tests/conftest.py +13 -0
- ragx_cli-0.2.0/tests/test_config_rc.py +118 -0
- ragx_cli-0.1.0/src/ragx/__init__.py +0 -1
- ragx_cli-0.1.0/src/ragx/core/config.py +0 -130
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/.github/workflows/publish.yml +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/AGENTS.md +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/LICENSE +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/cli/__init__.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/cli/output.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/cli/pipeline.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/__init__.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/chunking.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/discovery.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/errors.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/eval.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/expansion.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/fusion.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/graph.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/models.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/scoring.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/store.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/traversal.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/core/vectors.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/providers/__init__.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/providers/base.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/src/ragx/providers/openai_compat.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/__init__.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_chunking.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_cli.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_discovery.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_eval.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_expansion.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_fusion.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_graph.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_inspect.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_integration.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_providers.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_store.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_traversal.py +0 -0
- {ragx_cli-0.1.0 → ragx_cli-0.2.0}/tests/test_vectors.py +0 -0
|
@@ -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
|
|
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
|
-
-
|
|
86
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|