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,
an OpenWiki instance, an Obsidian-style notes vault, or any other knowledgebase repository or
arbitrary docs/code tree — ragx-cli init drops a .ragx/ directory 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.
uv tool install ragx-cli --with ragx-cli[rerank] # install and use `ragx-cli`
ragx-cli init # create .ragx/ with config.toml next to your corpus
ragx-cli index # chunk -> embed -> HNSW + kNN similarity graph
ragx-cli query "why did we switch build tools?" --json --files-only
ragx-cli index --changed # incremental: only new/modified/deleted files
Runs against any OpenAI-compatible embedding endpoint (LM Studio, Ollama, OpenAI). Reranking
uses a local sentence-transformers cross-encoder (ragx-cli[rerank] extra). Everything lives in a
.ragx/ directory beside your files — like .git/, delete it and the corpus is untouched.
- ragx-cli — similarity-graph RAG for your files
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.
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
Incremental runs (--changed) re-embed only changed files and repair only the edge lists that
those chunks touch. Content hashes make touch-ed but unchanged files free.
Querying
Every stage is individually skippable (--no-expand, --no-graph, --no-rerank) so callers
can trade quality for latency.
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 -. "--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<br/>(query, chunk) pairs, capped shortlist"]
K --> F["6 · combined score<br/>α·rerank + β·heat + γ·vector"]
F --> O["ranked chunks (or files 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.
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.
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 baked into the defaults: graph and
rerank ship together. Use --no-graph --no-rerank as the explicit fast mode.
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:
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=3is 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=4degrades: ~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), andmin_edge_sim(up to .7) don't move metrics;k=8is 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=3provides 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).
Agent-first conventions
--jsonemits exactly one JSON document on stdout (versioned schemas:ragx.query.v1,ragx.files.v1,ragx.status.v1,ragx.eval.v1,ragx.inspect.*.v1); logs go to stderr.- Exit codes:
0results,1success-but-empty,2error. - Every chunk carries
file,line_start/line_end,byte_start/byte_end— agents jump to the exact source location and read the full text themselves (JSON chunk text is truncated). --files-onlyaggregates chunk scores per file (sum of top-3) — the mode coding agents use most.ragx-cli query -reads the query from stdin;ragx-cli inspect chunk|file|neighborsdebugs 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):
## 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 --top 8` — each result carries
`file` + `line_start/line_end`; the JSON `text` is truncated, so read the file yourself
for full context.
- Fast mode (no LLM call, no cross-encoder): add `--no-expand --no-rerank`.
- After adding or editing files: `ragx-cli index --changed` (cheap, hash-based).
- 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):
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):
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:
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 explicitragx-cli config set <section>.base_url …always wins;OPENAI_BASE_URLapplies only while the config still holds the built-in default.- API key:
ragx-cli config set <section>.api_key_env MY_VARnames an env var to read (and fails loudly if that variable is unset); without it,OPENAI_API_KEYis used when present. Secrets themselves never go inconfig.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. Note: changing the embedding model invalidates the index — ragx-cli
detects the mismatch and asks you to run a full ragx-cli index.
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
config.toml, restricted to the [embeddings], [expansion], and [rerank] sections):
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/config.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/config.toml, managed via ragx-cli config get|set (add --global to write
provider settings to ~/.ragxrc instead — see above). Key defaults:
| section | defaults |
|---|---|
[chunking] |
size_tokens=800, overlap=0.15 |
[graph] |
k=8, min_edge_sim=0.55 |
[traversal] |
hops=2, decay=0.5, query_floor=0.35, max_frontier=150 |
[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="" |
[expansion] |
optional LLM for multi-query/HyDE; reasoning models supported (4096-token budget); api_key_env="" |
[rerank] |
BAAI/bge-reranker-v2-m3 via sentence-transformers (uv tool install 'ragx-cli[rerank]') |
Features & roadmap
Checked features are built and validated per the implementation plan; unchecked ones are next up:
- CLI & storage: typer CLI, SQLite schema, provider abstraction (Embedder/Generator/Reranker)
- Baseline vector RAG: discovery, chunking, embeddings, HNSW search, incremental
--changed - Similarity graph: kNN edge construction, heat-propagation traversal,
inspect,--explain - Quality & measurement: multi-query/HyDE expansion, RRF fusion, cross-encoder rerank,
evalharness - Communities: Leiden detection over the edge list,
query --globalfor corpus-level questions - MCP server: a second thin shell over
ragx.core(the core/CLI split it needs is already enforced) - Temporal weighting: opt-in
--since/--until/--temporal recent|oldest, date cascade filename/frontmatter → git → mtime - Release: publish to PyPI as
ragx-cli(plainragxis name-blocked, too similar to an existing project) souvx ragx-cliworks out of the box
Development: uv sync --group dev && uv run pytest. 126 tests; module contracts live in
CONTRACTS.md / CONTRACTS-PHASE23.md.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ragx_cli-0.2.0.tar.gz.
File metadata
- Download URL: ragx_cli-0.2.0.tar.gz
- Upload date:
- Size: 63.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
092d9ef2549c5482c874bf9cb03ff1390118004943ef445ac0fda88ae0651eea
|
|
| MD5 |
c83eba349aebf576a445380b7c9581d2
|
|
| BLAKE2b-256 |
66eafe6361c4bf5d7d35277b5154e04ea8571ca929583d427c0e16d01ee27b4f
|
File details
Details for the file ragx_cli-0.2.0-py3-none-any.whl.
File metadata
- Download URL: ragx_cli-0.2.0-py3-none-any.whl
- Upload date:
- Size: 42.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e0e92b643995bfa613d07aad17dd5bc56448334102073af780baa31fb597dd0c
|
|
| MD5 |
a409688e3319cbc3f3cca59062ad9c5e
|
|
| BLAKE2b-256 |
8b7c19cd42920e9293de9f8a597a4c5047e53611a19de495200c25e9ccd1545e
|