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
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)
|