ragx-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ragx_cli-0.1.0/.github/workflows/publish.yml +34 -0
- ragx_cli-0.1.0/.gitignore +9 -0
- ragx_cli-0.1.0/AGENTS.md +1 -0
- ragx_cli-0.1.0/CLAUDE.md +112 -0
- ragx_cli-0.1.0/CONTRACTS-PHASE23.md +166 -0
- ragx_cli-0.1.0/CONTRACTS.md +229 -0
- ragx_cli-0.1.0/LICENSE +21 -0
- ragx_cli-0.1.0/PKG-INFO +297 -0
- ragx_cli-0.1.0/README.md +271 -0
- ragx_cli-0.1.0/docs/feature-temporal-weighting.md +39 -0
- ragx_cli-0.1.0/pyproject.toml +52 -0
- ragx_cli-0.1.0/ragx-cli-plan.md +138 -0
- ragx_cli-0.1.0/src/ragx/__init__.py +1 -0
- ragx_cli-0.1.0/src/ragx/cli/__init__.py +0 -0
- ragx_cli-0.1.0/src/ragx/cli/app.py +105 -0
- ragx_cli-0.1.0/src/ragx/cli/eval_cmd.py +74 -0
- ragx_cli-0.1.0/src/ragx/cli/inspect_cmd.py +122 -0
- ragx_cli-0.1.0/src/ragx/cli/output.py +19 -0
- ragx_cli-0.1.0/src/ragx/cli/pipeline.py +93 -0
- ragx_cli-0.1.0/src/ragx/core/__init__.py +0 -0
- ragx_cli-0.1.0/src/ragx/core/chunking.py +183 -0
- ragx_cli-0.1.0/src/ragx/core/config.py +130 -0
- ragx_cli-0.1.0/src/ragx/core/discovery.py +79 -0
- ragx_cli-0.1.0/src/ragx/core/errors.py +10 -0
- ragx_cli-0.1.0/src/ragx/core/eval.py +93 -0
- ragx_cli-0.1.0/src/ragx/core/expansion.py +89 -0
- ragx_cli-0.1.0/src/ragx/core/fusion.py +14 -0
- ragx_cli-0.1.0/src/ragx/core/graph.py +37 -0
- ragx_cli-0.1.0/src/ragx/core/indexer.py +118 -0
- ragx_cli-0.1.0/src/ragx/core/models.py +67 -0
- ragx_cli-0.1.0/src/ragx/core/query.py +181 -0
- ragx_cli-0.1.0/src/ragx/core/scoring.py +48 -0
- ragx_cli-0.1.0/src/ragx/core/store.py +161 -0
- ragx_cli-0.1.0/src/ragx/core/traversal.py +68 -0
- ragx_cli-0.1.0/src/ragx/core/vectors.py +105 -0
- ragx_cli-0.1.0/src/ragx/providers/__init__.py +0 -0
- ragx_cli-0.1.0/src/ragx/providers/base.py +46 -0
- ragx_cli-0.1.0/src/ragx/providers/openai_compat.py +117 -0
- ragx_cli-0.1.0/src/ragx/providers/registry.py +81 -0
- ragx_cli-0.1.0/src/ragx/providers/st_reranker.py +28 -0
- ragx_cli-0.1.0/tests/__init__.py +0 -0
- ragx_cli-0.1.0/tests/test_chunking.py +99 -0
- ragx_cli-0.1.0/tests/test_cli.py +86 -0
- ragx_cli-0.1.0/tests/test_discovery.py +125 -0
- ragx_cli-0.1.0/tests/test_eval.py +237 -0
- ragx_cli-0.1.0/tests/test_expansion.py +65 -0
- ragx_cli-0.1.0/tests/test_fusion.py +72 -0
- ragx_cli-0.1.0/tests/test_graph.py +84 -0
- ragx_cli-0.1.0/tests/test_inspect.py +159 -0
- ragx_cli-0.1.0/tests/test_integration.py +109 -0
- ragx_cli-0.1.0/tests/test_providers.py +229 -0
- ragx_cli-0.1.0/tests/test_store.py +142 -0
- ragx_cli-0.1.0/tests/test_traversal.py +159 -0
- ragx_cli-0.1.0/tests/test_vectors.py +112 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
test:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: astral-sh/setup-uv@v6
|
|
13
|
+
- run: uv sync --group dev
|
|
14
|
+
- run: uv run pytest -q
|
|
15
|
+
- run: uv run ruff check src tests
|
|
16
|
+
|
|
17
|
+
publish:
|
|
18
|
+
needs: test
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
environment: pypi
|
|
21
|
+
permissions:
|
|
22
|
+
id-token: write
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
- uses: astral-sh/setup-uv@v6
|
|
26
|
+
- name: Check tag matches package version
|
|
27
|
+
run: |
|
|
28
|
+
VERSION=$(uv version --short)
|
|
29
|
+
[ "v$VERSION" = "$GITHUB_REF_NAME" ] || {
|
|
30
|
+
echo "Tag $GITHUB_REF_NAME does not match pyproject version $VERSION" >&2
|
|
31
|
+
exit 1
|
|
32
|
+
}
|
|
33
|
+
- run: uv build
|
|
34
|
+
- run: uv publish --trusted-publishing always
|
ragx_cli-0.1.0/AGENTS.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@CLAUDE.md
|
ragx_cli-0.1.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# CLAUDE.md — ragx
|
|
2
|
+
|
|
3
|
+
## What this is
|
|
4
|
+
|
|
5
|
+
`ragx` is a local CLI that indexes a corpus of files into a chunk-level **embedding similarity
|
|
6
|
+
graph** and answers queries via vector search + **heat-propagation graph traversal** +
|
|
7
|
+
**cross-encoder reranking**. Indexing is LLM-free (embeddings only); an LLM is optional at
|
|
8
|
+
query time for multi-query/HyDE expansion. Built agent-first: single JSON doc on stdout
|
|
9
|
+
(versioned schemas), logs on stderr, exit codes `0` results / `1` empty / `2` error,
|
|
10
|
+
byte-exact source locations, `--explain` traces justifying every result.
|
|
11
|
+
|
|
12
|
+
Design plan: `ragx-cli-plan.md` (phases, parameters, risks). Module build specs:
|
|
13
|
+
`CONTRACTS.md` (Phase 0–1) and `CONTRACTS-PHASE23.md` (Phase 2–3) — historical but accurate
|
|
14
|
+
API contracts for every core module. Deferred feature specs live in `docs/`.
|
|
15
|
+
|
|
16
|
+
## Stack
|
|
17
|
+
|
|
18
|
+
- Python 3.12, managed with **uv** (`uv sync --group dev`, `uv run pytest`). NOT Bun/TS —
|
|
19
|
+
deliberate exception, see plan §2.
|
|
20
|
+
- typer (CLI), hnswlib (HNSW vector index, cosine), SQLite (chunks/edges/manifest, WAL),
|
|
21
|
+
httpx (+respx in tests), xxhash (incremental hashing), pathspec (gitignore globs).
|
|
22
|
+
- Providers speak **OpenAI-compatible HTTP**. Local default: LM Studio at
|
|
23
|
+
`http://localhost:1234/v1`. `provider="ollama"` auto-switches to `:11434/v1`.
|
|
24
|
+
Env fallbacks: `OPENAI_BASE_URL` (only while base_url is still the default) and
|
|
25
|
+
`OPENAI_API_KEY` (only when `api_key_env` is unset); explicit config always wins.
|
|
26
|
+
- Reranker is always local: sentence-transformers CrossEncoder `BAAI/bge-reranker-v2-m3`
|
|
27
|
+
(optional extra `ragx-cli[rerank]`). LM Studio has NO /v1/rerank endpoint — verified; don't try.
|
|
28
|
+
|
|
29
|
+
## Layout (core/CLI split is the one architectural rule — MCP server later rides on it)
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
src/ragx/
|
|
33
|
+
core/ # all logic lives here, CLI-independent
|
|
34
|
+
config.py # .ragx/config.toml load/save, DEFAULTS dict, find_root/require_root
|
|
35
|
+
models.py # Chunk, ChunkDraft, FileRecord, Edge, ScoredChunk, QueryOutput
|
|
36
|
+
store.py # SQLite: files/chunks/edges/meta; replace_edges normalizes src<dst
|
|
37
|
+
vectors.py # hnswlib wrapper; soft-delete via JSON sidecar; get_vectors for graph
|
|
38
|
+
discovery.py # file walk, root-level .gitignore, binary/size/junk-dir filters, xxh64
|
|
39
|
+
chunking.py # markdown/code/recursive splitters; byte-exact slices; tiny-fragment merge
|
|
40
|
+
graph.py # knn_edges + affected_ids (incremental edge maintenance)
|
|
41
|
+
traversal.py # propagate_heat: max-aggregation, query floor, frontier cap, trace
|
|
42
|
+
expansion.py # one LLM call -> variants + HyDE; NEVER raises (degrades to no-op)
|
|
43
|
+
fusion.py # Reciprocal Rank Fusion
|
|
44
|
+
scoring.py # min-max normalize + alpha/beta/gamma combine (renormalizes w/o rerank)
|
|
45
|
+
indexer.py # run_index: discover->hash->chunk->embed->HNSW+store->kNN edges
|
|
46
|
+
query.py # run_query: expansion->fan-out->RRF->traversal->rerank->combine; JSON serializers
|
|
47
|
+
eval.py # recall@5/@10 + MRR over file-level ranking, injected query_fn
|
|
48
|
+
providers/ # base.py protocols (Embedder/Generator/Reranker); openai_compat.py;
|
|
49
|
+
# st_reranker.py; registry.py factories (env-var + api_key_env resolution)
|
|
50
|
+
cli/ # thin shells only: app.py (init/status/config + registration),
|
|
51
|
+
# pipeline.py (index/query), inspect_cmd.py, eval_cmd.py, output.py
|
|
52
|
+
tests/ # 126 tests; mocked HTTP (respx), FakeEmbedder integration tests, no live network
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Flows
|
|
56
|
+
|
|
57
|
+
**Index** (`ragx-cli index [--changed]`): discover files (include/exclude globs, root .gitignore,
|
|
58
|
+
skip node_modules/.venv/hidden/binaries/>2MB) → xxhash diff (`--changed` = incremental, default
|
|
59
|
+
= full rebuild) → chunk (~800 tok target, chunks are exact byte slices, sub-100-char fragments
|
|
60
|
+
merge into neighbors) → embed batched → HNSW add → kNN edges per new chunk (k=8, cos ≥ 0.55)
|
|
61
|
+
+ recompute edge lists of pre-existing neighbors that appear in new lists.
|
|
62
|
+
|
|
63
|
+
**Query** (`ragx-cli query "..."`): optional expansion (1 LLM call, strict-JSON parse, graceful
|
|
64
|
+
no-op on failure) → embed all variants, HNSW top-20 each → RRF merge = seeds → heat propagation
|
|
65
|
+
(2 hops, decay 0.5, heat = max not sum, neighbor admitted only if cos(query) ≥ 0.35 floor,
|
|
66
|
+
frontier ≤ 150) → cross-encoder rerank of top-100 shortlist → final =
|
|
67
|
+
0.6·rerank + 0.25·heat + 0.15·vector (each min-max normalized; weights renormalize when a
|
|
68
|
+
stage is off). `--no-expand/--no-graph/--no-rerank` skip stages; `--files-only` ranks files
|
|
69
|
+
by sum of top-3 chunk scores; `--explain` attaches seed→parent→edge_weight→hop traces.
|
|
70
|
+
|
|
71
|
+
## State & benchmarks (as of 2026-07-10)
|
|
72
|
+
|
|
73
|
+
Phases 0–3 of the plan are DONE and validated. Eval corpus: Albert's wiki
|
|
74
|
+
(`~/projects/alber70g/notes/wiki`, .md only, 636 files → 1,323 chunks → 5,246 edges); labeled
|
|
75
|
+
queries at `<corpus>/.ragx/queries.jsonl` (18, EN+NL). Results (recall@5 / recall@10 / MRR):
|
|
76
|
+
baseline .833/.833/.593 · graph-only .778/.833/.522 · graph+rerank .759/**.889**/.568 ·
|
|
77
|
+
full .759/**.889**/**.613**. Key finding: graph-only HURTS precision (near-duplicate neighbors
|
|
78
|
+
displace direct hits — parameter sweeps plateau below baseline MRR; corpus property, not a
|
|
79
|
+
tuning miss), but graph+rerank together deliver the recall win — one Dutch query's target is
|
|
80
|
+
unreachable by vector search or rerank-alone, surfaced only via a hop-1 edge then reranked
|
|
81
|
+
19→4. Ship graph+rerank together; `--no-graph --no-rerank` is the explicit fast mode.
|
|
82
|
+
|
|
83
|
+
## Gotchas
|
|
84
|
+
|
|
85
|
+
- Ornith (`mlx-community/Ornith-1.0-35B-3bit`, Albert's required expansion LLM) is a
|
|
86
|
+
*reasoning* model: answers land in `content` only after thinking; expansion uses
|
|
87
|
+
max_tokens=4096 and takes ~40 s/call. Don't lower it.
|
|
88
|
+
- Changing `embeddings.model` invalidates the index; run_query fails loud on manifest
|
|
89
|
+
mismatch; full `ragx-cli index` rebuilds.
|
|
90
|
+
- Chunk ids are SQLite AUTOINCREMENT rowids AND hnswlib labels — never reused after delete.
|
|
91
|
+
hnswlib can't enumerate deletions, hence the `vectors.hnsw.meta.json` sidecar.
|
|
92
|
+
- `Config.set` rejects keys not in `DEFAULTS` — add new config keys to DEFAULTS first.
|
|
93
|
+
- Tests must not hit the network; one provider test self-skips when sentence-transformers
|
|
94
|
+
is installed. Pyright import errors in editors = interpreter not set to `.venv`.
|
|
95
|
+
- When running ragx against the wiki corpus: `cd` there, then
|
|
96
|
+
`uv run --project /Users/albert/projects/alber70g/ragx ragx-cli …` — and NEVER `git add`/commit
|
|
97
|
+
from that directory (it's Albert's personal notes repo; `.ragx/` is gitignored there).
|
|
98
|
+
|
|
99
|
+
## What's next / deferred
|
|
100
|
+
|
|
101
|
+
- Phase 4 (plan §5): Leiden communities over the edge list, `query --global`.
|
|
102
|
+
- Phase 5: MCP server as a second thin shell over `ragx.core` (split already enforced).
|
|
103
|
+
- Temporal weighting: full decided spec in `docs/feature-temporal-weighting.md` — opt-in
|
|
104
|
+
`--since/--until/--temporal recent|oldest`, date cascade filename/frontmatter → git → mtime,
|
|
105
|
+
schema v2 migration. Build only after it can be measured against the eval baseline.
|
|
106
|
+
- README packaging claims (`uvx ragx-cli`; PyPI name ragx-cli, plain `ragx` is name-blocked) are aspirational — not yet published to PyPI.
|
|
107
|
+
|
|
108
|
+
## Dev loop
|
|
109
|
+
|
|
110
|
+
`uv sync --group dev --extra rerank` · `uv run pytest -q` (126 pass, ~5 s) ·
|
|
111
|
+
`uv run ruff check src tests` · file soft cap ~150 lines · expected failures raise `RagxError`
|
|
112
|
+
(CLI maps to exit 2). Live smoke: LM Studio must be running with the configured embedding model.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# ragx module contracts — Phase 2–3 build
|
|
2
|
+
|
|
3
|
+
Same ground rules as CONTRACTS.md (read its "Global rules" and "Report protocol" — they apply
|
|
4
|
+
verbatim). Own ONLY your listed files; never modify shared/core files or other modules' files.
|
|
5
|
+
Shared APIs you may rely on (already implemented — read them first):
|
|
6
|
+
`core/models.py`, `core/errors.py`, `core/config.py`, `core/store.py` (esp. `neighbors`,
|
|
7
|
+
`replace_edges`), `core/vectors.py` (esp. `search`, `get_vectors`), `core/query.py`
|
|
8
|
+
(`QueryOptions`, `QueryOutput`, `run_query`, `to_files_json`), `providers/base.py`,
|
|
9
|
+
`cli/output.py`. NOTE: `cli/app.py` wiring is integration-owned — CLI modules expose
|
|
10
|
+
`register(app)` and their tests build a fresh `typer.Typer()` and call `register` themselves.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Module F — kNN edge construction
|
|
15
|
+
|
|
16
|
+
**Files:** `src/ragx/core/graph.py`, `tests/test_graph.py`
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
def knn_edges(index: VectorIndex, ids: Sequence[int], k: int, min_sim: float) -> dict[int, list[tuple[int, float]]]:
|
|
20
|
+
# for each id: search its own stored vector (index.get_vectors) for k+1 hits,
|
|
21
|
+
# drop self, keep sim >= min_sim, cap at k. Returns id -> [(neighbor_id, sim), ...] sim-desc.
|
|
22
|
+
def affected_ids(new_edges: dict[int, list[tuple[int, float]]]) -> set[int]:
|
|
23
|
+
# every neighbor id referenced in new_edges values, minus new_edges' own keys —
|
|
24
|
+
# the incremental-maintenance set whose edge lists the indexer must recompute.
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Pure functions over VectorIndex; no Store access. Batch `get_vectors` in one call.
|
|
28
|
+
Tests: constructed unit vectors with known cosines — self excluded, min_sim filter, k cap,
|
|
29
|
+
sim-desc order, affected_ids correctness, empty ids -> {}.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Module G — heat-propagation traversal
|
|
34
|
+
|
|
35
|
+
**Files:** `src/ragx/core/traversal.py`, `tests/test_traversal.py`
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
@dataclass
|
|
39
|
+
class TraversalResult:
|
|
40
|
+
heat: dict[int, float] # node -> final heat
|
|
41
|
+
trace: dict[int, dict] # node -> {"seed": int, "parent": int | None, "edge_weight": float, "hop": int}
|
|
42
|
+
|
|
43
|
+
def propagate_heat(
|
|
44
|
+
seeds: dict[int, float],
|
|
45
|
+
neighbors_fn: Callable[[int], list[tuple[int, float]]],
|
|
46
|
+
query_sim_fn: Callable[[Sequence[int]], dict[int, float]], # BATCH: ids -> cosine vs query
|
|
47
|
+
*, hops: int = 2, decay: float = 0.5, query_floor: float = 0.35, max_frontier: int = 150,
|
|
48
|
+
) -> TraversalResult:
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Semantics (exact):
|
|
52
|
+
- Init: every seed gets heat = its seed score, trace {"seed": itself, "parent": None,
|
|
53
|
+
"edge_weight": 0.0, "hop": 0}. Seeds are exempt from the query floor.
|
|
54
|
+
- For hop h = 1..hops: frontier = nodes whose heat was set/improved in the previous hop
|
|
55
|
+
(hop 1: the seeds). For each frontier node u and each (v, w) from neighbors_fn(u):
|
|
56
|
+
contribution = heat[u] * w * decay. A non-seed v is admissible only if its query similarity
|
|
57
|
+
>= query_floor — batch query_sim_fn over all first-seen candidates of the hop, cache results.
|
|
58
|
+
Aggregation is MAX, not sum: heat[v] = max(existing, contribution); on improvement, v's trace
|
|
59
|
+
becomes {"seed": trace[u]["seed"], "parent": u, "edge_weight": w, "hop": h}.
|
|
60
|
+
- Frontier cap: after each hop keep only the top `max_frontier` improved nodes by heat as the
|
|
61
|
+
next frontier (others keep their heat but don't propagate). Ties break by lower node id.
|
|
62
|
+
- A seed's heat never decreases below its seed score. hops=0 -> seeds only.
|
|
63
|
+
- Deterministic for identical inputs; no randomness.
|
|
64
|
+
|
|
65
|
+
Tests (hand-built adjacency dicts, no Store): max-not-sum at a hub node; per-hop decay values
|
|
66
|
+
exact (seed 1.0, w 0.8 -> hop1 0.4, hop2 chain 0.16); query-floor exclusion (and that an excluded
|
|
67
|
+
node also doesn't relay); frontier cap; trace parent-chain always reaches a seed; hops=0.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Module H — expansion, RRF fusion, score combination
|
|
72
|
+
|
|
73
|
+
**Files:** `src/ragx/core/expansion.py`, `src/ragx/core/fusion.py`, `src/ragx/core/scoring.py`,
|
|
74
|
+
`tests/test_expansion.py`, `tests/test_fusion.py`
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
# expansion.py
|
|
78
|
+
@dataclass
|
|
79
|
+
class Expansion:
|
|
80
|
+
variants: list[str] # reformulations/sub-queries, excludes the original
|
|
81
|
+
hyde: str | None
|
|
82
|
+
|
|
83
|
+
def expand_query(gen: Generator, query: str, *, variants: int = 3, hyde: bool = True) -> Expansion:
|
|
84
|
+
# ONE gen.generate() call. Prompt demands strict JSON: {"queries": [...], "hyde": "..."}
|
|
85
|
+
# (omit hyde from the prompt when hyde=False). Parse defensively: strip markdown fences,
|
|
86
|
+
# take first '{' .. last '}'. On ANY failure (generator exception, bad JSON, wrong shape):
|
|
87
|
+
# log a warning to stderr and return Expansion([], None) — NEVER raise. Clamp to `variants`
|
|
88
|
+
# items; drop empty strings and case-insensitive duplicates of the original query.
|
|
89
|
+
|
|
90
|
+
# fusion.py
|
|
91
|
+
def rrf(rankings: Sequence[Sequence[int]], k: int = 60) -> dict[int, float]:
|
|
92
|
+
# Reciprocal Rank Fusion: score(d) = sum over rankings containing d of 1/(k + rank), rank 1-based.
|
|
93
|
+
|
|
94
|
+
# scoring.py
|
|
95
|
+
def normalize(scores: dict[int, float]) -> dict[int, float]:
|
|
96
|
+
# min-max to [0,1]; empty -> {}; all-equal -> all 1.0
|
|
97
|
+
def combine(candidates: Iterable[int], vector: dict[int, float], heat: dict[int, float],
|
|
98
|
+
rerank: dict[int, float] | None, *, alpha: float, beta: float, gamma: float) -> dict[int, float]:
|
|
99
|
+
# missing candidate scores are 0.0 raw; each component min-max normalized over candidates first.
|
|
100
|
+
# with rerank: alpha*r + beta*h + gamma*v
|
|
101
|
+
# without rerank: weights renormalized -> (beta/(beta+gamma))*h + (gamma/(beta+gamma))*v
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Tests: expansion with a mock Generator returning valid JSON / fenced JSON / garbage / raising;
|
|
105
|
+
rrf hand-computed values and stable ordering; normalize edge cases; combine with and without
|
|
106
|
+
rerank (weight renormalization exact).
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Module I — eval harness
|
|
111
|
+
|
|
112
|
+
**Files:** `src/ragx/core/eval.py`, `src/ragx/cli/eval_cmd.py`, `tests/test_eval.py`
|
|
113
|
+
|
|
114
|
+
`queries.jsonl`: one JSON object per line: `{"query": str, "relevant_files": [str, ...]}`
|
|
115
|
+
(paths relative to corpus root). Blank lines skipped; malformed line -> RagxError naming the line number.
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# eval.py — decoupled from the pipeline via an injected callable
|
|
119
|
+
QueryFn = Callable[[str, QueryOptions], QueryOutput]
|
|
120
|
+
|
|
121
|
+
def load_queries(path: Path) -> list[dict]
|
|
122
|
+
def evaluate(queries: list[dict], configs: list[tuple[str, QueryOptions]], query_fn: QueryFn,
|
|
123
|
+
*, top: int = 10) -> dict:
|
|
124
|
+
# For each config and query: out = query_fn(query, opts); rank FILES via
|
|
125
|
+
# ragx.core.query.to_files_json(out)["files"] order. Metrics per config, averaged over queries:
|
|
126
|
+
# recall_at_5, recall_at_10 (fraction of relevant_files present in top k files)
|
|
127
|
+
# mrr (reciprocal rank of the FIRST relevant file, 0 if absent)
|
|
128
|
+
# Returns {"schema": "ragx.eval.v1", "top": top, "query_count": n,
|
|
129
|
+
# "configs": [{"name", "recall_at_5", "recall_at_10", "mrr"}, ...]}
|
|
130
|
+
# plus per-query detail under "queries": [{"query", "per_config": {name: {"hit_ranks": [...]}}}]
|
|
131
|
+
|
|
132
|
+
# eval_cmd.py
|
|
133
|
+
def register(app: typer.Typer) -> None: ...
|
|
134
|
+
# ragx eval QUERIES_FILE [--json] [--top N] [--configs csv]
|
|
135
|
+
# built-in configs: baseline (expand/graph/rerank all off), graph (only graph on),
|
|
136
|
+
# full (all on). Default: run all three.
|
|
137
|
+
# Constructs QueryOptions accordingly and query_fn from run_query + make_embedder(cfg) —
|
|
138
|
+
# keep this thin; integration may extend it. Human output: aligned table of config x metrics.
|
|
139
|
+
# Exit codes: 0 ok, 2 on missing/malformed file.
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Tests: metric math against a fake query_fn with canned QueryOutput fixtures (verify recall@5/10
|
|
143
|
+
and MRR by hand-computed values); load_queries error cases; CLI registered on a fresh Typer with
|
|
144
|
+
a monkeypatched evaluate/query_fn (no embeddings, no network).
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Module J — inspect commands
|
|
149
|
+
|
|
150
|
+
**Files:** `src/ragx/cli/inspect_cmd.py`, `tests/test_inspect.py`
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
def register(app: typer.Typer) -> None: # app.add_typer(inspect_app, name="inspect")
|
|
154
|
+
# ragx inspect chunk ID [--json] -> chunk text, file, line/byte range + its edges
|
|
155
|
+
# (neighbor id, weight, neighbor's file path)
|
|
156
|
+
# ragx inspect file PATH [--json] -> file record + its chunks (id, line range, first 80 chars)
|
|
157
|
+
# ragx inspect neighbors ID [--json] -> store.neighbors(ID) enriched with each neighbor's file/lines
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
All read-only over Store (find root via require_root; no embeddings, no vectors file needed).
|
|
161
|
+
JSON schemas: `ragx.inspect.chunk.v1`, `ragx.inspect.file.v1`, `ragx.inspect.neighbors.v1`.
|
|
162
|
+
Exit codes: unknown chunk id or file path -> stderr message, exit 2; entity exists but has no
|
|
163
|
+
edges/chunks -> exit 1 (success-but-empty), matching the CLI conventions.
|
|
164
|
+
Tests: CliRunner on a fresh Typer + `register`; build a real Store in tmp_path with a few files/
|
|
165
|
+
chunks/edges directly (no embedding provider); cover all three subcommands, --json shape,
|
|
166
|
+
exit codes 0/1/2.
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# ragx module contracts (Phase 0–1 build)
|
|
2
|
+
|
|
3
|
+
Ground truth for the module-parallel build. Each module owns exactly the files listed for it.
|
|
4
|
+
**Do not create or edit files outside your module** — shared types live in `core/models.py`,
|
|
5
|
+
provider protocols in `providers/base.py`, config in `core/config.py`, errors in `core/errors.py`
|
|
6
|
+
(all already written; read them first, import from them, never modify them).
|
|
7
|
+
|
|
8
|
+
Global rules:
|
|
9
|
+
|
|
10
|
+
- Python 3.12, `uv sync --group dev` to set up, `uv run pytest tests/<your tests> -q` must pass,
|
|
11
|
+
`uv run ruff check <your files>` must be clean.
|
|
12
|
+
- Soft cap ~150 lines per file; split if you blow past it.
|
|
13
|
+
- No live network in tests (mock HTTP with `respx`; models/backends must not be required).
|
|
14
|
+
- Raise `RagxError` (or a subclass) for expected failures; let bugs raise naturally.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Module A — SQLite store
|
|
19
|
+
|
|
20
|
+
**Files:** `src/ragx/core/store.py`, `tests/test_store.py`
|
|
21
|
+
|
|
22
|
+
Single-file SQLite store for files, chunks, edges, and a key-value manifest.
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
class Store:
|
|
26
|
+
def __init__(self, path: Path): ... # open/create, apply schema, WAL mode, foreign_keys ON
|
|
27
|
+
def close(self) -> None: ... # also __enter__/__exit__
|
|
28
|
+
# manifest (embedding model, dim, schema info)
|
|
29
|
+
def get_meta(self, key: str) -> str | None: ...
|
|
30
|
+
def set_meta(self, key: str, value: str) -> None: ...
|
|
31
|
+
# files
|
|
32
|
+
def get_file_hashes(self) -> dict[str, str]: ... # path -> content_hash
|
|
33
|
+
def upsert_file(self, rec: FileRecord) -> None: ...
|
|
34
|
+
def delete_file(self, path: str) -> list[int]: ... # cascades chunks + incident edges; returns removed chunk ids
|
|
35
|
+
def file_count(self) -> int: ...
|
|
36
|
+
# chunks
|
|
37
|
+
def insert_chunks(self, file_path: str, drafts: Sequence[ChunkDraft]) -> list[int]: ...
|
|
38
|
+
def get_chunks(self, ids: Sequence[int]) -> list[Chunk]: ... # input order preserved; unknown ids skipped
|
|
39
|
+
def chunk_ids_for_file(self, path: str) -> list[int]: ...
|
|
40
|
+
def all_chunk_ids(self) -> list[int]: ...
|
|
41
|
+
def chunk_count(self) -> int: ...
|
|
42
|
+
# edges (undirected; store normalized src < dst)
|
|
43
|
+
def replace_edges(self, src: int, neighbors: Sequence[tuple[int, float]]) -> None: ...
|
|
44
|
+
def neighbors(self, chunk_id: int) -> list[tuple[int, float]]: ... # both directions, weight desc
|
|
45
|
+
def delete_edges_incident(self, ids: Sequence[int]) -> None: ...
|
|
46
|
+
def edge_count(self) -> int: ...
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Schema v1 (set `PRAGMA user_version = 1`):
|
|
50
|
+
|
|
51
|
+
```sql
|
|
52
|
+
CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
53
|
+
CREATE TABLE files(path TEXT PRIMARY KEY, content_hash TEXT NOT NULL,
|
|
54
|
+
mtime REAL NOT NULL, chunk_count INTEGER NOT NULL);
|
|
55
|
+
CREATE TABLE chunks(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE,
|
|
57
|
+
text TEXT NOT NULL, byte_start INTEGER NOT NULL, byte_end INTEGER NOT NULL,
|
|
58
|
+
line_start INTEGER NOT NULL, line_end INTEGER NOT NULL);
|
|
59
|
+
CREATE INDEX chunks_file ON chunks(file_path);
|
|
60
|
+
CREATE TABLE edges(src INTEGER NOT NULL, dst INTEGER NOT NULL, weight REAL NOT NULL,
|
|
61
|
+
PRIMARY KEY(src, dst)) WITHOUT ROWID;
|
|
62
|
+
CREATE INDEX edges_dst ON edges(dst);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`AUTOINCREMENT` is deliberate: chunk ids are HNSW labels and must never be reused after deletion.
|
|
66
|
+
`insert_chunks` requires the file row to exist first (upsert_file before insert_chunks).
|
|
67
|
+
|
|
68
|
+
Tests must cover: roundtrip of every entity; delete_file cascade removes chunks and edges touching
|
|
69
|
+
them (both src and dst side); replace_edges normalization + dedup (inserting (5,3,w) then querying
|
|
70
|
+
neighbors(3) and neighbors(5) both work); id monotonicity after delete + reinsert; meta roundtrip.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Module B — Providers
|
|
75
|
+
|
|
76
|
+
**Files:** `src/ragx/providers/openai_compat.py`, `src/ragx/providers/st_reranker.py`,
|
|
77
|
+
`src/ragx/providers/registry.py`, `tests/test_providers.py`
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
# openai_compat.py — used for LM Studio (localhost:1234/v1), Ollama's /v1, and OpenAI itself
|
|
81
|
+
class OpenAICompatEmbedder: # implements providers.base.Embedder
|
|
82
|
+
def __init__(self, base_url: str, model: str, doc_prefix: str = "", query_prefix: str = "",
|
|
83
|
+
api_key: str | None = None, batch_size: int = 32, timeout: float = 120.0): ...
|
|
84
|
+
# POST {base_url}/embeddings with {"model", "input": [prefixed texts]}
|
|
85
|
+
# batches of batch_size; 3 retries with exponential backoff on 5xx/connect errors
|
|
86
|
+
# dimension(): lazily embeds one probe string, caches the length
|
|
87
|
+
# empty input list -> [] without any HTTP call
|
|
88
|
+
|
|
89
|
+
class OpenAICompatGenerator: # implements providers.base.Generator
|
|
90
|
+
def __init__(self, base_url: str, model: str, api_key: str | None = None,
|
|
91
|
+
timeout: float = 300.0): ...
|
|
92
|
+
# POST {base_url}/chat/completions, messages=[system, user], temperature=0.3
|
|
93
|
+
# returns choices[0].message.content
|
|
94
|
+
|
|
95
|
+
# st_reranker.py — lazy `import sentence_transformers` inside __init__;
|
|
96
|
+
# ImportError -> RagxError("... install with: uv pip install 'ragx[rerank]'")
|
|
97
|
+
class STReranker: # implements providers.base.Reranker
|
|
98
|
+
def __init__(self, model: str): ...
|
|
99
|
+
def score(self, query: str, texts: Sequence[str]) -> list[float]: ... # CrossEncoder.predict
|
|
100
|
+
|
|
101
|
+
# registry.py — factories reading Config (see core/config.py DEFAULTS for keys)
|
|
102
|
+
def make_embedder(cfg: Config) -> Embedder: ...
|
|
103
|
+
# embeddings.provider: "openai" -> OpenAICompatEmbedder with [embeddings] settings
|
|
104
|
+
# "ollama" -> same class, base_url defaulting to http://localhost:11434/v1
|
|
105
|
+
# unknown provider -> RagxError
|
|
106
|
+
def make_generator(cfg: Config) -> Generator | None: ... # None when expansion.enabled is false
|
|
107
|
+
def make_reranker(cfg: Config) -> Reranker | None: ...
|
|
108
|
+
# None when rerank.enabled is false; on RagxError from missing extra: log warning to stderr, return None
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Tests: respx-mocked embeddings (prefix application, batching — e.g. batch_size=2 with 5 inputs →
|
|
112
|
+
3 requests; order preserved across batches), retry on a 500 then success, dimension() caching
|
|
113
|
+
(one probe call), generator payload shape, registry dispatch incl. disabled → None. No real network.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Module C — Discovery + chunking
|
|
118
|
+
|
|
119
|
+
**Files:** `src/ragx/core/discovery.py`, `src/ragx/core/chunking.py`,
|
|
120
|
+
`tests/test_discovery.py`, `tests/test_chunking.py`
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
# discovery.py
|
|
124
|
+
def discover_files(root: Path, include: list[str], exclude: list[str],
|
|
125
|
+
respect_gitignore: bool = True) -> list[str]: ...
|
|
126
|
+
# sorted relative POSIX paths; include/exclude are gitwildmatch globs (use pathspec)
|
|
127
|
+
# always skipped: .ragx/, .git/, hidden dirs, binaries (NUL byte in first 8KB), files > 2MB
|
|
128
|
+
# gitignore: root-level .gitignore only (MVP; document the limitation)
|
|
129
|
+
def hash_file(path: Path) -> str: ... # xxhash.xxh64 hexdigest of file bytes
|
|
130
|
+
|
|
131
|
+
# chunking.py
|
|
132
|
+
def chunk_text(text: str, path: str, size_tokens: int = 800, overlap: float = 0.15) -> list[ChunkDraft]: ...
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Chunking rules:
|
|
136
|
+
|
|
137
|
+
- Token estimate: 1 token ≈ 4 chars → target = size_tokens*4 chars, hard max 1.5× target.
|
|
138
|
+
- Dispatch on extension: `.md`/`.markdown` → markdown splitter; code
|
|
139
|
+
(`.py .ts .js .tsx .jsx .go .rs .java .rb`) → code splitter; anything else → recursive splitter.
|
|
140
|
+
- Markdown: split at heading lines (`^#{1,6} `), heading stays with its section; adjacent small
|
|
141
|
+
sections merge until ≈target; oversized sections fall through to the recursive splitter.
|
|
142
|
+
- Code: split at top-level `def`/`class`/`function`/`fn`/`func` boundaries (regex, no tree-sitter
|
|
143
|
+
in MVP); oversized pieces fall through to the recursive splitter.
|
|
144
|
+
- Recursive: split by separators ["\n\n", "\n", ". ", " "] to fit max, with `overlap` fraction of
|
|
145
|
+
the previous chunk's tail prepended as range overlap.
|
|
146
|
+
- **Invariant (test this):** every draft's `text` == `text_bytes[byte_start:byte_end].decode("utf-8")`
|
|
147
|
+
where text_bytes = the original file's utf-8 bytes — chunks are exact slices of the source
|
|
148
|
+
(overlap = overlapping ranges, never synthesized text). line_start/line_end are 1-based inclusive.
|
|
149
|
+
- Empty/whitespace-only files → []. Never emit empty-text drafts.
|
|
150
|
+
|
|
151
|
+
Tests: the slice invariant on multi-byte (emoji/accented) content; markdown heading split + small-
|
|
152
|
+
section merging; oversized-section fallback; overlap ranges actually overlap; discovery honors
|
|
153
|
+
include/exclude/.gitignore, skips binaries and .ragx.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Module D — HNSW vector index
|
|
158
|
+
|
|
159
|
+
**Files:** `src/ragx/core/vectors.py`, `tests/test_vectors.py`
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
class VectorIndex:
|
|
163
|
+
@classmethod
|
|
164
|
+
def create(cls, path: Path, dim: int, capacity: int = 2048) -> VectorIndex: ...
|
|
165
|
+
@classmethod
|
|
166
|
+
def load(cls, path: Path, dim: int) -> VectorIndex: ... # missing file -> RagxError
|
|
167
|
+
def add(self, ids: Sequence[int], vectors: Sequence[Sequence[float]]) -> None: ...
|
|
168
|
+
def search(self, vector: Sequence[float], k: int) -> list[tuple[int, float]]: ...
|
|
169
|
+
def mark_deleted(self, ids: Sequence[int]) -> None: ...
|
|
170
|
+
def save(self) -> None: ...
|
|
171
|
+
@property
|
|
172
|
+
def count(self) -> int: ... # active (non-deleted) element count
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
- hnswlib `space="cosine"`, M=16, ef_construction=200, query-time ef = max(100, 2k).
|
|
176
|
+
- `search` returns (id, cosine_similarity) sorted desc; similarity = 1 − hnswlib distance,
|
|
177
|
+
clamped to [0,1]; k silently clamped to active count; empty index → [].
|
|
178
|
+
- `add` auto-resizes (double capacity) when full. ids are arbitrary non-reused ints (SQLite rowids).
|
|
179
|
+
- Deleted elements never appear in results (hnswlib `mark_deleted`); deleting unknown ids is a no-op.
|
|
180
|
+
- save/load roundtrip preserves search results and deletions.
|
|
181
|
+
|
|
182
|
+
Tests: nearest-neighbor sanity on constructed vectors (a query nearest to its own cluster),
|
|
183
|
+
resize past initial capacity, delete-then-search exclusion, save/load roundtrip, empty-index search.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Module E — CLI shell
|
|
188
|
+
|
|
189
|
+
**Files:** `src/ragx/cli/app.py`, `src/ragx/cli/output.py`, `tests/test_cli.py`
|
|
190
|
+
|
|
191
|
+
typer app exposing `init`, `status`, `config`. (`index`/`query` are wired in a later integration
|
|
192
|
+
pass — the app must be importable and testable without them.)
|
|
193
|
+
|
|
194
|
+
Conventions (these ARE the product for agent callers — be exact):
|
|
195
|
+
|
|
196
|
+
- `--json` on `status`: emit exactly one JSON document to stdout, nothing else on stdout.
|
|
197
|
+
- All logging/diagnostics → stderr (configure `logging` to stderr in `main()`).
|
|
198
|
+
- Exit codes: 0 = success with results, 1 = success but empty result, 2 = error.
|
|
199
|
+
Map `RagxError` → stderr message + exit 2 (no traceback); unexpected exceptions may traceback.
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
# output.py
|
|
203
|
+
def emit_json(doc: dict) -> None: ... # json.dumps to stdout with trailing newline
|
|
204
|
+
def fail(msg: str, code: int = 2) -> NoReturn: ...
|
|
205
|
+
|
|
206
|
+
# app.py
|
|
207
|
+
app = typer.Typer(...)
|
|
208
|
+
# ragx init [path] -> write_default_config at path (default cwd); error (exit 2) if .ragx exists;
|
|
209
|
+
# prints created config path
|
|
210
|
+
# ragx status [--json] -> root path, embedding model/provider, counts (files/chunks/edges) read via
|
|
211
|
+
# Store if index.db exists else zeros, schema "ragx.status.v1" in JSON mode
|
|
212
|
+
# ragx config get KEY / ragx config set KEY VALUE -> Config.get / Config.set + save
|
|
213
|
+
def main() -> None: ... # entry point declared in pyproject [project.scripts]
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Module E may import Store (module A) **only** inside `status` behind a
|
|
217
|
+
`try/except ImportError` fallback to zeros, so the module builds independently; the import will
|
|
218
|
+
resolve after integration. Tests: use `typer.testing.CliRunner` + tmp_path; cover init (fresh +
|
|
219
|
+
already-initialized), config get/set roundtrip persisted to disk, status --json shape + stdout
|
|
220
|
+
purity (stdout parses as JSON), exit codes incl. `config get bogus.key` → 2.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Report protocol (all modules)
|
|
225
|
+
|
|
226
|
+
When done: `git add -A && git commit -m "<module>: <summary>"` in your worktree, then report:
|
|
227
|
+
branch (`git rev-parse --abbrev-ref HEAD`), commit SHA, pytest pass/fail counts, ruff status,
|
|
228
|
+
and any deviations from this contract. On any blocker: STOP — report the exact error output and
|
|
229
|
+
what you attempted instead of improvising around the contract.
|
ragx_cli-0.1.0/LICENSE
ADDED
|
@@ -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.
|