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.
- ragx/__init__.py +1 -0
- ragx/cli/__init__.py +0 -0
- ragx/cli/app.py +105 -0
- ragx/cli/eval_cmd.py +74 -0
- ragx/cli/inspect_cmd.py +122 -0
- ragx/cli/output.py +19 -0
- ragx/cli/pipeline.py +93 -0
- ragx/core/__init__.py +0 -0
- ragx/core/chunking.py +183 -0
- ragx/core/config.py +130 -0
- ragx/core/discovery.py +79 -0
- ragx/core/errors.py +10 -0
- ragx/core/eval.py +93 -0
- ragx/core/expansion.py +89 -0
- ragx/core/fusion.py +14 -0
- ragx/core/graph.py +37 -0
- ragx/core/indexer.py +118 -0
- ragx/core/models.py +67 -0
- ragx/core/query.py +181 -0
- ragx/core/scoring.py +48 -0
- ragx/core/store.py +161 -0
- ragx/core/traversal.py +68 -0
- ragx/core/vectors.py +105 -0
- ragx/providers/__init__.py +0 -0
- ragx/providers/base.py +46 -0
- ragx/providers/openai_compat.py +117 -0
- ragx/providers/registry.py +81 -0
- ragx/providers/st_reranker.py +28 -0
- ragx_cli-0.1.0.dist-info/METADATA +297 -0
- ragx_cli-0.1.0.dist-info/RECORD +33 -0
- ragx_cli-0.1.0.dist-info/WHEEL +4 -0
- ragx_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ragx_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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.
|