Metadata-Version: 2.5
Name: ragx-cli
Version: 0.10.0
Summary: Similarity-graph RAG CLI: index a corpus into a chunk-level embedding graph and query it
Project-URL: Repository, https://github.com/alber70g/ragx-cli
Project-URL: Issues, https://github.com/alber70g/ragx-cli/issues
Author-email: Albert Groothedde <albertgroothedde@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: cli,embeddings,rag,retrieval,search,similarity-graph
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Text Processing :: Indexing
Requires-Python: >=3.12
Requires-Dist: graspologic-native>=1.3.1
Requires-Dist: hnswlib>=0.8
Requires-Dist: httpx>=0.27
Requires-Dist: numpy>=1.26
Requires-Dist: pathspec>=0.12
Requires-Dist: tomli-w>=1.0
Requires-Dist: typer>=0.12
Requires-Dist: xxhash>=3.4
Provides-Extra: rerank
Requires-Dist: sentence-transformers>=3.0; extra == 'rerank'
Description-Content-Type: text/markdown

# ragx-cli — similarity-graph RAG for your files

`ragx-cli` indexes a corpus of files into a **chunk-level embedding similarity graph** and answers
queries by combining vector search, **graph traversal**, and **cross-encoder reranking** — all
from a single local CLI. Indexing needs **no LLM** (only an embedding model); an LLM is used
optionally at query time, for query expansion.

It works on any directory of text: pair it with a
[Karpathy-style LLM Wiki](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f),
an OpenWiki instance, an Obsidian-style notes vault, or any other knowledgebase repository or
arbitrary docs/code tree — `ragx-cli init` drops a `ragx.toml` next to the files and
everything else stays untouched. LLM-maintained wikis and ragx-cli are complementary: the wiki
distills knowledge into curated pages, while ragx-cli gives agents fast graph-backed retrieval
over those pages (and the raw sources beside them) without re-reading everything per question.

**The goal:** local semantic search that finds documents *plain vector search misses*, stays
cheap to (re)index, and is built to be driven by coding agents as much as by humans — stable
JSON schemas, deterministic exit codes, byte-exact source locations, and an `--explain` mode
that can justify every result via the exact graph path that produced it.

```bash
uv tool install ragx-cli --with ragx-cli[rerank]  # install and use `ragx-cli`
ragx-cli init                  # create ragx.toml next to your corpus (interactive on a TTY; --yes for defaults)
ragx-cli index                 # chunk -> embed -> HNSW + kNN similarity graph (incremental after the first run)
ragx-cli query "why did we switch build tools?" --json --files-only
ragx-cli index --full          # full rebuild: re-chunk + re-embed everything
ragx-cli index --edges-only    # rebuild sub-chunks/edges/communities only; keeps chunk embeddings
ragx-cli models                # recommend + download an embedding/reranker combo via LM Studio
ragx-cli doctor                # check the configured providers start and answer, and the index matches
ragx-cli --version             # version, repo, and the effective models (corpus config + ~/.ragxrc overrides)
```

Runs against any OpenAI-compatible embedding endpoint (LM Studio, Ollama, OpenAI). Reranking
uses a local sentence-transformers cross-encoder (`ragx-cli[rerank]` extra). Config lives in
`ragx.toml` at the corpus root (commit it); all index data lives in a `.ragx/` directory beside
your files (gitignore it) — like `.git/`, delete `.ragx/` and the corpus is untouched.

---

- [ragx-cli — similarity-graph RAG for your files](#ragx-cli--similarity-graph-rag-for-your-files)
  - [How it works](#how-it-works)
    - [Indexing (LLM-free)](#indexing-llm-free)
    - [Querying](#querying)
  - [Does it actually help? (benchmarks)](#does-it-actually-help-benchmarks)
    - [Parameter study: what each knob actually does (2026-07)](#parameter-study-what-each-knob-actually-does-2026-07)
    - [BGE-M3 embedding study (2026-07-12)](#bge-m3-embedding-study-2026-07-12)
  - [Checking your setup: `ragx-cli doctor`](#checking-your-setup-ragx-cli-doctor)
  - [Agent-first conventions](#agent-first-conventions)
  - [Using ragx-cli from a coding agent (CLAUDE.md / AGENTS.md)](#using-ragx-cli-from-a-coding-agent-claudemd--agentsmd)
    - [Pointing ragx-cli at your LLM — local or online](#pointing-ragx-cli-at-your-llm--local-or-online)
    - [Machine-level settings: `~/.ragxrc`](#machine-level-settings-ragxrc)
  - [Configuration](#configuration)
  - [Features \& roadmap](#features--roadmap)


## How it works

### Indexing (LLM-free)

Files are chunked structure-aware (markdown headings / code boundaries / recursive fallback),
embedded, and stored in an HNSW index. The similarity graph then falls out almost for free:
one kNN pass over the vectors that are already in memory — each chunk gets edges to its top-k
nearest neighbors above a similarity floor.

`graph.edge_source = "subchunk"` (default) derives edge weights from sentence-aligned
**sub-chunks** instead of whole-chunk cosine — each chunk is split into ~`subchunk_size_tokens`
(256) token windows, embedded separately (stored in SQLite, never in the query-time HNSW), and
the edge weight between two chunks becomes the *max* similarity over their sub-chunk pairs, so a
chunk mixing several concepts gets one sharp edge per concept instead of a diluted average. Edges
between near-duplicate chunks (whole-chunk cosine ≥ `near_dup_sim`) are dropped — those are the
measured precision-killers. Costs ~2× more embedding calls at index time than `edge_source =
"chunk"`; retrieval units, traversal, and query flow are unchanged. Switching `edge_source` (or
the sub-chunk size) requires either `index --edges-only` (cheap: rebuilds sub-chunks, edges and
communities from the stored chunks — chunk embeddings and the HNSW index are kept) or
`index --full`; incremental runs fail loud on the mismatch, naming the old/new value and both
fixes. Literature grounding:
`research/fine-grained-sub-chunk-edges-with-coarse-chunk-nodes-multi-granularity-graph-rag-literature-validation.md`.
In this mode `k` counts links per *sub-chunk* with no per-chunk cap, so the graph is denser and
shallower traversal suffices (the default `hops=2` already matches this). Quality ties
`edge_source="chunk"` exactly at every sub-chunk size tried, 64–256 tokens (2026-07 and
2026-10-21 sweeps); the corpora measured so far have short, single-topic chunks with no
concept-dilution headroom to exploit. `256` is the default because it's the cheapest size that
ties — fewest edges, ~2× index time vs ~2.8–4.7× at 128/64 tokens. Set `graph.edge_source =
"chunk"` back if a corpus's long, genuinely multi-concept chunks make the extra edges worth the
cost — no corpus has measured a win yet. Sweep data:
`research/subchunk-size-sweep-2026-10-21-default-edge-source-subchunk-size-choice.md`.

After edge construction, a Leiden partition (`graspologic-native`, seeded, deterministic) is
computed over the whole edge list every index run and stored read-only — see
[`[communities]`](#configuration) and `inspect communities`/`inspect community`.

```mermaid
flowchart TD
    A[files] --> B["discover + hash<br/>(gitignore, binary/junk filters,<br/>xxhash for incremental)"]
    B --> C["chunk<br/>(headings / code boundaries,<br/>byte-exact slices + line ranges)"]
    C --> D["embed<br/>(any OpenAI-compatible endpoint)"]
    D --> E[("HNSW index<br/>vectors.hnsw")]
    D --> F[("SQLite<br/>files · chunks · edges · manifest")]
    E -- "kNN per chunk<br/>(k=8, cos ≥ 0.55)" --> G["similarity graph<br/>undirected weighted edges"]
    G --> F
```

`ragx-cli index` is **incremental by default** (0.4.0; `--changed` is a deprecated alias): it
re-embeds only new/modified files, drops deleted ones, and repairs only the edge lists those
chunks touch. Content hashes make `touch`-ed but unchanged files free, and include/exclude glob
changes converge to the same file set a rebuild would produce. `--full` rebuilds from scratch;
changing `embeddings.model` or `chunking.*` requires it — incremental runs fail loud on the
manifest mismatch, naming the changed key and its old/new value. Changing `graph.edge_source`/
`subchunk_size_tokens` alone is cheaper: `--edges-only` rebuilds just the sub-chunks, edges and
communities from the chunks already stored — it skips chunking and embedding entirely, reusing
each chunk's stored vector (via the HNSW index) for chunks that stay whole, and only calls the
embedder for the sub-chunk pieces a long chunk splits into. It fails loud the same way `--full`
does if `embeddings.model`/`chunking.*` don't match the stored manifest (those invalidate the
chunks it reuses), but `edge_source`/`subchunk_size_tokens` are exactly what it's meant to change.
`ragx-cli status` reports **drift** (files new/changed/deleted since the last index) so you can
see when a re-index is due without running one.

Upgrading from an older `ragx-cli` after a default change (e.g. the `graph.edge_source` flip to
`"subchunk"`): `ragx-cli init` always writes every `[graph]` key explicitly into `ragx.toml`
(`Config.save` bakes the full default set, not just overrides), so an init-created corpus already
pins its `edge_source`/`subchunk_size_tokens` and is unaffected by a later default change. Only a
hand-written `ragx.toml` missing those keys picks up the new default; its next incremental
`index` then fails loud — `index --edges-only` rebuilds just the graph (cheapest), `index --full`
rebuilds everything, or pin the old value back in `ragx.toml`.

### Querying

A default query runs vector search + graph traversal: no LLM call, no cross-encoder.
Expansion (`--expand`) and rerank (`--rerank`) are opt-in; `--no-graph` skips traversal,
which leaves pure vector search. Callers trade latency for quality per query.

Every result chunk also comes with grep-style `-B`/`-A`/`-C` context lines, on by default
(`query.context_lines=5` on each side). `-B/--before N` and `-A/--after N` set each side
independently; `-C/--context N` sets both; explicit `-B`/`-A` win over `-C`, which wins over
the default. Counting rule: `N` counts **non-empty** lines walking outward from the chunk —
blank lines don't count toward `N` but are kept in the returned span if they fall between the
chunk and the Nth non-empty line; the span stops exactly at that line, so blank lines beyond it
are excluded. `N=0` disables that side; fewer than `N` non-empty lines in the file returns what
exists; none returns `null`. Context is read fresh from disk at query time, never truncated by
`query.max_chunk_chars`, and `--files-only` skips it (no file reads, since the text isn't shown).

Results are **one entry per file**. The pipeline ranks chunks, walks that list until it has
seen `--top` distinct files (default `query.top = 20`), and folds each file's chunks inside
that window into one entry: up to 3 sub-entries, entry score = their sum. A file that would
appear several times in a flat list therefore ranks higher. Chunks below the window never
count, so graph traversal's weak neighbour chunks cannot pad a file (measured on the notes
eval corpus: graph-config MRR .43 flat-list, .38 summing all chunks, .65 windowed).

```mermaid
flowchart TD
    Q[query] --> X["1 · expansion (optional, one LLM call)<br/>2–4 reformulations + HyDE passage"]
    X --> S["2 · fan-out vector search<br/>top-20 per variant"]
    Q -. "default (no --expand)" .-> S
    S --> R["3 · Reciprocal Rank Fusion<br/>merged seed set"]
    R --> H["4 · heat propagation over the graph<br/>2 hops · decay 0.5 · max-aggregation<br/>query-similarity floor · frontier cap"]
    R -. "--no-graph" .-> K
    H --> K["5 · cross-encoder rerank (--rerank)<br/>(query, chunk) pairs, capped shortlist"]
    H -. "default (no --rerank)" .-> F
    K --> F["6 · combined score<br/>α·rerank + β·heat + γ·vector"]
    F --> G["7 · fold per file<br/>a file's best ≤3 chunks → one entry, score = their sum"]
    G --> O["top 20 entries (or paths via --files-only)<br/>+ --explain traversal trace"]
```

**Heat propagation** is what sets ragx-cli apart from plain RAG: seed chunks (from vector search)
push "heat" along similarity edges — `heat × edge_weight × decay` per hop. A neighbor's heat is
the **max** of incoming contributions (not the sum, so hub chunks can't inflate themselves), and
a neighbor is only admitted if it's similar enough to the *original query* — traversal stays
anchored to the question instead of drifting through the corpus.

```mermaid
flowchart LR
    subgraph seeds["seeds (vector hits)"]
        S1["chunk A · heat 1.0"]
    end
    S1 -- "edge 0.86" --> N1["chunk B<br/>heat = 1.0 × 0.86 × 0.5 = 0.43"]
    N1 -- "edge 0.70" --> N2["chunk C<br/>heat = 0.43 × 0.70 × 0.5 = 0.15"]
    S1 -- "edge 0.60" --> X1["chunk D — below query floor<br/>✗ not admitted, doesn't relay"]
```

Because every admitted chunk records which seed and edge produced it, `--explain` can print the
full justification: *seed → edge(weight) → chunk*, per result. The explain object also carries
`path`: the seed-first chain of chunk ids along the traversal's own parent links
(`[seed, ..., chunk_id]`), so a UI can highlight the exact path the traversal took instead of
guessing a shortest path through the graph.

---

## Does it actually help? (benchmarks)

Measured with the built-in harness (`ragx-cli eval queries.jsonl`) on a real, decade-spanning
personal wiki — organic notes, not a synthetic benchmark. Corpus provenance:

| | |
|---|---|
| **corpus** | 636 markdown files indexed (644 on disk; 8 auto-excluded as `node_modules`/hidden) · 2.9 MB · avg 4.6 KB/file |
| **structure** | topical top-level dirs (`clients/`, `projects/`, `workstreams/`, `personal/`, …), nested up to 10 levels deep |
| **content** | mixed **English + Dutch**: meeting/daily notes, research docs, transcripts, reference material |
| **chunks** | 1,323 (avg 2.1 per file, 452 files are single-chunk; median 2,390 chars ≈ 600 tokens, max 4,888) |
| **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 |
| **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 |
| **labels** | 18 queries (EN + NL) with known-relevant files, single- and multi-target (`.ragx/queries.jsonl`) |

Reranker: `BAAI/bge-reranker-v2-m3` (local cross-encoder). Results:

| config                           | recall@5 | recall@10 |       MRR |
| -------------------------------- | -------: | --------: | --------: |
| `baseline` — vector search only  |    0.833 |     0.833 |     0.593 |
| `graph` — + heat propagation     |    0.778 |     0.833 |     0.522 |
| `rerank` — graph + cross-encoder |    0.759 | **0.889** |     0.568 |
| `full` — + LLM expansion         |    0.759 | **0.889** | **0.613** |

The recall win is exactly the designed mechanism, and it's traceable. For one Dutch query
("zonnepanelen offerte en terugverdientijd"), the relevant document is **never retrieved** by
vector search — and a reranker alone can't help, because you can't rerank what retrieval never
surfaced:

| pipeline                 | rank of the relevant file |
| ------------------------ | ------------------------: |
| vector search only       |               *not found* |
| rerank **without** graph |               *not found* |
| graph only               |                        19 |
| graph **+** rerank       |                     **4** |

The graph surfaced it through a single hop-1 edge (weight 0.86) from a seed chunk, and the
cross-encoder promoted it — *graph expands recall, rerank recovers precision*. The `--explain`
output for that result shows the exact seed → edge → chunk path.

**Honest caveat:** graph traversal *alone* hurts precision on this corpus (MRR 0.593 → 0.522) —
near-duplicate neighbors (e.g. adjacent meeting notes) displace weaker direct hits. A parameter
sweep over decay/floor/weights plateaued below baseline MRR, so this is a property of
similarity-only edges, not a tuning miss. Conclusion: **graph and rerank belong together**.
Since 0.8.0 the CLI default is the fast path (vector + graph, no rerank), which is the
"graph-only" row above; pass `--rerank` for the measured best quality, or `--no-graph` for
pure vector search.

The parameter study below explains the mechanism behind this caveat — and shows that once the
scoring weights stop putting heat into the final score, the graph win gets much bigger.

### Parameter study: what each knob actually does (2026-07)

A follow-up study swept every traversal, scoring, and graph-build parameter to map sensitivity
and find better settings. Setup: a scoped subset of the same wiki (`clients/` + `workstreams/`:
383 files → 720 chunks → 2,848 edges) with a fresh 26-query EN+NL label set.

**Process.** Sweeping through real `eval` runs costs ~9 minutes each, so the study used an
offline harness (`.lab/harness.py`): one retrieval pass per query plus a text-keyed cross-encoder
score cache lets any traversal × scoring × edge-filter combination be re-evaluated in seconds —
the combination step is deterministic post-processing, and rerank scores depend only on
(query, chunk text). The harness was validated **digit-exact** against the real pipeline before
use, ~125 configurations were measured in staged rounds (scoring simplex → refinement →
traversal one-factor-at-a-time → interaction grid → edge-filter simulations), the winning config
was re-verified with a real `eval` run (again digit-exact), and finally replicated on an
independently rebuilt index.

**Result** — same-index comparisons, `rerank` config (no expansion) unless noted:

| config | MRR | recall@5 | recall@10 |
|---|---:|---:|---:|
| defaults (`hops=2`, α/β/γ = .6/.25/.15) | 0.665 | 0.885 | 0.923 |
| **tuned (`hops=3`, α/β/γ = .9/0/.1)** | **0.755** | 0.885 | **0.962** |

The +13.7% MRR delta replicated as +14.5% on an independently rebuilt index (absolute numbers
differ per build — see the measurement caveat below).

Apply the tuned settings to a corpus with:

```bash
ragx-cli config set traversal.hops 3
ragx-cli config set scoring.alpha_rerank 0.9
ragx-cli config set scoring.beta_heat 0.0
ragx-cli config set scoring.gamma_vector 0.1
```

**Discoveries**, in decreasing order of impact:

- **α (rerank weight) is the dominant knob.** MRR rises monotonically from α=.6 to a plateau at
  α≈.85–.9 (+9% relative), with recall flat across the whole range. Heat belongs at **β=0**:
  once the cross-encoder is trusted, heat in the final score only adds near-duplicate noise —
  which is exactly why the earlier graph-only sweeps plateaued.
- **But never α=1.0.** With β=γ=0 the pre-rerank shortlist selector degenerates (it renormalizes
  β:γ, so every pre-score becomes 0) and MRR collapses to 0.32. Vector + heat pick *which* 100
  candidates the cross-encoder sees at all — they are load-bearing for shortlist selection even
  at near-zero final weight. Keep γ > 0.
- **`hops=3` is the second win, but only after fixing the scoring.** Under default weights,
  traversal depth is completely inert (heat dilution cancels the candidate gains); under
  rerank-heavy weights it adds both MRR and recall@10. **`hops=4` degrades**: ~570 candidates
  overwhelm the fixed rerank shortlist (`RERANK_CAP=100`) and good candidates get evicted before
  the cross-encoder ever sees them — the cap, not the graph, becomes the binding constraint.
- **Graph = recall channel, cross-encoder = precision channel.** At identical tuned scoring,
  removing the graph keeps MRR (0.71) but drops recall@10 by 7.7 points. The graph's job is to
  put reachable targets in front of the reranker; the reranker's job is to rank them.
- **Everything else is inert or already optimal**: `decay` (.3–.7), `query_floor` (.2–.5),
  `max_frontier` (50–300), and `min_edge_sim` (up to .7) don't move metrics; `k=8` is bracketed
  optimal from both sides (k=4/6 lose recall, a real k=12 rebuild was no better).
- **LLM expansion buys recall, not ranking.** The full pipeline at default params reached the
  same recall@10 that `hops=3` provides for free, at ~40 s/query for a local reasoning model.
  On tuned params expansion still stacked (+.03 MRR) — worth it only when latency doesn't matter.
- **Measurement caveat: index rebuilds are not reproducible across embedding-server sessions.**
  Rebuilding is deterministic within a session, but embeddings drift between LM Studio sessions
  (~.04 MRR at identical params). Compare configs on the same build only; the tuned-vs-default
  delta replicated across two independent builds (+13.7% / +14.5% MRR).

### BGE-M3 embedding study (2026-07-12)

A follow-up study asked whether switching the embedding model beats tuning parameters on top of
the existing one. Setup: same scoped corpus and 26-query EN+NL label set as the parameter study
above, tuned retrieval params held identical across both legs (`hops=3`, α/β/γ = .9/0/.1),
reranker unchanged (`BAAI/bge-reranker-v2-m3`). Leg 1 = the production `nomic-embed-text-v1.5`
(Q4_K_M) index; leg 2 = a full re-index with `text-embedding-bge-m3` (Q8 GGUF), doc/query
prefixes emptied (BGE-M3 takes none).

| config | recall@5 | recall@10 |   MRR |
|---|---:|---:|---:|
| `baseline` — nomic (Q4_K_M)  |  0.846 |  0.846 |  0.561 |
| `baseline` — bge-m3 (Q8)     | **0.923** | **0.962** | **0.640** |
| `rerank` — nomic (Q4_K_M)    |  0.846 |  0.923 |  0.717 |
| `rerank` — bge-m3 (Q8)       | **0.885** | **0.962** | **0.755** |

**Recommendation:** `text-embedding-bge-m3` (Q8 GGUF in LM Studio) with EMPTY `doc_prefix`/
`query_prefix` is now the recommended embedding model — it beats nomic on every metric at
identical retrieval params, with the biggest gains on Dutch/multilingual queries (3 of nomic's
4 baseline misses become hits). Two caveats before treating this as fully settled: the comparison
ran nomic at Q4_K_M against bge-m3 at Q8, so part of the delta may be quantization quality rather
than model architecture (an isolating Q8-vs-Q8 run hasn't been run); and BGE-M3's cosine
distribution sits lower than nomic's (median edge weight .70 vs .81), which makes the fixed graph
thresholds (`min_edge_sim=0.55`, `query_floor=0.35`) effectively stricter for it — a retune pass
on the BGE-M3 distribution is a known follow-up, not yet done.

Two sibling questions were studied and rejected as adoption paths right now. **Sparse/lexical**:
start a lexical retrieval leg with SQLite FTS5/BM25 as an extra RRF seed ranking, not BGE-M3's
`lexical_weights` — the latter needs a resident PyTorch model at query time for a gain that isn't
proven necessary here. **ColBERT multi-vector** late interaction: no-adopt for both reranking and
edge-building — it underperforms the existing cross-encoder, can't be served over ragx's
OpenAI-compatible HTTP provider architecture, and costs roughly 100x the storage of the existing
subchunk edge mechanism for a mechanism the subchunk ablation already showed has no headroom on
this corpus.

Full write-ups: `research/bge-m3-dense-q8-vs-nomic-q4-benchmark-2026-07-12-worktree-eval-results.md`
(this benchmark), `research/bge-m3-dense-embeddings-as-ragx-provider-multilingual-quality-and-threshold-calibration.md`
(dense literature review), `research/bge-m3-sparse-lexical-weights-hybrid-retrieval-leg-for-ragx-vs-sqlite-fts5-bm25.md`
(sparse), `research/bge-m3-colbert-multi-vector-late-interaction-for-ragx-rerank-alternative-and-token-level-graph-edges.md`
(ColBERT).

---

## Picking models: `ragx-cli models`

`ragx-cli models` recommends an embedding + reranker combo, downloads the embedding model
**through LM Studio** (`lms get` — one download path, plays well with restricted networks),
pre-fetches the reranker from Hugging Face, and writes both to `ragx.toml`. On a TTY it walks
three steps — context first, consequences before anything is written:

1. **machine header** — detected RAM, platform, whether `llama-server` is on PATH and the
   `lms` CLI was found (this is what drives the recommended defaults);
2. **numbered menus** — the embedding tier table (model, size, languages, context, license)
   and one menu per serving engine with a one-line tradeoff each; the recommended default is
   marked `»` (Enter accepts), invalid input re-prompts;
3. **plan** — what will be downloaded (or `already downloaded ✓`) with approximate sizes,
   which config sections change, and whether a re-index is required — all shown *before*
   the final confirm.

Scriptable via flags:

```bash
ragx-cli models                                        # interactive
ragx-cli models --quality balanced --yes --json        # agents
ragx-cli models --quality fast --dry-run               # recommend only, change nothing
```

The curated catalog (every entry verified working end-to-end):

| tier | embedding model | size | langs | ctx | notes |
|---|---|---|---|---|---|
| `fast` | EmbeddingGemma 300M | 308M | 100+ | 2K | lightest; small corpora, low RAM |
| `balanced` | BGE-M3 | 568M | ~100 | 8K | ragx's benchmarked production model |
| `best` | Qwen3-Embedding-0.6B | 0.6B | 100+ | 32K | highest quality; slower indexing |
| `jina-nano` | Jina embeddings v5 nano | 239M | multi | 8K | best sub-500M; **CC-BY-NC (non-commercial)**; llama-server engine only — LM Studio downloads it but can't serve EuroBERT (picking it locks the embedding engine to llama-server automatically; an explicit `--embed-engine lm-studio` still fails loud) |

**Serving engines.** Embeddings run through `--embed-engine`: **`llama-server`** (default when
llama.cpp is installed — ragx auto-spawns `llama-server --embedding` on the downloaded GGUF and
terminates it at exit; LM Studio is only the downloader) or **`lm-studio`** (LM Studio serves
`/v1/embeddings`; zero extra processes, LM Studio must be running). The reranker —
`BAAI/bge-reranker-v2-m3` (multilingual) — runs through `--rerank-engine`:

- **`llama-server`** (default when llama.cpp is installed): the reranker's Q8_0 GGUF is also
  downloaded via LM Studio, and ragx auto-spawns/terminates a `llama-server --rerank` process
  at query time (`rerank.provider="llama-server"`, `rerank.gguf`, `rerank.base_url`,
  `rerank.server_bin`). **No huggingface.co access needed anywhere.** GGUF scores are validated
  within ~0.5 logit of the safetensors originals.
- **`sentence-transformers`**: the classic CrossEncoder path; model pre-fetched from Hugging
  Face (needs the `ragx-cli[rerank]` extra).

With both engines on `llama-server` the whole stack is ragx-managed: LM Studio downloads the
GGUFs, ragx spawns and reaps the servers, and neither LM Studio nor huggingface.co is needed
at query time. Under 8 GB RAM the recommended default moves to `fast`: interactively, picking
a heavier tier asks for confirmation and then **honors your choice**; non-interactive runs
(`--yes`/piped) auto-downgrade to `fast` with a note, since there is nobody to ask. On macOS
`lms get --yes` picks MLX variants where LM Studio's catalog offers them. Switching the
embedding model invalidates the index — the command tells you to run `ragx-cli index --full`.
`init` offers this flow as its final step; rerun `ragx-cli models` anytime to switch.

Why not `jina-embeddings-v5-text-nano`? Excellent model, but its EuroBERT architecture needs
Jina's llama.cpp fork — stock LM Studio can't load it (and the license is CC-BY-NC). See
`research/lm-studio-model-download-api-and-curated-embedding-reranker-catalog-for-ragx-models-command.md`.

## Checking your setup: `ragx-cli doctor`

`ragx-cli doctor` runs the configured pipeline's providers for real — it embeds a probe string,
scores a probe pair with the reranker, and checks the index on disk against the config — then
reports each stage:

```
✓ config      /corpus/ragx.toml — embeddings=openai/text-embedding-bge-m3 · expansion=openai/qwen3.5-9b · rerank=sentence-transformers/BAAI/bge-reranker-v2-m3
✗ embeddings  request to http://localhost:1234/v1/embeddings failed after 3 attempts: [Errno 61] Connection refused
  → ragx does not start this server — launch it at http://localhost:1234/v1 (LM Studio: `lms server start`),
    or hand the process to ragx with `ragx-cli models --embed-engine llama-server`
✗ expansion   no server answered at http://localhost:1234/v1/models
  → turn it off with `ragx-cli config set expansion.enabled false`
✓ rerank      sentence-transformers/BAAI/bge-reranker-v2-m3 scored a probe pair (-8.14, 4.72)  3.1s
✓ index       634 files, 1247 chunks, built with 'text-embedding-bge-m3', no drift
```

Exit codes: `0` healthy, `1` at least one check failed, `2` doctor itself could not run (no
corpus, unreadable config). Warnings (no index yet, corpus drift, a model the server does not
list but may JIT-load) do not fail the run. `--json` emits a `ragx.doctor.v1` document with a
`status`/`detail`/`hint`/`elapsed_ms` entry per check.

**Which servers does ragx start?** All of the local ones:

| engine (`ragx-cli models`) | config | lifecycle |
| --- | --- | --- |
| `llama-server` | `provider="llama-server"` (embeddings and/or rerank) | spawned on demand from the configured GGUF and **killed at exit** — ragx owns the process |
| `lm-studio` | `embeddings.provider="openai"` + a local `base_url` | `lms server start` + `lms load <model>` when nothing answers — LM Studio **keeps running** afterwards (it is an app, not ragx's child) |
| `sentence-transformers` | `rerank.provider="sentence-transformers"` | in-process, no server |

`index`, `query`, and `doctor` all do this before their first request, so a cold machine needs
no manual setup. What was started is logged to stderr and shown in doctor's report:

```
✓ embeddings  started the LM Studio server on port 1234; loaded 'text-embedding-nomic-embed-text-v1.5' into LM Studio — openai/text-embedding-nomic-embed-text-v1.5 answered, dim=768  14.8s
```

Rules it follows: only a **local** `base_url` is ever touched (a cloud endpoint, including one
set via `OPENAI_BASE_URL`, is never ragx's to start); an already-running server is left alone,
and if LM Studio isn't installed but something else answers on that port, ragx just uses it; a
model that isn't downloaded fails loud naming the models the machine does have. Turn it off per
section with `ragx-cli config set embeddings.autostart false` (or machine-wide via
`--global`) — then ragx only ever talks to servers you started.

`ragx-cli models` still only downloads models and writes `ragx.toml`; it never launches
anything. If a run dies with "connection refused", `doctor` names the stage and the fix.

## Browsing the graph: `ragx-cli web`

`ragx-cli web` serves an interactive view of the chunk graph plus a query box, over plain
`http.server` (no web framework, no build step for the backend):

```
ragx-cli web [--host 127.0.0.1] [--port 8765] [--open/--no-open]
```

Renders whatever edges the index has: sub-chunk similarity when it was built with
`graph.edge_source = "subchunk"` (the default), whole-chunk cosine when `"chunk"`. Fails loud
(exit 2) only when no index exists yet. To switch an existing index to sub-chunk edges without a
full re-embed, set the `[graph]` keys in `ragx.toml` and run `ragx-cli index --edges-only`.
`--open` (default) launches the browser; Ctrl-C shuts the server down cleanly.

Endpoints (JSON; errors are `{"error": "<message>"}`):

- `GET /` and `GET /static/<path>` — the viewer's static assets.
- `GET /api/graph` — `ragx.graph.v1`: `meta` (root, edge_source, subchunk_size_tokens,
  embedding_model, nodes/edges/communities counts), `nodes` (id, file, line_start/line_end,
  label, community, degree), `edges` (`[src, dst, weight]`). Built once at startup, cached,
  gzip-encoded when the request sends `Accept-Encoding: gzip`.
- `GET /api/chunk/{id}` — a chunk's file/lines/text/community plus its neighbors
  (`{chunk_id, weight}`, weight descending). 404 for an unknown id.
- `POST /api/query` — body `{"text", "top", "expand", "graph", "rerank", "hops", "explain",
  "before", "after"}` (only `text` required; the rest default like `ragx-cli query`). Returns
  `ragx.query.v3` (same shape as `--json` query output) plus `elapsed_ms`. 400 on a malformed
  body, 502 when the query pipeline raises (backend down, manifest mismatch).

Providers (embedder, reranker, expansion LLM) are built lazily on first use of their stage and
cached for the server's lifetime; queries run one at a time behind a lock.

## Agent-first conventions

- `--json` emits exactly one JSON document on stdout (versioned schemas: `ragx.query.v3`,
  `ragx.files.v1`, `ragx.status.v1`, `ragx.doctor.v1`, `ragx.eval.v1`, `ragx.inspect.*.v1`); logs go
  to stderr.
- Exit codes: `0` results, `1` success-but-empty, `2` error.
- `ragx.query.v3` results are one entry per file: `{file, score, chunks: [...]}`. Each chunk
  carries `chunk_id`, `line_start/line_end`, `byte_start/byte_end`, `score`, `breakdown`, `text`,
  `truncated` — agents jump to the exact source location and read the full text themselves
  (JSON chunk text is truncated). v1 (one flat entry per chunk) is gone since 0.8.0.
- Each chunk also carries `context: {before, after}` (grep-style `-B`/`-A` lines, default 5
  each; see [Querying](#querying)) — each side is `{line_start, line_end, text}` or `null`.
  The key is omitted entirely when both `--before`/`-B` and `--after`/`-A` are 0.
- `--files-only` returns the same entries without text (`file`, `score`, `chunk_ids`) — the
  mode coding agents use most.
- `ragx-cli status` includes a `drift` object (`new`/`changed`/`deleted` file counts vs the index) —
  agents check it to decide whether to run `ragx-cli index` before querying.
- `ragx-cli doctor --json` verifies the providers answer before a batch of queries (`ok: false`
  plus a per-check `hint` when something is down); exit `1` = a check failed.
- `ragx-cli query -` reads the query from stdin; `ragx-cli inspect chunk|file|neighbors|communities|community`
  debugs the graph.

## Using ragx-cli from a coding agent (CLAUDE.md / AGENTS.md)

Give your agent standing instructions by pasting this into the repo's `CLAUDE.md` or `AGENTS.md`
(adjust the fenced block to your corpus):

```markdown
## Semantic search with ragx-cli

This repo has a ragx-cli index (`.ragx/`). Prefer it over grep for "where is X discussed/decided?"
questions; fall back to grep for exact identifiers.

- Find relevant files: `ragx-cli query "<natural-language question>" --json --files-only`
- Get chunks with exact locations: `ragx-cli query "..." --json` — one result per `file`
  (default 20, `--top N`), each with up to 3 `chunks` carrying `line_start/line_end`; the JSON
  `text` is truncated, so read the file yourself for full context.
- The default query makes no LLM call and loads no cross-encoder. For higher precision add
  `--rerank`; add `--expand` for an LLM query-expansion pass (one extra LLM call).
- After adding or editing files: `ragx-cli index` (incremental and cheap — hash-based).
  `ragx-cli status --json` shows `drift` counts if you want to check staleness first.
- stdout is exactly one JSON document; logs are on stderr.
  Exit codes: 0 = results, 1 = no results (not an error), 2 = error.
- Why did this result appear? `ragx-cli query "..." --explain`.
  Explore the graph: `ragx-cli inspect neighbors <chunk_id>`.
```

### Pointing ragx-cli at your LLM — local or online

ragx-cli talks to any **OpenAI-compatible** API for embeddings and (optionally) query expansion.
Pick one recipe; run it inside the corpus after `ragx-cli init`:

**LM Studio** (default — nothing to change if it runs on `localhost:1234`):

```bash
curl -s http://localhost:1234/v1/models   # see what's loaded
ragx-cli config set embeddings.model text-embedding-nomic-embed-text-v1.5
ragx-cli config set expansion.model  <any-chat-model-id>     # or: ragx-cli config set expansion.enabled false
```

**Ollama** (base_url switches to `localhost:11434/v1` automatically):

```bash
ollama pull nomic-embed-text
ragx-cli config set embeddings.provider ollama
ragx-cli config set embeddings.model nomic-embed-text
ragx-cli config set expansion.provider ollama
ragx-cli config set expansion.model llama3.1                  # any local chat model
```

**Online / any OpenAI-compatible endpoint** (OpenAI, OpenRouter, Together, …).
ragx-cli honors the conventional env vars used by generic OpenAI-compatible tooling — with
`OPENAI_BASE_URL` and `OPENAI_API_KEY` exported, only the model names need configuring:

```bash
export OPENAI_BASE_URL=https://api.openai.com/v1
export OPENAI_API_KEY=sk-...
ragx-cli config set embeddings.model text-embedding-3-small
ragx-cli config set embeddings.doc_prefix ""                  # prefixes are for nomic-style models
ragx-cli config set embeddings.query_prefix ""
ragx-cli config set expansion.model gpt-5.2-mini
```

Precedence rules (per section, embeddings and expansion independently):

- `base_url`: an explicit `ragx-cli config set <section>.base_url …` always wins;
  `OPENAI_BASE_URL` applies only while the config still holds the built-in default.
- API key: `ragx-cli config set <section>.api_key_env MY_VAR` names an env var to read (and fails
  loudly if that variable is unset); without it, `OPENAI_API_KEY` is used when present.
  Secrets themselves never go in `ragx.toml`.

Mixed setups are normal — e.g. local Ollama embeddings + online expansion via
`ragx-cli config set expansion.base_url https://openrouter.ai/api/v1` +
`ragx-cli config set expansion.api_key_env OPENROUTER_API_KEY`. The reranker is always local
(sentence-transformers); disable it with `ragx-cli config set rerank.enabled false` if the model
download is unwanted (can't reach huggingface.co? — see the next section). **Note:** changing the embedding model invalidates the index — ragx-cli
detects the mismatch and asks you to run a full `ragx-cli index`.

### Reranker on restricted networks (no huggingface.co)

With the default `sentence-transformers` engine, the reranker model
(`BAAI/bge-reranker-v2-m3`, ~2.3 GB) is downloaded from huggingface.co on first use. If your
network blocks huggingface.co, ragx-cli degrades gracefully — queries run without reranking
and a warning explains why — and the easiest fix is to skip huggingface.co entirely:

**Option 0 — the `llama-server` engine (recommended).** `ragx-cli models --rerank-engine
llama-server` downloads a validated Q8_0 GGUF of the reranker **through LM Studio** (same
download path as the embedding model) and reranks via llama.cpp's `llama-server --rerank`,
which ragx starts and stops automatically. Requires a recent llama.cpp
(`brew install llama.cpp`); no torch, no sentence-transformers, no huggingface.co. The GGUFs
score within ~0.5 logit of the safetensors originals with identical ordering.

Prefer the sentence-transformers engine? Three more ways:

**Option A — copy the model and point `rerank.model` at the directory** (recommended).
On any machine with access:

```bash
pip install -U huggingface_hub   # or: uv tool install huggingface_hub
hf download BAAI/bge-reranker-v2-m3 --local-dir bge-reranker-v2-m3
```

Transfer the `bge-reranker-v2-m3/` directory to the restricted machine (it must contain
`config.json`, `model.safetensors`, and the tokenizer files — `hf download` fetches all of
them), then:

```bash
ragx-cli config set --global rerank.model /absolute/path/to/bge-reranker-v2-m3
```

sentence-transformers loads a local directory without any network access. `--global` writes
to `~/.ragxrc` so the machine-local path doesn't end up in a shared corpus `config.toml`.

**Option B — use a HuggingFace mirror.** `huggingface_hub` honors the `HF_ENDPOINT`
environment variable, so if a mirror is reachable (e.g. an internal artifact proxy, or a
public mirror such as `https://hf-mirror.com`):

```bash
HF_ENDPOINT=https://hf-mirror.com ragx-cli query "..."
```

**Option C — pre-seed the HuggingFace cache.** Copy
`~/.cache/huggingface/hub/models--BAAI--bge-reranker-v2-m3/` from a machine where the model
has already been used to the same path on the restricted machine. Set `HF_HUB_OFFLINE=1` to
stop `huggingface_hub` from attempting update checks against huggingface.co.

If none of these are workable, `ragx-cli config set rerank.enabled false` turns reranking
off explicitly (scoring weights renormalize automatically).

### Machine-level settings: `~/.ragxrc`

Provider settings that belong to the machine rather than the corpus — which embedding
model, which LLM, which base URL — can live in `~/.ragxrc` (TOML, same shape as
`ragx.toml`, restricted to the `[embeddings]`, `[expansion]`, and `[rerank]` sections):

```bash
ragx-cli config set --global embeddings.model text-embedding-nomic-embed-text-v1.5
ragx-cli config set --global expansion.model llama3.1
```

Precedence: built-in defaults < corpus `ragx.toml` < `~/.ragxrc`. The rc
**overrides** corpus values, and every command warns on stderr when it does, so a
corpus config never loses silently. Set corpus-specific values without `--global`
as usual. Other sections (chunking, graph, …) are corpus-level and rejected from
the rc. The index-invalidation note above applies equally when the rc changes the
effective embedding model.

## Configuration

`ragx.toml` at the corpus root, managed via `ragx-cli config get|set` (add `--global` to
write provider settings to `~/.ragxrc` instead — see above). The config file is meant to be
committed with your corpus; `.ragx/` (index data) is safe to gitignore — a fresh clone just
runs `ragx-cli index`. The config file itself is never indexed as corpus content.

> **Upgrading from ≤0.2.x:** the config moved from `.ragx/config.toml` to `ragx.toml` at the
> corpus root, and ragx-cli migrates it for you on first use: on a TTY it asks before moving
> the file (declining leaves everything untouched and prints the `mv` to run yourself);
> piped/agent runs migrate automatically with a notice on stderr. If both files exist,
> commands fail loud — keep one and delete the other. The index itself is untouched.

`ragx-cli init` is **interactive when run on a TTY**: it probes the default LM Studio
(`localhost:1234`) and Ollama (`localhost:11434`) ports, lists the models each server
reports, and walks through embeddings (provider/base URL/model/API-key env var), query
expansion (enable + model — likely thinking/reasoning models are listed last, since a
non-thinking model is the better expansion choice; detection is a best-effort name
heuristic, the APIs expose no capability flag), and corpus include/exclude
(gitignore-style globs, comma-separated). Every prompt is pre-filled with the defaults
below — Enter accepts them all. With piped stdin (agents), `--yes`, or
`--no-interactive`, it writes the defaults unchanged, exactly as before. After writing the
config, `init` prints a note: an AI agent can tailor `corpus.include`/`exclude` to the
project, and add a CLAUDE.md/AGENTS.md section on searching it with `ragx-cli query`
(see [Using ragx-cli from a coding agent](#using-ragx-cli-from-a-coding-agent-claudemd--agentsmd)).
Key defaults:

| section | defaults |
|---|---|
| `[corpus]` | `include` = source code (`*.py`, `*.ts`, `*.go`, `*.rs`, … ~70 extensions, plus `Dockerfile`/`Makefile`) and text docs (`*.md`, `*.mdx`, `*.rst`, `*.txt`, `*.adoc`, `*.org`); `exclude` = lockfiles (`*.lock`, `package-lock.json`, `pnpm-lock.yaml`, `go.sum`, …), `requirements*.txt`/`constraints*.txt`, `LICENSE*`/`COPYING*`, `*.min.js`/`*.min.css`/`*.map`, and `dist/`, `build/`, `target/`, `coverage/`, `vendor/`; `respect_gitignore=true`. Full lists: `src/ragx/core/corpus_globs.py`. Existing `ragx.toml` files keep their own lists |
| `[chunking]` | `size_tokens=800`, `overlap=0.15` |
| `[graph]` | `k=8`, `min_edge_sim=0.55`, `edge_source="subchunk"`, `subchunk_size_tokens=256`, `near_dup_sim=0.9` |
| `[traversal]` | `hops=2`, `decay=0.5`, `query_floor=0.35`, `max_frontier=150` |
| `[communities]` | `resolution=1.0`, `seed=42` — recomputed every index run; changing these never invalidates the index |
| `[fusion]` | `rrf_k=60`, `per_query_top=20` |
| `[scoring]` | `alpha_rerank=0.6`, `beta_heat=0.25`, `gamma_vector=0.15` |
| `[embeddings]` | `provider="openai"`, `base_url="http://localhost:1234/v1"`, prefixes for nomic-style models, `api_key_env=""`, `autostart=true` (start LM Studio + load the model when the URL is local) |
| `[expansion]` | optional LLM for multi-query/HyDE; reasoning models supported (4096-token budget); `api_key_env=""`, `autostart=true` |
| `[rerank]` | `BAAI/bge-reranker-v2-m3` via sentence-transformers (`uv tool install 'ragx-cli[rerank]'`) |
| `[query]` | `top=20`, `max_chunk_chars=1200`, `context_lines=5` (default for `-B`/`-A`/`-C`) |

## Features & roadmap

Checked features are built and validated per [the implementation plan](ragx-cli-plan.md);
unchecked ones are next up. Release history: [CHANGELOG.md](CHANGELOG.md).

- [x] **CLI & storage**: typer CLI, SQLite schema, provider abstraction (Embedder/Generator/Reranker)
- [x] **Baseline vector RAG**: discovery, chunking, embeddings, HNSW search, incremental indexing
- [x] **Similarity graph**: kNN edge construction, heat-propagation traversal, `inspect`, `--explain`
- [x] **Quality & measurement**: multi-query/HyDE expansion, RRF fusion, cross-encoder rerank, `eval` harness
- [x] **Communities**: Leiden detection over the edge list (index-time, read-only via status/inspect)
- [x] **Interactive `init`**: LM Studio/Ollama server + model detection, guided embeddings/expansion/corpus setup (`--yes` for defaults)
- [x] **Committable config**: `ragx.toml` at the corpus root (0.3.0; was `.ragx/config.toml`), index data stays gitignored in `.ragx/`
- [x] **Offline-friendly rerank**: graceful degradation + pre-seeding docs when huggingface.co is unreachable
- [x] **Sub-chunk edges** (default since the 2026-10-21 default flip; `graph.edge_source="subchunk"`): edge weight = max cosine over sentence-aligned sub-chunk pairs, for corpora with long multi-concept chunks; set `graph.edge_source="chunk"` for the whole-chunk-cosine alternative
- [x] **Incremental by default** (0.4.0): `index` hash-diffs, `--full` rebuilds, `status` reports corpus drift, chunking params are manifest-guarded
- [x] **`ragx-cli models`**: curated embedding/reranker recommendation (quality tier + RAM detection), downloads via LM Studio's `lms get`, serving via llama-server or LM Studio
- [x] **`ragx-cli web`**: interactive sub-chunk graph viewer + query UI over stdlib `http.server`
- [ ] **Community labels**: name the Leiden communities so they're browsable without reading member chunks
- [ ] **query --global** for corpus-level questions (answer from community summaries, not individual chunks)
- [ ] **MCP server**: a second thin shell over `ragx.core` (the core/CLI split it needs is already enforced)
- [ ] **[Temporal weighting](docs/feature-temporal-weighting.md)**: opt-in `--since`/`--until`/`--temporal recent|oldest`, date cascade filename/frontmatter → git → mtime
- [x] **Release**: publish to PyPI as `ragx-cli` (plain `ragx` is name-blocked, too similar to an existing project) so `uvx ragx-cli` works out of the box

Development: `uv sync --group dev && uv run pytest`. 183 tests; module contracts live in
`CONTRACTS.md` / `CONTRACTS-PHASE23.md`.
