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/core/config.py ADDED
@@ -0,0 +1,130 @@
1
+ """Config: .ragx/config.toml is the single source of truth. Written by `init`, read everywhere."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import tomli_w
10
+
11
+ from ragx.core.errors import NotInitializedError, RagxError
12
+
13
+ RAGX_DIR = ".ragx"
14
+
15
+ DEFAULTS: dict[str, dict[str, Any]] = {
16
+ "corpus": {"include": ["**/*"], "exclude": [], "respect_gitignore": True},
17
+ "chunking": {"size_tokens": 800, "overlap": 0.15},
18
+ "graph": {"k": 8, "min_edge_sim": 0.55},
19
+ "traversal": {"hops": 2, "decay": 0.5, "query_floor": 0.35, "max_frontier": 150},
20
+ "fusion": {"rrf_k": 60, "per_query_top": 20},
21
+ "scoring": {"alpha_rerank": 0.6, "beta_heat": 0.25, "gamma_vector": 0.15},
22
+ "embeddings": {
23
+ "provider": "openai",
24
+ "base_url": "http://localhost:1234/v1",
25
+ "model": "text-embedding-nomic-embed-text-v1.5@q4_k_m",
26
+ "doc_prefix": "search_document: ",
27
+ "query_prefix": "search_query: ",
28
+ "batch_size": 32,
29
+ "api_key_env": "", # NAME of an env var holding the API key; empty = no auth header
30
+ },
31
+ "expansion": {
32
+ "enabled": True,
33
+ "provider": "openai",
34
+ "base_url": "http://localhost:1234/v1",
35
+ "model": "mlx-community/Ornith-1.0-35B-3bit",
36
+ "variants": 3,
37
+ "hyde": True,
38
+ "api_key_env": "",
39
+ },
40
+ "rerank": {"enabled": True, "provider": "sentence-transformers", "model": "BAAI/bge-reranker-v2-m3"},
41
+ "query": {"top": 8, "max_chunk_chars": 1200},
42
+ }
43
+
44
+
45
+ def find_root(start: Path | None = None) -> Path | None:
46
+ """Walk up from `start` (default cwd) looking for a .ragx/ directory."""
47
+ cur = (start or Path.cwd()).resolve()
48
+ for p in [cur, *cur.parents]:
49
+ if (p / RAGX_DIR).is_dir():
50
+ return p
51
+ return None
52
+
53
+
54
+ def require_root(start: Path | None = None) -> Path:
55
+ root = find_root(start)
56
+ if root is None:
57
+ raise NotInitializedError("no .ragx/ found — run `ragx init` first")
58
+ return root
59
+
60
+
61
+ def config_path(root: Path) -> Path:
62
+ return root / RAGX_DIR / "config.toml"
63
+
64
+
65
+ def db_path(root: Path) -> Path:
66
+ return root / RAGX_DIR / "index.db"
67
+
68
+
69
+ def vectors_path(root: Path) -> Path:
70
+ return root / RAGX_DIR / "vectors.hnsw"
71
+
72
+
73
+ def _deep_merge(base: dict, override: dict) -> dict:
74
+ out = {k: dict(v) for k, v in base.items()}
75
+ for section, values in override.items():
76
+ out.setdefault(section, {}).update(values)
77
+ return out
78
+
79
+
80
+ class Config:
81
+ """Two-level (section.key) config with defaults merged under the file's values."""
82
+
83
+ def __init__(self, data: dict[str, dict[str, Any]]):
84
+ self.data = data
85
+
86
+ @classmethod
87
+ def load(cls, root: Path) -> Config:
88
+ path = config_path(root)
89
+ file_data: dict = {}
90
+ if path.exists():
91
+ with path.open("rb") as f:
92
+ file_data = tomllib.load(f)
93
+ return cls(_deep_merge(DEFAULTS, file_data))
94
+
95
+ def get(self, dotted: str) -> Any:
96
+ section, _, key = dotted.partition(".")
97
+ if not key:
98
+ raise RagxError(f"config keys are 'section.key', got: {dotted!r}")
99
+ try:
100
+ return self.data[section][key]
101
+ except KeyError:
102
+ raise RagxError(f"unknown config key: {dotted!r}") from None
103
+
104
+ def set(self, dotted: str, value: Any) -> None:
105
+ section, _, key = dotted.partition(".")
106
+ if not key or section not in DEFAULTS or key not in DEFAULTS[section]:
107
+ raise RagxError(f"unknown config key: {dotted!r}")
108
+ current = DEFAULTS[section][key]
109
+ if isinstance(current, bool):
110
+ value = str(value).lower() in ("1", "true", "yes", "on")
111
+ elif isinstance(current, int) and not isinstance(current, bool):
112
+ value = int(value)
113
+ elif isinstance(current, float):
114
+ value = float(value)
115
+ elif isinstance(current, list) and isinstance(value, str):
116
+ value = [v.strip() for v in value.split(",") if v.strip()]
117
+ self.data.setdefault(section, {})[key] = value
118
+
119
+ def save(self, root: Path) -> None:
120
+ path = config_path(root)
121
+ path.parent.mkdir(parents=True, exist_ok=True)
122
+ with path.open("wb") as f:
123
+ tomli_w.dump(self.data, f)
124
+
125
+
126
+ def write_default_config(root: Path) -> Path:
127
+ """Create .ragx/ with a default config.toml. Used by `ragx init`."""
128
+ cfg = Config({k: dict(v) for k, v in DEFAULTS.items()})
129
+ cfg.save(root)
130
+ return config_path(root)
ragx/core/discovery.py ADDED
@@ -0,0 +1,79 @@
1
+ """File discovery: walk a corpus root, filter by globs/.gitignore, skip binaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pathspec
8
+ import xxhash
9
+
10
+ MAX_FILE_SIZE = 2 * 1024 * 1024
11
+ BINARY_SNIFF_BYTES = 8192
12
+ ALWAYS_SKIP_DIRS = {".ragx", ".git", "node_modules", "__pycache__", ".venv", "venv"}
13
+
14
+
15
+ def _is_binary(path: Path) -> bool:
16
+ with path.open("rb") as f:
17
+ chunk = f.read(BINARY_SNIFF_BYTES)
18
+ return b"\x00" in chunk
19
+
20
+
21
+ def _load_gitignore_spec(root: Path) -> pathspec.PathSpec | None:
22
+ gitignore = root / ".gitignore"
23
+ if not gitignore.is_file():
24
+ return None
25
+ lines = gitignore.read_text(encoding="utf-8", errors="ignore").splitlines()
26
+ return pathspec.PathSpec.from_lines("gitwildmatch", lines)
27
+
28
+
29
+ def discover_files(
30
+ root: Path,
31
+ include: list[str],
32
+ exclude: list[str],
33
+ respect_gitignore: bool = True,
34
+ ) -> list[str]:
35
+ """Return sorted relative POSIX paths of files matching include/exclude under root.
36
+
37
+ Always skips ALWAYS_SKIP_DIRS, hidden directories, binaries (NUL byte in first 8KB),
38
+ and files larger than 2MB. Only a root-level .gitignore is honored (MVP limitation).
39
+ """
40
+ include_spec = pathspec.PathSpec.from_lines("gitwildmatch", include)
41
+ exclude_spec = pathspec.PathSpec.from_lines("gitwildmatch", exclude) if exclude else None
42
+ gitignore_spec = _load_gitignore_spec(root) if respect_gitignore else None
43
+
44
+ results: list[str] = []
45
+ for dirpath, dirnames, filenames in root.walk():
46
+ dirnames[:] = [
47
+ d for d in dirnames if d not in ALWAYS_SKIP_DIRS and not d.startswith(".")
48
+ ]
49
+ for name in filenames:
50
+ path = dirpath / name
51
+ rel = path.relative_to(root).as_posix()
52
+
53
+ if not include_spec.match_file(rel):
54
+ continue
55
+ if exclude_spec is not None and exclude_spec.match_file(rel):
56
+ continue
57
+ if gitignore_spec is not None and gitignore_spec.match_file(rel):
58
+ continue
59
+
60
+ try:
61
+ if path.stat().st_size > MAX_FILE_SIZE:
62
+ continue
63
+ if _is_binary(path):
64
+ continue
65
+ except OSError:
66
+ continue
67
+
68
+ results.append(rel)
69
+
70
+ return sorted(results)
71
+
72
+
73
+ def hash_file(path: Path) -> str:
74
+ """xxhash.xxh64 hexdigest of the file's raw bytes."""
75
+ hasher = xxhash.xxh64()
76
+ with path.open("rb") as f:
77
+ for block in iter(lambda: f.read(65536), b""):
78
+ hasher.update(block)
79
+ return hasher.hexdigest()
ragx/core/errors.py ADDED
@@ -0,0 +1,10 @@
1
+ class RagxError(Exception):
2
+ """Base error for ragx. CLI maps this to exit code 2."""
3
+
4
+
5
+ class NotInitializedError(RagxError):
6
+ """No .ragx/ directory found at or above the working directory."""
7
+
8
+
9
+ class ManifestMismatchError(RagxError):
10
+ """Index was built with a different embedding model/dimension than configured."""
ragx/core/eval.py ADDED
@@ -0,0 +1,93 @@
1
+ """Evaluation harness: compare retrieval configs against a labeled queries.jsonl file.
2
+
3
+ See CONTRACTS-PHASE23.md Module I. Decoupled from the pipeline via an injected QueryFn.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+
12
+ from ragx.core.errors import RagxError
13
+ from ragx.core.query import QueryOptions, QueryOutput, to_files_json
14
+
15
+ QueryFn = Callable[[str, QueryOptions], QueryOutput]
16
+
17
+
18
+ def load_queries(path: Path) -> list[dict]:
19
+ """Parse `{"query": str, "relevant_files": [str, ...]}` per non-blank line."""
20
+ if not path.exists():
21
+ raise RagxError(f"queries file not found: {path}")
22
+ queries: list[dict] = []
23
+ for lineno, raw in enumerate(path.read_text().splitlines(), start=1):
24
+ line = raw.strip()
25
+ if not line:
26
+ continue
27
+ try:
28
+ obj = json.loads(line)
29
+ except json.JSONDecodeError as exc:
30
+ raise RagxError(f"malformed query on line {lineno}: {exc}") from exc
31
+ if not isinstance(obj, dict) or "query" not in obj or "relevant_files" not in obj:
32
+ raise RagxError(f"malformed query on line {lineno}: expected 'query' and 'relevant_files'")
33
+ queries.append(obj)
34
+ return queries
35
+
36
+
37
+ def _recall(ranked: list[str], relevant: set[str], k: int) -> float:
38
+ if not relevant:
39
+ return 0.0
40
+ hits = len(set(ranked[:k]) & relevant)
41
+ return hits / len(relevant)
42
+
43
+
44
+ def _hit_ranks(ranked: list[str], relevant: set[str]) -> list[int]:
45
+ return [i + 1 for i, f in enumerate(ranked) if f in relevant]
46
+
47
+
48
+ def evaluate(
49
+ queries: list[dict],
50
+ configs: list[tuple[str, QueryOptions]],
51
+ query_fn: QueryFn,
52
+ *,
53
+ top: int = 10,
54
+ ) -> dict:
55
+ """Run each config against each query; rank files via `to_files_json`, average metrics.
56
+
57
+ `top` bounds the ranked-files window that recall@5/@10/mrr are computed over.
58
+ """
59
+ totals = {name: {"recall_at_5": 0.0, "recall_at_10": 0.0, "mrr": 0.0} for name, _ in configs}
60
+ per_query: list[dict] = []
61
+ n = len(queries)
62
+
63
+ for q in queries:
64
+ relevant = set(q["relevant_files"])
65
+ per_config: dict[str, dict] = {}
66
+ for name, opts in configs:
67
+ out = query_fn(q["query"], opts)
68
+ ranked = [f["file"] for f in to_files_json(out)["files"]][:top]
69
+ hit_ranks = _hit_ranks(ranked, relevant)
70
+ mrr = 1.0 / hit_ranks[0] if hit_ranks else 0.0
71
+ totals[name]["recall_at_5"] += _recall(ranked, relevant, 5)
72
+ totals[name]["recall_at_10"] += _recall(ranked, relevant, 10)
73
+ totals[name]["mrr"] += mrr
74
+ per_config[name] = {"hit_ranks": hit_ranks}
75
+ per_query.append({"query": q["query"], "per_config": per_config})
76
+
77
+ configs_out = [
78
+ {
79
+ "name": name,
80
+ "recall_at_5": totals[name]["recall_at_5"] / n if n else 0.0,
81
+ "recall_at_10": totals[name]["recall_at_10"] / n if n else 0.0,
82
+ "mrr": totals[name]["mrr"] / n if n else 0.0,
83
+ }
84
+ for name, _ in configs
85
+ ]
86
+
87
+ return {
88
+ "schema": "ragx.eval.v1",
89
+ "top": top,
90
+ "query_count": n,
91
+ "configs": configs_out,
92
+ "queries": per_query,
93
+ }
ragx/core/expansion.py ADDED
@@ -0,0 +1,89 @@
1
+ """Query expansion: LLM-generated reformulations + a HyDE passage, one generate() call."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from dataclasses import dataclass
8
+
9
+ from ragx.providers.base import Generator
10
+
11
+ _SYSTEM = (
12
+ "You rewrite search queries for a code/document retrieval system. "
13
+ "Respond with strict JSON only, no markdown, no commentary."
14
+ )
15
+
16
+
17
+ @dataclass
18
+ class Expansion:
19
+ variants: list[str] # reformulations/sub-queries, excludes the original
20
+ hyde: str | None
21
+
22
+
23
+ def _build_prompt(query: str, variants: int, hyde: bool) -> str:
24
+ lines = [
25
+ f'Given the search query: "{query}"',
26
+ f"Produce up to {variants} alternative phrasings or related sub-queries "
27
+ "that would help retrieve relevant passages.",
28
+ ]
29
+ if hyde:
30
+ lines.append(
31
+ "Also produce a short hypothetical passage that would answer the query "
32
+ "(HyDE-style)."
33
+ )
34
+ schema = '{"queries": ["...", "..."], "hyde": "..."}'
35
+ else:
36
+ schema = '{"queries": ["...", "..."]}'
37
+ lines.append(f"Respond with JSON matching this exact schema: {schema}")
38
+ return "\n".join(lines)
39
+
40
+
41
+ def _extract_json(raw: str) -> dict:
42
+ text = raw.strip()
43
+ # strip markdown fences if present
44
+ if text.startswith("```"):
45
+ text = text.split("\n", 1)[1] if "\n" in text else text
46
+ if text.endswith("```"):
47
+ text = text.rsplit("```", 1)[0]
48
+ start = text.find("{")
49
+ end = text.rfind("}")
50
+ if start == -1 or end == -1 or end < start:
51
+ raise ValueError("no JSON object found")
52
+ return json.loads(text[start : end + 1])
53
+
54
+
55
+ def expand_query(gen: Generator, query: str, *, variants: int = 3, hyde: bool = True) -> Expansion:
56
+ empty = Expansion([], None)
57
+ try:
58
+ # generous budget: reasoning models spend tokens thinking before the JSON
59
+ raw = gen.generate(_SYSTEM, _build_prompt(query, variants, hyde), max_tokens=4096)
60
+ data = _extract_json(raw)
61
+ if not isinstance(data, dict) or "queries" not in data:
62
+ raise ValueError(f"unexpected JSON shape: {data!r}")
63
+ queries = data["queries"]
64
+ if not isinstance(queries, list):
65
+ raise ValueError(f"'queries' is not a list: {queries!r}")
66
+ except Exception as exc: # noqa: BLE001 - defensive per contract, never raise
67
+ print(f"warning: query expansion failed: {exc}", file=sys.stderr)
68
+ return empty
69
+
70
+ seen = {query.strip().lower()}
71
+ out: list[str] = []
72
+ for item in queries:
73
+ if not isinstance(item, str):
74
+ continue
75
+ cleaned = item.strip()
76
+ if not cleaned:
77
+ continue
78
+ key = cleaned.lower()
79
+ if key in seen:
80
+ continue
81
+ seen.add(key)
82
+ out.append(cleaned)
83
+ if len(out) >= variants:
84
+ break
85
+
86
+ hyde_text = data.get("hyde") if hyde and isinstance(data, dict) else None
87
+ if not isinstance(hyde_text, str) or not hyde_text.strip():
88
+ hyde_text = None
89
+ return Expansion(out, hyde_text)
ragx/core/fusion.py ADDED
@@ -0,0 +1,14 @@
1
+ """Reciprocal Rank Fusion over multiple rankings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+
8
+ def rrf(rankings: Sequence[Sequence[int]], k: int = 60) -> dict[int, float]:
9
+ """score(d) = sum over rankings containing d of 1/(k + rank), rank 1-based."""
10
+ scores: dict[int, float] = {}
11
+ for ranking in rankings:
12
+ for rank, doc_id in enumerate(ranking, start=1):
13
+ scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
14
+ return scores
ragx/core/graph.py ADDED
@@ -0,0 +1,37 @@
1
+ """kNN edge construction over a VectorIndex.
2
+
3
+ Pure functions: no Store access. Callers persist the resulting edges.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Sequence
9
+
10
+ from ragx.core.vectors import VectorIndex
11
+
12
+
13
+ def knn_edges(
14
+ index: VectorIndex, ids: Sequence[int], k: int, min_sim: float
15
+ ) -> dict[int, list[tuple[int, float]]]:
16
+ """For each id, search its own stored vector for k+1 hits, drop self,
17
+ keep sim >= min_sim, cap at k. Returns id -> [(neighbor_id, sim), ...] sim-desc.
18
+ """
19
+ if not ids:
20
+ return {}
21
+ vectors = index.get_vectors(ids)
22
+ result: dict[int, list[tuple[int, float]]] = {}
23
+ for chunk_id, vector in zip(ids, vectors):
24
+ hits = index.search(vector, k + 1)
25
+ neighbors = [
26
+ (nid, sim) for nid, sim in hits if nid != chunk_id and sim >= min_sim
27
+ ][:k]
28
+ result[chunk_id] = neighbors
29
+ return result
30
+
31
+
32
+ def affected_ids(new_edges: dict[int, list[tuple[int, float]]]) -> set[int]:
33
+ """Every neighbor id referenced in new_edges values, minus new_edges' own keys —
34
+ the incremental-maintenance set whose edge lists the indexer must recompute.
35
+ """
36
+ referenced = {nid for neighbors in new_edges.values() for nid, _ in neighbors}
37
+ return referenced - new_edges.keys()
ragx/core/indexer.py ADDED
@@ -0,0 +1,118 @@
1
+ """Indexing pipeline: discover -> hash -> chunk -> embed -> vector index + store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from ragx.core.chunking import chunk_text
10
+ from ragx.core.config import Config, db_path, vectors_path
11
+ from ragx.core.discovery import discover_files, hash_file
12
+ from ragx.core.errors import ManifestMismatchError
13
+ from ragx.core.graph import affected_ids, knn_edges
14
+ from ragx.core.models import FileRecord
15
+ from ragx.core.store import Store
16
+ from ragx.core.vectors import VectorIndex
17
+ from ragx.providers.base import Embedder
18
+
19
+ log = logging.getLogger("ragx.index")
20
+
21
+
22
+ @dataclass
23
+ class IndexStats:
24
+ files_indexed: int = 0
25
+ files_deleted: int = 0
26
+ files_unchanged: int = 0
27
+ chunks_added: int = 0
28
+ chunks_deleted: int = 0
29
+ edges_total: int = 0
30
+
31
+
32
+ def run_index(root: Path, cfg: Config, embedder: Embedder, *, changed_only: bool = False) -> IndexStats:
33
+ """Index the corpus at `root`. Full rebuild by default; hash-diff when changed_only."""
34
+ with Store(db_path(root)) as store:
35
+ _check_manifest(store, embedder, allow_rewrite=not changed_only)
36
+ dim = embedder.dimension()
37
+ store.set_meta("embedding_model", embedder.model)
38
+ store.set_meta("embedding_dim", str(dim))
39
+
40
+ current = {
41
+ rel: hash_file(root / rel)
42
+ for rel in discover_files(
43
+ root,
44
+ cfg.get("corpus.include"),
45
+ cfg.get("corpus.exclude"),
46
+ cfg.get("corpus.respect_gitignore"),
47
+ )
48
+ }
49
+ known = store.get_file_hashes()
50
+
51
+ if changed_only:
52
+ to_index = [p for p, h in current.items() if known.get(p) != h]
53
+ to_delete = [p for p in known if p not in current]
54
+ index = _open_vectors(root, dim)
55
+ else:
56
+ to_index, to_delete = list(current), []
57
+ for p in known:
58
+ store.delete_file(p)
59
+ vectors_path(root).unlink(missing_ok=True)
60
+ index = _open_vectors(root, dim)
61
+
62
+ stats = IndexStats(files_unchanged=len(current) - len(to_index))
63
+
64
+ for path in to_delete:
65
+ removed = store.delete_file(path)
66
+ index.mark_deleted(removed)
67
+ stats.files_deleted += 1
68
+ stats.chunks_deleted += len(removed)
69
+
70
+ size_tokens = cfg.get("chunking.size_tokens")
71
+ overlap = cfg.get("chunking.overlap")
72
+ new_ids: list[int] = []
73
+ for path in sorted(to_index):
74
+ if path in known: # changed file: drop old chunks first
75
+ removed = store.delete_file(path)
76
+ index.mark_deleted(removed)
77
+ stats.chunks_deleted += len(removed)
78
+ text = (root / path).read_text(encoding="utf-8", errors="replace")
79
+ drafts = chunk_text(text, path, size_tokens=size_tokens, overlap=overlap)
80
+ store.upsert_file(FileRecord(path, current[path], (root / path).stat().st_mtime, len(drafts)))
81
+ if not drafts:
82
+ stats.files_indexed += 1
83
+ continue
84
+ ids = store.insert_chunks(path, drafts)
85
+ vectors = embedder.embed_documents([d.text for d in drafts])
86
+ index.add(ids, vectors)
87
+ new_ids.extend(ids)
88
+ stats.files_indexed += 1
89
+ stats.chunks_added += len(ids)
90
+ log.info("indexed %s (%d chunks)", path, len(ids))
91
+
92
+ if new_ids:
93
+ k, min_sim = cfg.get("graph.k"), cfg.get("graph.min_edge_sim")
94
+ edges = knn_edges(index, new_ids, k, min_sim)
95
+ touched = affected_ids(edges)
96
+ if touched: # incremental: refresh edge lists of pre-existing neighbors
97
+ edges.update(knn_edges(index, sorted(touched), k, min_sim))
98
+ for src, nbrs in edges.items():
99
+ store.replace_edges(src, nbrs)
100
+ log.info("graph: %d nodes re-edged", len(edges))
101
+
102
+ stats.edges_total = store.edge_count()
103
+ index.save()
104
+ return stats
105
+
106
+
107
+ def _check_manifest(store: Store, embedder: Embedder, *, allow_rewrite: bool) -> None:
108
+ prev = store.get_meta("embedding_model")
109
+ if prev and prev != embedder.model and not allow_rewrite:
110
+ raise ManifestMismatchError(
111
+ f"index was built with {prev!r} but config says {embedder.model!r}; "
112
+ "run a full `ragx index` to rebuild"
113
+ )
114
+
115
+
116
+ def _open_vectors(root: Path, dim: int) -> VectorIndex:
117
+ vp = vectors_path(root)
118
+ return VectorIndex.load(vp, dim) if vp.exists() else VectorIndex.create(vp, dim)
ragx/core/models.py ADDED
@@ -0,0 +1,67 @@
1
+ """Core data types shared across modules. Frozen where identity matters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ChunkDraft:
10
+ """A chunk produced by a chunker, before it has an id or embedding."""
11
+
12
+ text: str
13
+ byte_start: int
14
+ byte_end: int
15
+ line_start: int # 1-based, inclusive
16
+ line_end: int # 1-based, inclusive
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Chunk:
21
+ """A stored chunk. `id` is the SQLite rowid and doubles as the HNSW label."""
22
+
23
+ id: int
24
+ file_path: str # relative to corpus root, POSIX separators
25
+ text: str
26
+ byte_start: int
27
+ byte_end: int
28
+ line_start: int
29
+ line_end: int
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class FileRecord:
34
+ path: str # relative to corpus root, POSIX separators
35
+ content_hash: str # xxhash64 hexdigest
36
+ mtime: float
37
+ chunk_count: int
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Edge:
42
+ """Undirected similarity edge. Stored normalized with src < dst."""
43
+
44
+ src: int
45
+ dst: int
46
+ weight: float # cosine similarity in [0, 1]
47
+
48
+
49
+ @dataclass
50
+ class ScoredChunk:
51
+ """A query result candidate with its score breakdown."""
52
+
53
+ chunk: Chunk
54
+ score: float = 0.0
55
+ vector_score: float = 0.0 # fused RRF score (or raw cosine in no-fusion mode)
56
+ heat: float = 0.0
57
+ rerank_score: float = 0.0
58
+ explain: dict | None = None # traversal trace: seeds, edge paths, weights
59
+
60
+
61
+ @dataclass
62
+ class QueryOutput:
63
+ """Top-level result of the query pipeline; serialized as ragx.query.v1."""
64
+
65
+ query: str
66
+ variants: list[str] = field(default_factory=list)
67
+ results: list[ScoredChunk] = field(default_factory=list)