ragx-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,117 @@
1
+ """OpenAI-compatible HTTP providers: works with LM Studio, Ollama's /v1, and OpenAI itself."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Sequence
7
+
8
+ import httpx
9
+
10
+ from ragx.core.errors import RagxError
11
+
12
+ _MAX_RETRIES = 3
13
+
14
+
15
+ def _post_with_retry(client: httpx.Client, url: str, payload: dict) -> dict:
16
+ last_exc: Exception | None = None
17
+ for attempt in range(_MAX_RETRIES):
18
+ try:
19
+ resp = client.post(url, json=payload)
20
+ if resp.status_code >= 500:
21
+ last_exc = RagxError(f"{url} returned {resp.status_code}: {resp.text}")
22
+ else:
23
+ resp.raise_for_status()
24
+ return resp.json()
25
+ except httpx.ConnectError as exc:
26
+ last_exc = exc
27
+ if attempt < _MAX_RETRIES - 1:
28
+ time.sleep(2**attempt * 0.01)
29
+ raise RagxError(f"request to {url} failed after {_MAX_RETRIES} attempts: {last_exc}")
30
+
31
+
32
+ class OpenAICompatEmbedder:
33
+ """Embedder implementing providers.base.Embedder against an OpenAI-compatible /embeddings API."""
34
+
35
+ model: str
36
+
37
+ def __init__(
38
+ self,
39
+ base_url: str,
40
+ model: str,
41
+ doc_prefix: str = "",
42
+ query_prefix: str = "",
43
+ api_key: str | None = None,
44
+ batch_size: int = 32,
45
+ timeout: float = 120.0,
46
+ ) -> None:
47
+ self.base_url = base_url.rstrip("/")
48
+ self.model = model
49
+ self.doc_prefix = doc_prefix
50
+ self.query_prefix = query_prefix
51
+ self.batch_size = batch_size
52
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
53
+ self._client = httpx.Client(timeout=timeout, headers=headers)
54
+ self._dim: int | None = None
55
+
56
+ def dimension(self) -> int:
57
+ if self._dim is None:
58
+ vecs = self._embed_raw(["probe"])
59
+ self._dim = len(vecs[0])
60
+ return self._dim
61
+
62
+ def embed_documents(self, texts: Sequence[str]) -> list[list[float]]:
63
+ return self._embed_prefixed(texts, self.doc_prefix)
64
+
65
+ def embed_queries(self, texts: Sequence[str]) -> list[list[float]]:
66
+ return self._embed_prefixed(texts, self.query_prefix)
67
+
68
+ def _embed_prefixed(self, texts: Sequence[str], prefix: str) -> list[list[float]]:
69
+ if not texts:
70
+ return []
71
+ return self._embed_raw([f"{prefix}{t}" for t in texts])
72
+
73
+ def _embed_raw(self, texts: Sequence[str]) -> list[list[float]]:
74
+ out: list[list[float]] = []
75
+ for i in range(0, len(texts), self.batch_size):
76
+ batch = texts[i : i + self.batch_size]
77
+ data = _post_with_retry(
78
+ self._client,
79
+ f"{self.base_url}/embeddings",
80
+ {"model": self.model, "input": list(batch)},
81
+ )
82
+ out.extend(item["embedding"] for item in data["data"])
83
+ return out
84
+
85
+
86
+ class OpenAICompatGenerator:
87
+ """Generator implementing providers.base.Generator against an OpenAI-compatible chat API."""
88
+
89
+ model: str
90
+
91
+ def __init__(
92
+ self,
93
+ base_url: str,
94
+ model: str,
95
+ api_key: str | None = None,
96
+ timeout: float = 300.0,
97
+ ) -> None:
98
+ self.base_url = base_url.rstrip("/")
99
+ self.model = model
100
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
101
+ self._client = httpx.Client(timeout=timeout, headers=headers)
102
+
103
+ def generate(self, system: str, prompt: str, *, max_tokens: int = 1024) -> str:
104
+ data = _post_with_retry(
105
+ self._client,
106
+ f"{self.base_url}/chat/completions",
107
+ {
108
+ "model": self.model,
109
+ "messages": [
110
+ {"role": "system", "content": system},
111
+ {"role": "user", "content": prompt},
112
+ ],
113
+ "temperature": 0.3,
114
+ "max_tokens": max_tokens,
115
+ },
116
+ )
117
+ return data["choices"][0]["message"]["content"]
@@ -0,0 +1,81 @@
1
+ """Factories that build provider instances from Config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ from ragx.core.config import Config
9
+ from ragx.core.errors import RagxError
10
+ from ragx.providers.base import Embedder, Generator, Reranker
11
+ from ragx.providers.openai_compat import OpenAICompatEmbedder, OpenAICompatGenerator
12
+ from ragx.providers.st_reranker import STReranker
13
+
14
+ _OLLAMA_BASE_URL = "http://localhost:11434/v1"
15
+ # DEFAULTS["embeddings"]["base_url"] — the LM Studio default; if a user switches to the
16
+ # "ollama" provider without also overriding base_url, we swap in the ollama default instead.
17
+ _DEFAULT_OPENAI_BASE_URL = "http://localhost:1234/v1"
18
+
19
+
20
+ def _resolve_api_key(cfg: Config, section: str) -> str | None:
21
+ """`<section>.api_key_env` names an env var (fails loud if unset); otherwise fall back to
22
+ the conventional OPENAI_API_KEY when present."""
23
+ env_name = cfg.get(f"{section}.api_key_env")
24
+ if env_name:
25
+ key = os.environ.get(env_name)
26
+ if not key:
27
+ raise RagxError(
28
+ f"[{section}] api_key_env = {env_name!r} but that environment variable is not set"
29
+ )
30
+ return key
31
+ return os.environ.get("OPENAI_API_KEY") or None
32
+
33
+
34
+ def _resolve_base_url(cfg: Config, section: str) -> str:
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."""
37
+ base_url = cfg.get(f"{section}.base_url")
38
+ env_url = os.environ.get("OPENAI_BASE_URL")
39
+ if env_url and base_url == _DEFAULT_OPENAI_BASE_URL:
40
+ return env_url.rstrip("/")
41
+ return base_url
42
+
43
+
44
+ def make_embedder(cfg: Config) -> Embedder:
45
+ provider = cfg.get("embeddings.provider")
46
+ base_url = cfg.get("embeddings.base_url")
47
+ if provider == "openai":
48
+ base_url = _resolve_base_url(cfg, "embeddings")
49
+ elif provider == "ollama":
50
+ if base_url == _DEFAULT_OPENAI_BASE_URL:
51
+ base_url = _OLLAMA_BASE_URL
52
+ else:
53
+ raise RagxError(f"unknown embeddings provider: {provider!r}")
54
+ return OpenAICompatEmbedder(
55
+ base_url=base_url,
56
+ model=cfg.get("embeddings.model"),
57
+ doc_prefix=cfg.get("embeddings.doc_prefix"),
58
+ query_prefix=cfg.get("embeddings.query_prefix"),
59
+ batch_size=cfg.get("embeddings.batch_size"),
60
+ api_key=_resolve_api_key(cfg, "embeddings"),
61
+ )
62
+
63
+
64
+ def make_generator(cfg: Config) -> Generator | None:
65
+ if not cfg.get("expansion.enabled"):
66
+ return None
67
+ return OpenAICompatGenerator(
68
+ base_url=_resolve_base_url(cfg, "expansion"),
69
+ model=cfg.get("expansion.model"),
70
+ api_key=_resolve_api_key(cfg, "expansion"),
71
+ )
72
+
73
+
74
+ def make_reranker(cfg: Config) -> Reranker | None:
75
+ if not cfg.get("rerank.enabled"):
76
+ return None
77
+ try:
78
+ return STReranker(model=cfg.get("rerank.model"))
79
+ except RagxError as exc:
80
+ print(f"warning: reranker unavailable: {exc}", file=sys.stderr)
81
+ return None
@@ -0,0 +1,28 @@
1
+ """Cross-encoder reranker backed by sentence-transformers (optional extra)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Sequence
6
+
7
+ from ragx.core.errors import RagxError
8
+
9
+
10
+ class STReranker:
11
+ """Reranker implementing providers.base.Reranker via a sentence-transformers CrossEncoder."""
12
+
13
+ model: str
14
+
15
+ def __init__(self, model: str) -> None:
16
+ try:
17
+ from sentence_transformers import CrossEncoder
18
+ except ImportError as exc:
19
+ raise RagxError(
20
+ "sentence-transformers not installed — install with: uv pip install 'ragx[rerank]'"
21
+ ) from exc
22
+ self.model = model
23
+ self._encoder = CrossEncoder(model)
24
+
25
+ def score(self, query: str, texts: Sequence[str]) -> list[float]:
26
+ pairs = [[query, text] for text in texts]
27
+ scores = self._encoder.predict(pairs)
28
+ return [float(s) for s in scores]
@@ -0,0 +1,297 @@
1
+ Metadata-Version: 2.4
2
+ Name: ragx-cli
3
+ Version: 0.1.0
4
+ Summary: Similarity-graph RAG CLI: index a corpus into a chunk-level embedding graph and query it
5
+ Project-URL: Repository, https://github.com/alber70g/ragx-cli
6
+ Project-URL: Issues, https://github.com/alber70g/ragx-cli/issues
7
+ Author-email: Albert Groothedde <albertgroothedde@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: cli,embeddings,rag,retrieval,search,similarity-graph
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Text Processing :: Indexing
16
+ Requires-Python: >=3.12
17
+ Requires-Dist: hnswlib>=0.8
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: pathspec>=0.12
20
+ Requires-Dist: tomli-w>=1.0
21
+ Requires-Dist: typer>=0.12
22
+ Requires-Dist: xxhash>=3.4
23
+ Provides-Extra: rerank
24
+ Requires-Dist: sentence-transformers>=3.0; extra == 'rerank'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # ragx-cli — similarity-graph RAG for your files
28
+
29
+ `ragx-cli` indexes a corpus of files into a **chunk-level embedding similarity graph** and answers
30
+ queries by combining vector search, **graph traversal**, and **cross-encoder reranking** — all
31
+ from a single local CLI. Indexing needs **no LLM** (only an embedding model); an LLM is used
32
+ optionally at query time, for query expansion.
33
+
34
+ It works on any directory of text: pair it with a
35
+ [Karpathy-style LLM Wiki](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f),
36
+ an OpenWiki instance, an Obsidian-style notes vault, or any other knowledgebase repository or
37
+ arbitrary docs/code tree — `ragx-cli init` drops a `.ragx/` directory next to the files and
38
+ everything else stays untouched. LLM-maintained wikis and ragx-cli are complementary: the wiki
39
+ distills knowledge into curated pages, while ragx-cli gives agents fast graph-backed retrieval
40
+ over those pages (and the raw sources beside them) without re-reading everything per question.
41
+
42
+ **The goal:** local semantic search that finds documents *plain vector search misses*, stays
43
+ cheap to (re)index, and is built to be driven by coding agents as much as by humans — stable
44
+ JSON schemas, deterministic exit codes, byte-exact source locations, and an `--explain` mode
45
+ that can justify every result via the exact graph path that produced it.
46
+
47
+ ```bash
48
+ uv tool install ragx-cli # or run one-off without installing: uvx ragx-cli ...
49
+ ragx-cli init # create .ragx/ with config.toml next to your corpus
50
+ ragx-cli index # chunk -> embed -> HNSW + kNN similarity graph
51
+ ragx-cli query "why did we switch build tools?" --json --files-only
52
+ ragx-cli index --changed # incremental: only new/modified/deleted files
53
+ ```
54
+
55
+ Runs against any OpenAI-compatible embedding endpoint (LM Studio, Ollama, OpenAI). Reranking
56
+ uses a local sentence-transformers cross-encoder (`ragx-cli[rerank]` extra). Everything lives in a
57
+ `.ragx/` directory beside your files — like `.git/`, delete it and the corpus is untouched.
58
+
59
+ ---
60
+
61
+ - [ragx-cli — similarity-graph RAG for your files](#ragx-cli--similarity-graph-rag-for-your-files)
62
+ - [How it works](#how-it-works)
63
+ - [Indexing (LLM-free)](#indexing-llm-free)
64
+ - [Querying](#querying)
65
+ - [Does it actually help? (benchmarks)](#does-it-actually-help-benchmarks)
66
+ - [Agent-first conventions](#agent-first-conventions)
67
+ - [Using ragx-cli from a coding agent (CLAUDE.md / AGENTS.md)](#using-ragx-cli-from-a-coding-agent-claudemd--agentsmd)
68
+ - [Pointing ragx-cli at your LLM — local or online](#pointing-ragx-cli-at-your-llm--local-or-online)
69
+ - [Configuration](#configuration)
70
+ - [Features & roadmap](#features--roadmap)
71
+
72
+
73
+ ## How it works
74
+
75
+ ### Indexing (LLM-free)
76
+
77
+ Files are chunked structure-aware (markdown headings / code boundaries / recursive fallback),
78
+ embedded, and stored in an HNSW index. The similarity graph then falls out almost for free:
79
+ one kNN pass over the vectors that are already in memory — each chunk gets edges to its top-k
80
+ nearest neighbors above a similarity floor.
81
+
82
+ ```mermaid
83
+ flowchart TD
84
+ A[files] --> B["discover + hash<br/>(gitignore, binary/junk filters,<br/>xxhash for incremental)"]
85
+ B --> C["chunk<br/>(headings / code boundaries,<br/>byte-exact slices + line ranges)"]
86
+ C --> D["embed<br/>(any OpenAI-compatible endpoint)"]
87
+ D --> E[("HNSW index<br/>vectors.hnsw")]
88
+ D --> F[("SQLite<br/>files · chunks · edges · manifest")]
89
+ E -- "kNN per chunk<br/>(k=8, cos ≥ 0.55)" --> G["similarity graph<br/>undirected weighted edges"]
90
+ G --> F
91
+ ```
92
+
93
+ Incremental runs (`--changed`) re-embed only changed files and repair only the edge lists that
94
+ those chunks touch. Content hashes make `touch`-ed but unchanged files free.
95
+
96
+ ### Querying
97
+
98
+ Every stage is individually skippable (`--no-expand`, `--no-graph`, `--no-rerank`) so callers
99
+ can trade quality for latency.
100
+
101
+ ```mermaid
102
+ flowchart TD
103
+ Q[query] --> X["1 · expansion (optional, one LLM call)<br/>2–4 reformulations + HyDE passage"]
104
+ X --> S["2 · fan-out vector search<br/>top-20 per variant"]
105
+ Q -. "--no-expand" .-> S
106
+ S --> R["3 · Reciprocal Rank Fusion<br/>merged seed set"]
107
+ R --> H["4 · heat propagation over the graph<br/>2 hops · decay 0.5 · max-aggregation<br/>query-similarity floor · frontier cap"]
108
+ R -. "--no-graph" .-> K
109
+ H --> K["5 · cross-encoder rerank<br/>(query, chunk) pairs, capped shortlist"]
110
+ K --> F["6 · combined score<br/>α·rerank + β·heat + γ·vector"]
111
+ F --> O["ranked chunks (or files via --files-only)<br/>+ --explain traversal trace"]
112
+ ```
113
+
114
+ **Heat propagation** is what sets ragx-cli apart from plain RAG: seed chunks (from vector search)
115
+ push "heat" along similarity edges — `heat × edge_weight × decay` per hop. A neighbor's heat is
116
+ the **max** of incoming contributions (not the sum, so hub chunks can't inflate themselves), and
117
+ a neighbor is only admitted if it's similar enough to the *original query* — traversal stays
118
+ anchored to the question instead of drifting through the corpus.
119
+
120
+ ```mermaid
121
+ flowchart LR
122
+ subgraph seeds["seeds (vector hits)"]
123
+ S1["chunk A · heat 1.0"]
124
+ end
125
+ S1 -- "edge 0.86" --> N1["chunk B<br/>heat = 1.0 × 0.86 × 0.5 = 0.43"]
126
+ N1 -- "edge 0.70" --> N2["chunk C<br/>heat = 0.43 × 0.70 × 0.5 = 0.15"]
127
+ S1 -- "edge 0.60" --> X1["chunk D — below query floor<br/>✗ not admitted, doesn't relay"]
128
+ ```
129
+
130
+ Because every admitted chunk records which seed and edge produced it, `--explain` can print the
131
+ full justification: *seed → edge(weight) → chunk*, per result.
132
+
133
+ ---
134
+
135
+ ## Does it actually help? (benchmarks)
136
+
137
+ Measured with the built-in harness (`ragx-cli eval queries.jsonl`) on a real, decade-spanning
138
+ personal wiki — organic notes, not a synthetic benchmark. Corpus provenance:
139
+
140
+ | | |
141
+ |---|---|
142
+ | **corpus** | 636 markdown files indexed (644 on disk; 8 auto-excluded as `node_modules`/hidden) · 2.9 MB · avg 4.6 KB/file |
143
+ | **structure** | topical top-level dirs (`clients/`, `projects/`, `workstreams/`, `personal/`, …), nested up to 10 levels deep |
144
+ | **content** | mixed **English + Dutch**: meeting/daily notes, research docs, transcripts, reference material |
145
+ | **chunks** | 1,323 (avg 2.1 per file, 452 files are single-chunk; median 2,390 chars ≈ 600 tokens, max 4,888) |
146
+ | **graph** | 5,246 edges · avg degree 7.9 (k=8 cap) · weights 0.59–1.00 above the 0.55 floor · only 3 isolated chunks |
147
+ | **index** | 5.1 MB SQLite + 4.1 MB HNSW (768-dim `nomic-embed-text-v1.5` via LM Studio) · full build ≈ 2 min on an M-series laptop |
148
+ | **labels** | 18 queries (EN + NL) with known-relevant files, single- and multi-target (`.ragx/queries.jsonl`) |
149
+
150
+ Reranker: `BAAI/bge-reranker-v2-m3` (local cross-encoder). Results:
151
+
152
+ | config | recall@5 | recall@10 | MRR |
153
+ | -------------------------------- | -------: | --------: | --------: |
154
+ | `baseline` — vector search only | 0.833 | 0.833 | 0.593 |
155
+ | `graph` — + heat propagation | 0.778 | 0.833 | 0.522 |
156
+ | `rerank` — graph + cross-encoder | 0.759 | **0.889** | 0.568 |
157
+ | `full` — + LLM expansion | 0.759 | **0.889** | **0.613** |
158
+
159
+ The recall win is exactly the designed mechanism, and it's traceable. For one Dutch query
160
+ ("zonnepanelen offerte en terugverdientijd"), the relevant document is **never retrieved** by
161
+ vector search — and a reranker alone can't help, because you can't rerank what retrieval never
162
+ surfaced:
163
+
164
+ | pipeline | rank of the relevant file |
165
+ | ------------------------ | ------------------------: |
166
+ | vector search only | *not found* |
167
+ | rerank **without** graph | *not found* |
168
+ | graph only | 19 |
169
+ | graph **+** rerank | **4** |
170
+
171
+ The graph surfaced it through a single hop-1 edge (weight 0.86) from a seed chunk, and the
172
+ cross-encoder promoted it — *graph expands recall, rerank recovers precision*. The `--explain`
173
+ output for that result shows the exact seed → edge → chunk path.
174
+
175
+ **Honest caveat:** graph traversal *alone* hurts precision on this corpus (MRR 0.593 → 0.522) —
176
+ near-duplicate neighbors (e.g. adjacent meeting notes) displace weaker direct hits. A parameter
177
+ sweep over decay/floor/weights plateaued below baseline MRR, so this is a property of
178
+ similarity-only edges, not a tuning miss. Conclusion baked into the defaults: **graph and
179
+ rerank ship together**. Use `--no-graph --no-rerank` as the explicit fast mode.
180
+
181
+ ---
182
+
183
+ ## Agent-first conventions
184
+
185
+ - `--json` emits exactly one JSON document on stdout (versioned schemas: `ragx.query.v1`,
186
+ `ragx.files.v1`, `ragx.status.v1`, `ragx.eval.v1`, `ragx.inspect.*.v1`); logs go to stderr.
187
+ - Exit codes: `0` results, `1` success-but-empty, `2` error.
188
+ - Every chunk carries `file`, `line_start/line_end`, `byte_start/byte_end` — agents jump to the
189
+ exact source location and read the full text themselves (JSON chunk text is truncated).
190
+ - `--files-only` aggregates chunk scores per file (sum of top-3) — the mode coding agents use most.
191
+ - `ragx-cli query -` reads the query from stdin; `ragx-cli inspect chunk|file|neighbors` debugs the graph.
192
+
193
+ ## Using ragx-cli from a coding agent (CLAUDE.md / AGENTS.md)
194
+
195
+ Give your agent standing instructions by pasting this into the repo's `CLAUDE.md` or `AGENTS.md`
196
+ (adjust the fenced block to your corpus):
197
+
198
+ ```markdown
199
+ ## Semantic search with ragx-cli
200
+
201
+ This repo has a ragx-cli index (`.ragx/`). Prefer it over grep for "where is X discussed/decided?"
202
+ questions; fall back to grep for exact identifiers.
203
+
204
+ - Find relevant files: `ragx-cli query "<natural-language question>" --json --files-only`
205
+ - Get chunks with exact locations: `ragx-cli query "..." --json --top 8` — each result carries
206
+ `file` + `line_start/line_end`; the JSON `text` is truncated, so read the file yourself
207
+ for full context.
208
+ - Fast mode (no LLM call, no cross-encoder): add `--no-expand --no-rerank`.
209
+ - After adding or editing files: `ragx-cli index --changed` (cheap, hash-based).
210
+ - stdout is exactly one JSON document; logs are on stderr.
211
+ Exit codes: 0 = results, 1 = no results (not an error), 2 = error.
212
+ - Why did this result appear? `ragx-cli query "..." --explain`.
213
+ Explore the graph: `ragx-cli inspect neighbors <chunk_id>`.
214
+ ```
215
+
216
+ ### Pointing ragx-cli at your LLM — local or online
217
+
218
+ ragx-cli talks to any **OpenAI-compatible** API for embeddings and (optionally) query expansion.
219
+ Pick one recipe; run it inside the corpus after `ragx-cli init`:
220
+
221
+ **LM Studio** (default — nothing to change if it runs on `localhost:1234`):
222
+
223
+ ```bash
224
+ curl -s http://localhost:1234/v1/models # see what's loaded
225
+ ragx-cli config set embeddings.model text-embedding-nomic-embed-text-v1.5
226
+ ragx-cli config set expansion.model <any-chat-model-id> # or: ragx-cli config set expansion.enabled false
227
+ ```
228
+
229
+ **Ollama** (base_url switches to `localhost:11434/v1` automatically):
230
+
231
+ ```bash
232
+ ollama pull nomic-embed-text
233
+ ragx-cli config set embeddings.provider ollama
234
+ ragx-cli config set embeddings.model nomic-embed-text
235
+ ragx-cli config set expansion.provider ollama
236
+ ragx-cli config set expansion.model llama3.1 # any local chat model
237
+ ```
238
+
239
+ **Online / any OpenAI-compatible endpoint** (OpenAI, OpenRouter, Together, …).
240
+ ragx-cli honors the conventional env vars used by generic OpenAI-compatible tooling — with
241
+ `OPENAI_BASE_URL` and `OPENAI_API_KEY` exported, only the model names need configuring:
242
+
243
+ ```bash
244
+ export OPENAI_BASE_URL=https://api.openai.com/v1
245
+ export OPENAI_API_KEY=sk-...
246
+ ragx-cli config set embeddings.model text-embedding-3-small
247
+ ragx-cli config set embeddings.doc_prefix "" # prefixes are for nomic-style models
248
+ ragx-cli config set embeddings.query_prefix ""
249
+ ragx-cli config set expansion.model gpt-5.2-mini
250
+ ```
251
+
252
+ Precedence rules (per section, embeddings and expansion independently):
253
+
254
+ - `base_url`: an explicit `ragx-cli config set <section>.base_url …` always wins;
255
+ `OPENAI_BASE_URL` applies only while the config still holds the built-in default.
256
+ - API key: `ragx-cli config set <section>.api_key_env MY_VAR` names an env var to read (and fails
257
+ loudly if that variable is unset); without it, `OPENAI_API_KEY` is used when present.
258
+ Secrets themselves never go in `config.toml`.
259
+
260
+ Mixed setups are normal — e.g. local Ollama embeddings + online expansion via
261
+ `ragx-cli config set expansion.base_url https://openrouter.ai/api/v1` +
262
+ `ragx-cli config set expansion.api_key_env OPENROUTER_API_KEY`. The reranker is always local
263
+ (sentence-transformers); disable it with `ragx-cli config set rerank.enabled false` if the model
264
+ download is unwanted. **Note:** changing the embedding model invalidates the index — ragx-cli
265
+ detects the mismatch and asks you to run a full `ragx-cli index`.
266
+
267
+ ## Configuration
268
+
269
+ `.ragx/config.toml`, managed via `ragx-cli config get|set`. Key defaults:
270
+
271
+ | section | defaults |
272
+ |---|---|
273
+ | `[chunking]` | `size_tokens=800`, `overlap=0.15` |
274
+ | `[graph]` | `k=8`, `min_edge_sim=0.55` |
275
+ | `[traversal]` | `hops=2`, `decay=0.5`, `query_floor=0.35`, `max_frontier=150` |
276
+ | `[fusion]` | `rrf_k=60`, `per_query_top=20` |
277
+ | `[scoring]` | `alpha_rerank=0.6`, `beta_heat=0.25`, `gamma_vector=0.15` |
278
+ | `[embeddings]` | `provider="openai"`, `base_url="http://localhost:1234/v1"`, prefixes for nomic-style models, `api_key_env=""` |
279
+ | `[expansion]` | optional LLM for multi-query/HyDE; reasoning models supported (4096-token budget); `api_key_env=""` |
280
+ | `[rerank]` | `BAAI/bge-reranker-v2-m3` via sentence-transformers (`uv tool install 'ragx-cli[rerank]'`) |
281
+
282
+ ## Features & roadmap
283
+
284
+ Checked features are built and validated per [the implementation plan](ragx-cli-plan.md);
285
+ unchecked ones are next up:
286
+
287
+ - [x] **CLI & storage**: typer CLI, SQLite schema, provider abstraction (Embedder/Generator/Reranker)
288
+ - [x] **Baseline vector RAG**: discovery, chunking, embeddings, HNSW search, incremental `--changed`
289
+ - [x] **Similarity graph**: kNN edge construction, heat-propagation traversal, `inspect`, `--explain`
290
+ - [x] **Quality & measurement**: multi-query/HyDE expansion, RRF fusion, cross-encoder rerank, `eval` harness
291
+ - [ ] **Communities**: Leiden detection over the edge list, `query --global` for corpus-level questions
292
+ - [ ] **MCP server**: a second thin shell over `ragx.core` (the core/CLI split it needs is already enforced)
293
+ - [ ] **[Temporal weighting](docs/feature-temporal-weighting.md)**: opt-in `--since`/`--until`/`--temporal recent|oldest`, date cascade filename/frontmatter → git → mtime
294
+ - [ ] **Release**: publish to PyPI as `ragx-cli` (plain `ragx` is name-blocked, too similar to an existing project) so `uvx ragx-cli` works out of the box
295
+
296
+ Development: `uv sync --group dev && uv run pytest`. 126 tests; module contracts live in
297
+ `CONTRACTS.md` / `CONTRACTS-PHASE23.md`.
@@ -0,0 +1,33 @@
1
+ ragx/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ ragx/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ ragx/cli/app.py,sha256=JF34OKekTU9Z8hHqPdQcltYKJUFzh5nhHq0oYllRrd8,2984
4
+ ragx/cli/eval_cmd.py,sha256=enrl4zfwFXjzI7n5oJ5B07SY_Wz3giDRzlyvhL9oB7Y,2717
5
+ ragx/cli/inspect_cmd.py,sha256=TLdclQ48lAymC9rzbGOC-wlcKKx1Rw-lW-nY6P3PKLA,4167
6
+ ragx/cli/output.py,sha256=PA_nX9eE0aiBx8pLVxa2fY7eOA-kiHwrNN5n-9f1CnU,567
7
+ ragx/cli/pipeline.py,sha256=ma8Cw2bTcrYJzR9AS2vptS0dARqSBttqake25-Jlo3U,3422
8
+ ragx/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ ragx/core/chunking.py,sha256=EBGbGXMgc1Acja6Xzz5dYejPhKXQez64TS7NSV_tDa4,6194
10
+ ragx/core/config.py,sha256=-5_1n_VvDJbSU37F9ldsCiCMu6LX4yu94zD4RDJ3-XU,4460
11
+ ragx/core/discovery.py,sha256=UE1CtePKwynbK4VTfcEg4Hch7PrwhDLKcRgxpbzKM94,2571
12
+ ragx/core/errors.py,sha256=9pZXyLsoy8DSGleCofHownQAn5OhzYJUANrMKNlaq8I,328
13
+ ragx/core/eval.py,sha256=G-CLqYuLTmhjd52ojL4xhX1IeD-2alHWCWrdSwnc_BM,3221
14
+ ragx/core/expansion.py,sha256=mePowcU_ax_AROsxVlv9WXr81LdUkY0A7iyQQG_ajMc,3031
15
+ ragx/core/fusion.py,sha256=5ONs68KybSCxT3AolIR_aI5feXtjHfPkLfhEKfIn788,499
16
+ ragx/core/graph.py,sha256=s5LebTT77orOAMYgkrhZApoESsRHr6OUpW9jYHoNMR0,1285
17
+ ragx/core/indexer.py,sha256=_UK7cYerJgIPRTS17tXuelM5fDwAk_VOv5WHw8TvAIo,4578
18
+ ragx/core/models.py,sha256=jpEfU_LIbleMB7Xe-a1w5BAYQax73bwsNAFcVSb6rbM,1657
19
+ ragx/core/query.py,sha256=L4_FcJLKgTL9Y98IE-zRJ3fWIAuhSYnUxmM9MZpY6ZM,7102
20
+ ragx/core/scoring.py,sha256=SyqWiipAQbbG3p01RrDeomgRKnUONvGo9__5mIH7zHs,1520
21
+ ragx/core/store.py,sha256=a28fIrnk8fTIelUP7Cc9m1CIB4nkHkZbgoP-Cn48gH8,6536
22
+ ragx/core/traversal.py,sha256=x4Buk9bGDOt76AalJ3CUVeLaDFtYerFLaMzlfCm_bf0,2224
23
+ ragx/core/vectors.py,sha256=3frTPe_kajAa22gfeycrN9YdT_Z1ED_TvZQDfM2Z0AA,3779
24
+ ragx/providers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
+ ragx/providers/base.py,sha256=TbXHKULuBMtrz7ba1IE3gMvPRl2CI_EKN6o9-_I-4sY,1383
26
+ ragx/providers/openai_compat.py,sha256=hM3pQJl6a8lVxVAbIiA3TGHtD1uFNaDwM2L4C2Fga_s,3908
27
+ ragx/providers/registry.py,sha256=huLf1Q55jHSpi4wWtQFxgpjbMQynDs5BBhDzeQ-zkBo,3015
28
+ ragx/providers/st_reranker.py,sha256=aWC_J13W_0p3uP_Gv1acWZ49jHfF0pPOOX2fkJKLUq4,904
29
+ ragx_cli-0.1.0.dist-info/METADATA,sha256=I7PRU_Czy9vPLGs2O43vZT5v3I4m2VfjFeffEV0Q3pA,15840
30
+ ragx_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
31
+ ragx_cli-0.1.0.dist-info/entry_points.txt,sha256=DnzUStJpxrAZKXJV6vkXUomgWIU45hBzmLRfdsFa1yo,47
32
+ ragx_cli-0.1.0.dist-info/licenses/LICENSE,sha256=rDLbA2XooXRy7qm4a2OaILIq3fSKh__wiHwSqiJJWCg,1074
33
+ ragx_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ragx-cli = ragx.cli.app:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Albert Groothedde
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.