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/query.py ADDED
@@ -0,0 +1,181 @@
1
+ """Query pipeline: expansion -> fan-out retrieval -> RRF -> heat traversal -> rerank -> combine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Sequence
9
+
10
+ from ragx.core.config import Config, db_path, vectors_path
11
+ from ragx.core.errors import RagxError
12
+ from ragx.core.expansion import expand_query
13
+ from ragx.core.fusion import rrf
14
+ from ragx.core.models import QueryOutput, ScoredChunk
15
+ from ragx.core.scoring import combine
16
+ from ragx.core.store import Store
17
+ from ragx.core.traversal import propagate_heat
18
+ from ragx.core.vectors import VectorIndex
19
+ from ragx.providers.base import Embedder, Generator, Reranker
20
+
21
+ RERANK_CAP = 100 # cross-encoder cost guard: rerank at most this many candidates
22
+
23
+
24
+ @dataclass
25
+ class QueryOptions:
26
+ top: int = 8
27
+ files_only: bool = False
28
+ expand: bool = True
29
+ graph: bool = True
30
+ rerank: bool = True
31
+ hops: int | None = None
32
+ explain: bool = False
33
+
34
+
35
+ def _normalize(vec: Sequence[float]) -> list[float]:
36
+ norm = math.sqrt(sum(v * v for v in vec)) or 1.0
37
+ return [v / norm for v in vec]
38
+
39
+
40
+ def _check_model(store: Store, embedder: Embedder) -> None:
41
+ built_with = store.get_meta("embedding_model")
42
+ if built_with != embedder.model:
43
+ raise RagxError(
44
+ f"index built with {built_with!r}, config says {embedder.model!r}; "
45
+ "run `ragx index` to rebuild"
46
+ )
47
+
48
+
49
+ def run_query(
50
+ root: Path,
51
+ cfg: Config,
52
+ embedder: Embedder,
53
+ text: str,
54
+ opts: QueryOptions,
55
+ *,
56
+ generator: Generator | None = None,
57
+ reranker: Reranker | None = None,
58
+ ) -> QueryOutput:
59
+ if not text.strip():
60
+ raise RagxError("empty query")
61
+ with Store(db_path(root)) as store:
62
+ if store.chunk_count() == 0:
63
+ return QueryOutput(query=text)
64
+ _check_model(store, embedder)
65
+ index = VectorIndex.load(vectors_path(root), int(store.get_meta("embedding_dim") or 0))
66
+
67
+ # 1. expansion (optional, one LLM call)
68
+ variants: list[str] = []
69
+ if opts.expand and generator is not None:
70
+ exp = expand_query(
71
+ generator, text,
72
+ variants=cfg.get("expansion.variants"), hyde=cfg.get("expansion.hyde"),
73
+ )
74
+ variants = exp.variants + ([exp.hyde] if exp.hyde else [])
75
+
76
+ # 2. fan-out retrieval + RRF fusion
77
+ q_vecs = embedder.embed_queries([text, *variants])
78
+ per_top = cfg.get("fusion.per_query_top")
79
+ rankings = [[cid for cid, _ in index.search(v, per_top)] for v in q_vecs]
80
+ fused = rrf(rankings, k=cfg.get("fusion.rrf_k"))
81
+ if not fused:
82
+ return QueryOutput(query=text, variants=variants)
83
+
84
+ # 3. graph expansion via heat propagation
85
+ heat: dict[int, float] = dict(fused)
86
+ trace: dict[int, dict] = {}
87
+ if opts.graph and store.edge_count() > 0:
88
+ qv = _normalize(q_vecs[0])
89
+
90
+ def query_sim_fn(ids: Sequence[int]) -> dict[int, float]:
91
+ vecs = index.get_vectors(list(ids)) # stored normalized (cosine space)
92
+ return {i: max(0.0, sum(a * b for a, b in zip(v, qv))) for i, v in zip(ids, vecs)}
93
+
94
+ result = propagate_heat(
95
+ dict(fused), store.neighbors, query_sim_fn,
96
+ hops=opts.hops if opts.hops is not None else cfg.get("traversal.hops"),
97
+ decay=cfg.get("traversal.decay"),
98
+ query_floor=cfg.get("traversal.query_floor"),
99
+ max_frontier=cfg.get("traversal.max_frontier"),
100
+ )
101
+ heat, trace = result.heat, result.trace
102
+
103
+ candidates = sorted(set(fused) | set(heat))
104
+ alpha, beta, gamma = (
105
+ cfg.get("scoring.alpha_rerank"), cfg.get("scoring.beta_heat"), cfg.get("scoring.gamma_vector"),
106
+ )
107
+
108
+ # 4. cross-encoder rerank (capped shortlist by pre-score)
109
+ rerank_scores: dict[int, float] | None = None
110
+ if opts.rerank and reranker is not None:
111
+ pre = combine(candidates, fused, heat, None, alpha=alpha, beta=beta, gamma=gamma)
112
+ shortlist = sorted(candidates, key=lambda c: (-pre.get(c, 0.0), c))[:RERANK_CAP]
113
+ by_id = {c.id: c for c in store.get_chunks(shortlist)}
114
+ ordered = [cid for cid in shortlist if cid in by_id]
115
+ scores = reranker.score(text, [by_id[c].text for c in ordered])
116
+ rerank_scores = {cid: float(s) for cid, s in zip(ordered, scores)}
117
+ candidates = ordered # unreranked stragglers would score 0 anyway
118
+
119
+ # 5. combined scoring + output
120
+ final = combine(candidates, fused, heat, rerank_scores, alpha=alpha, beta=beta, gamma=gamma)
121
+ top_ids = sorted(candidates, key=lambda c: (-final.get(c, 0.0), c))[: opts.top]
122
+ by_id = {c.id: c for c in store.get_chunks(top_ids)}
123
+ results = [
124
+ ScoredChunk(
125
+ chunk=by_id[cid],
126
+ score=final.get(cid, 0.0),
127
+ vector_score=fused.get(cid, 0.0),
128
+ heat=heat.get(cid, 0.0),
129
+ rerank_score=(rerank_scores or {}).get(cid, 0.0),
130
+ explain=trace.get(cid) if opts.explain else None,
131
+ )
132
+ for cid in top_ids
133
+ if cid in by_id
134
+ ]
135
+ return QueryOutput(query=text, variants=variants, results=results)
136
+
137
+
138
+ def to_query_json(out: QueryOutput, *, max_chunk_chars: int = 1200) -> dict:
139
+ """Serialize as the versioned agent-facing schema."""
140
+ return {
141
+ "schema": "ragx.query.v1",
142
+ "query": out.query,
143
+ "variants": out.variants,
144
+ "results": [
145
+ {
146
+ "chunk_id": r.chunk.id,
147
+ "file": r.chunk.file_path,
148
+ "line_start": r.chunk.line_start,
149
+ "line_end": r.chunk.line_end,
150
+ "byte_start": r.chunk.byte_start,
151
+ "byte_end": r.chunk.byte_end,
152
+ "score": round(r.score, 6),
153
+ "breakdown": {
154
+ "vector": round(r.vector_score, 6),
155
+ "heat": round(r.heat, 6),
156
+ "rerank": round(r.rerank_score, 6),
157
+ },
158
+ "text": r.chunk.text[:max_chunk_chars],
159
+ "truncated": len(r.chunk.text) > max_chunk_chars,
160
+ **({"explain": r.explain} if r.explain else {}),
161
+ }
162
+ for r in out.results
163
+ ],
164
+ }
165
+
166
+
167
+ def to_files_json(out: QueryOutput) -> dict:
168
+ """Aggregate chunk scores per file: sum of each file's top-3 chunk scores."""
169
+ per_file: dict[str, list[ScoredChunk]] = {}
170
+ for r in out.results:
171
+ per_file.setdefault(r.chunk.file_path, []).append(r)
172
+ files = [
173
+ {
174
+ "file": path,
175
+ "score": round(sum(sorted((r.score for r in rs), reverse=True)[:3]), 6),
176
+ "chunk_ids": [r.chunk.id for r in sorted(rs, key=lambda r: r.score, reverse=True)],
177
+ }
178
+ for path, rs in per_file.items()
179
+ ]
180
+ files.sort(key=lambda f: f["score"], reverse=True)
181
+ return {"schema": "ragx.files.v1", "query": out.query, "variants": out.variants, "files": files}
ragx/core/scoring.py ADDED
@@ -0,0 +1,48 @@
1
+ """Score normalization and multi-signal combination."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+
8
+ def normalize(scores: dict[int, float]) -> dict[int, float]:
9
+ """Min-max scale to [0, 1]; empty -> {}; all-equal -> all 1.0."""
10
+ if not scores:
11
+ return {}
12
+ lo = min(scores.values())
13
+ hi = max(scores.values())
14
+ if hi == lo:
15
+ return {k: 1.0 for k in scores}
16
+ span = hi - lo
17
+ return {k: (v - lo) / span for k, v in scores.items()}
18
+
19
+
20
+ def combine(
21
+ candidates: Iterable[int],
22
+ vector: dict[int, float],
23
+ heat: dict[int, float],
24
+ rerank: dict[int, float] | None,
25
+ *,
26
+ alpha: float,
27
+ beta: float,
28
+ gamma: float,
29
+ ) -> dict[int, float]:
30
+ """Combine per-signal scores, min-max normalizing each component over candidates first."""
31
+ ids = list(candidates)
32
+ raw_vector = {cid: vector.get(cid, 0.0) for cid in ids}
33
+ raw_heat = {cid: heat.get(cid, 0.0) for cid in ids}
34
+ norm_vector = normalize(raw_vector)
35
+ norm_heat = normalize(raw_heat)
36
+
37
+ if rerank is not None:
38
+ raw_rerank = {cid: rerank.get(cid, 0.0) for cid in ids}
39
+ norm_rerank = normalize(raw_rerank)
40
+ return {
41
+ cid: alpha * norm_rerank[cid] + beta * norm_heat[cid] + gamma * norm_vector[cid]
42
+ for cid in ids
43
+ }
44
+
45
+ denom = beta + gamma
46
+ w_heat = beta / denom if denom else 0.0
47
+ w_vector = gamma / denom if denom else 0.0
48
+ return {cid: w_heat * norm_heat[cid] + w_vector * norm_vector[cid] for cid in ids}
ragx/core/store.py ADDED
@@ -0,0 +1,161 @@
1
+ """Single-file SQLite store for files, chunks, edges, and a key-value manifest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from pathlib import Path
7
+ from typing import Sequence
8
+
9
+ from ragx.core.models import Chunk, ChunkDraft, FileRecord
10
+
11
+ SCHEMA = """
12
+ CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
13
+ CREATE TABLE files(path TEXT PRIMARY KEY, content_hash TEXT NOT NULL,
14
+ mtime REAL NOT NULL, chunk_count INTEGER NOT NULL);
15
+ CREATE TABLE chunks(id INTEGER PRIMARY KEY AUTOINCREMENT,
16
+ file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE,
17
+ text TEXT NOT NULL, byte_start INTEGER NOT NULL, byte_end INTEGER NOT NULL,
18
+ line_start INTEGER NOT NULL, line_end INTEGER NOT NULL);
19
+ CREATE INDEX chunks_file ON chunks(file_path);
20
+ CREATE TABLE edges(src INTEGER NOT NULL, dst INTEGER NOT NULL, weight REAL NOT NULL,
21
+ PRIMARY KEY(src, dst)) WITHOUT ROWID;
22
+ CREATE INDEX edges_dst ON edges(dst);
23
+ """
24
+
25
+
26
+ class Store:
27
+ def __init__(self, path: Path):
28
+ self.conn = sqlite3.connect(str(path))
29
+ self.conn.execute("PRAGMA journal_mode = WAL")
30
+ self.conn.execute("PRAGMA foreign_keys = ON")
31
+ user_version = self.conn.execute("PRAGMA user_version").fetchone()[0]
32
+ if user_version == 0:
33
+ self.conn.executescript(SCHEMA)
34
+ self.conn.execute("PRAGMA user_version = 1")
35
+ self.conn.commit()
36
+
37
+ def close(self) -> None:
38
+ self.conn.close()
39
+
40
+ def __enter__(self) -> Store:
41
+ return self
42
+
43
+ def __exit__(self, *exc_info) -> None:
44
+ self.close()
45
+
46
+ # -- manifest -----------------------------------------------------
47
+
48
+ def get_meta(self, key: str) -> str | None:
49
+ row = self.conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
50
+ return row[0] if row else None
51
+
52
+ def set_meta(self, key: str, value: str) -> None:
53
+ self.conn.execute(
54
+ "INSERT INTO meta(key, value) VALUES (?, ?) "
55
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
56
+ (key, value),
57
+ )
58
+ self.conn.commit()
59
+
60
+ # -- files ----------------------------------------------------------
61
+
62
+ def get_file_hashes(self) -> dict[str, str]:
63
+ rows = self.conn.execute("SELECT path, content_hash FROM files").fetchall()
64
+ return dict(rows)
65
+
66
+ def upsert_file(self, rec: FileRecord) -> None:
67
+ self.conn.execute(
68
+ "INSERT INTO files(path, content_hash, mtime, chunk_count) VALUES (?, ?, ?, ?) "
69
+ "ON CONFLICT(path) DO UPDATE SET content_hash = excluded.content_hash, "
70
+ "mtime = excluded.mtime, chunk_count = excluded.chunk_count",
71
+ (rec.path, rec.content_hash, rec.mtime, rec.chunk_count),
72
+ )
73
+ self.conn.commit()
74
+
75
+ def delete_file(self, path: str) -> list[int]:
76
+ ids = [
77
+ row[0]
78
+ for row in self.conn.execute("SELECT id FROM chunks WHERE file_path = ?", (path,))
79
+ ]
80
+ if ids:
81
+ placeholders = ",".join("?" * len(ids))
82
+ self.conn.execute(f"DELETE FROM edges WHERE src IN ({placeholders})", ids)
83
+ self.conn.execute(f"DELETE FROM edges WHERE dst IN ({placeholders})", ids)
84
+ self.conn.execute("DELETE FROM files WHERE path = ?", (path,))
85
+ self.conn.commit()
86
+ return ids
87
+
88
+ def file_count(self) -> int:
89
+ return self.conn.execute("SELECT COUNT(*) FROM files").fetchone()[0]
90
+
91
+ # -- chunks ------------------------------------------------------
92
+
93
+ def insert_chunks(self, file_path: str, drafts: Sequence[ChunkDraft]) -> list[int]:
94
+ ids = []
95
+ for d in drafts:
96
+ cur = self.conn.execute(
97
+ "INSERT INTO chunks(file_path, text, byte_start, byte_end, line_start, line_end) "
98
+ "VALUES (?, ?, ?, ?, ?, ?)",
99
+ (file_path, d.text, d.byte_start, d.byte_end, d.line_start, d.line_end),
100
+ )
101
+ ids.append(cur.lastrowid)
102
+ self.conn.commit()
103
+ return ids
104
+
105
+ def get_chunks(self, ids: Sequence[int]) -> list[Chunk]:
106
+ if not ids:
107
+ return []
108
+ placeholders = ",".join("?" * len(ids))
109
+ rows = self.conn.execute(
110
+ f"SELECT id, file_path, text, byte_start, byte_end, line_start, line_end "
111
+ f"FROM chunks WHERE id IN ({placeholders})",
112
+ list(ids),
113
+ ).fetchall()
114
+ by_id = {row[0]: Chunk(*row) for row in rows}
115
+ return [by_id[i] for i in ids if i in by_id]
116
+
117
+ def chunk_ids_for_file(self, path: str) -> list[int]:
118
+ rows = self.conn.execute(
119
+ "SELECT id FROM chunks WHERE file_path = ? ORDER BY id", (path,)
120
+ ).fetchall()
121
+ return [row[0] for row in rows]
122
+
123
+ def all_chunk_ids(self) -> list[int]:
124
+ rows = self.conn.execute("SELECT id FROM chunks ORDER BY id").fetchall()
125
+ return [row[0] for row in rows]
126
+
127
+ def chunk_count(self) -> int:
128
+ return self.conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
129
+
130
+ # -- edges (undirected; stored normalized src < dst) -----------------
131
+
132
+ def replace_edges(self, src: int, neighbors: Sequence[tuple[int, float]]) -> None:
133
+ self.conn.execute("DELETE FROM edges WHERE src = ? OR dst = ?", (src, src))
134
+ for dst, weight in neighbors:
135
+ lo, hi = (src, dst) if src < dst else (dst, src)
136
+ self.conn.execute(
137
+ "INSERT INTO edges(src, dst, weight) VALUES (?, ?, ?) "
138
+ "ON CONFLICT(src, dst) DO UPDATE SET weight = excluded.weight",
139
+ (lo, hi, weight),
140
+ )
141
+ self.conn.commit()
142
+
143
+ def neighbors(self, chunk_id: int) -> list[tuple[int, float]]:
144
+ rows = self.conn.execute(
145
+ "SELECT dst AS other, weight FROM edges WHERE src = ? "
146
+ "UNION ALL "
147
+ "SELECT src AS other, weight FROM edges WHERE dst = ?",
148
+ (chunk_id, chunk_id),
149
+ ).fetchall()
150
+ return sorted(((other, weight) for other, weight in rows), key=lambda t: -t[1])
151
+
152
+ def delete_edges_incident(self, ids: Sequence[int]) -> None:
153
+ if not ids:
154
+ return
155
+ placeholders = ",".join("?" * len(ids))
156
+ self.conn.execute(f"DELETE FROM edges WHERE src IN ({placeholders})", list(ids))
157
+ self.conn.execute(f"DELETE FROM edges WHERE dst IN ({placeholders})", list(ids))
158
+ self.conn.commit()
159
+
160
+ def edge_count(self) -> int:
161
+ return self.conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
ragx/core/traversal.py ADDED
@@ -0,0 +1,68 @@
1
+ """Heat-propagation traversal: spreads seed relevance heat across the similarity graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class TraversalResult:
11
+ heat: dict[int, float] # node -> final heat
12
+ trace: dict[int, dict] # node -> {"seed": int, "parent": int | None, "edge_weight": float, "hop": int}
13
+
14
+
15
+ def propagate_heat(
16
+ seeds: dict[int, float],
17
+ neighbors_fn: Callable[[int], list[tuple[int, float]]],
18
+ query_sim_fn: Callable[[Sequence[int]], dict[int, float]],
19
+ *,
20
+ hops: int = 2,
21
+ decay: float = 0.5,
22
+ query_floor: float = 0.35,
23
+ max_frontier: int = 150,
24
+ ) -> TraversalResult:
25
+ heat: dict[int, float] = {}
26
+ trace: dict[int, dict] = {}
27
+ for seed_id, score in seeds.items():
28
+ heat[seed_id] = score
29
+ trace[seed_id] = {"seed": seed_id, "parent": None, "edge_weight": 0.0, "hop": 0}
30
+
31
+ sim_cache: dict[int, float] = {}
32
+ frontier = sorted(seeds)
33
+
34
+ for hop in range(1, hops + 1):
35
+ if not frontier:
36
+ break
37
+
38
+ edges: list[tuple[int, int, float]] = []
39
+ first_seen: list[int] = []
40
+ seen_this_hop: set[int] = set()
41
+ for u in frontier:
42
+ for v, w in neighbors_fn(u):
43
+ edges.append((u, v, w))
44
+ if v not in seeds and v not in sim_cache and v not in seen_this_hop:
45
+ first_seen.append(v)
46
+ seen_this_hop.add(v)
47
+
48
+ if first_seen:
49
+ sim_cache.update(query_sim_fn(first_seen))
50
+
51
+ improved: set[int] = set()
52
+ for u, v, w in edges:
53
+ if v not in seeds and sim_cache.get(v, 0.0) < query_floor:
54
+ continue
55
+ contribution = heat[u] * w * decay
56
+ if contribution > heat.get(v, float("-inf")):
57
+ heat[v] = contribution
58
+ trace[v] = {
59
+ "seed": trace[u]["seed"],
60
+ "parent": u,
61
+ "edge_weight": w,
62
+ "hop": hop,
63
+ }
64
+ improved.add(v)
65
+
66
+ frontier = sorted(improved, key=lambda n: (-heat[n], n))[:max_frontier]
67
+
68
+ return TraversalResult(heat=heat, trace=trace)
ragx/core/vectors.py ADDED
@@ -0,0 +1,105 @@
1
+ """HNSW-backed vector index (hnswlib), cosine space.
2
+
3
+ hnswlib doesn't expose a way to enumerate/count soft-deleted labels after
4
+ save/load, so we track the deleted-id set and capacity ourselves in a small
5
+ JSON sidecar next to the index file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Sequence
13
+
14
+ import hnswlib
15
+
16
+ from ragx.core.errors import RagxError
17
+
18
+ _M = 16
19
+ _EF_CONSTRUCTION = 200
20
+
21
+
22
+ def _meta_path(path: Path) -> Path:
23
+ return path.with_name(path.name + ".meta.json")
24
+
25
+
26
+ class VectorIndex:
27
+ """Wraps an hnswlib.Index with resize-on-full and soft-delete tracking."""
28
+
29
+ def __init__(self, index: hnswlib.Index, path: Path, capacity: int, deleted: set[int]):
30
+ self._index = index
31
+ self._path = path
32
+ self._capacity = capacity
33
+ self._deleted = deleted
34
+
35
+ @classmethod
36
+ def create(cls, path: Path, dim: int, capacity: int = 2048) -> VectorIndex:
37
+ index = hnswlib.Index(space="cosine", dim=dim)
38
+ index.init_index(max_elements=capacity, M=_M, ef_construction=_EF_CONSTRUCTION)
39
+ return cls(index, path, capacity, set())
40
+
41
+ @classmethod
42
+ def load(cls, path: Path, dim: int) -> VectorIndex:
43
+ if not path.exists():
44
+ raise RagxError(f"vector index file not found: {path}")
45
+ meta_path = _meta_path(path)
46
+ capacity = 2048
47
+ deleted: set[int] = set()
48
+ if meta_path.exists():
49
+ meta = json.loads(meta_path.read_text())
50
+ capacity = meta["capacity"]
51
+ deleted = set(meta["deleted"])
52
+ index = hnswlib.Index(space="cosine", dim=dim)
53
+ index.load_index(str(path), max_elements=capacity)
54
+ return cls(index, path, capacity, deleted)
55
+
56
+ def add(self, ids: Sequence[int], vectors: Sequence[Sequence[float]]) -> None:
57
+ if not ids:
58
+ return
59
+ needed = self._index.get_current_count() + len(ids)
60
+ while needed > self._capacity:
61
+ self._capacity *= 2
62
+ if self._capacity > self._index.get_max_elements():
63
+ self._index.resize_index(self._capacity)
64
+ self._index.add_items(list(vectors), list(ids))
65
+
66
+ def search(self, vector: Sequence[float], k: int) -> list[tuple[int, float]]:
67
+ active = self.count
68
+ if active == 0 or k <= 0:
69
+ return []
70
+ k = min(k, active)
71
+ ef = max(100, 2 * k)
72
+ self._index.set_ef(ef)
73
+ labels, distances = self._index.knn_query([vector], k=k)
74
+ results = [
75
+ (int(label), max(0.0, min(1.0, 1.0 - float(dist))))
76
+ for label, dist in zip(labels[0], distances[0])
77
+ ]
78
+ results.sort(key=lambda x: x[1], reverse=True)
79
+ return results
80
+
81
+ def get_vectors(self, ids: Sequence[int]) -> list[list[float]]:
82
+ """Stored (normalized) vectors for `ids`, order preserved. Unknown id -> RagxError."""
83
+ if not ids:
84
+ return []
85
+ try:
86
+ return [list(map(float, v)) for v in self._index.get_items(list(ids))]
87
+ except RuntimeError as exc:
88
+ raise RagxError(f"unknown vector id in {list(ids)!r}: {exc}") from exc
89
+
90
+ def mark_deleted(self, ids: Sequence[int]) -> None:
91
+ known_ids = set(self._index.get_ids_list())
92
+ for i in ids:
93
+ if i in known_ids and i not in self._deleted:
94
+ self._index.mark_deleted(i)
95
+ self._deleted.add(i)
96
+
97
+ def save(self) -> None:
98
+ self._path.parent.mkdir(parents=True, exist_ok=True)
99
+ self._index.save_index(str(self._path))
100
+ meta = {"capacity": self._capacity, "deleted": sorted(self._deleted)}
101
+ _meta_path(self._path).write_text(json.dumps(meta))
102
+
103
+ @property
104
+ def count(self) -> int:
105
+ return self._index.get_current_count() - len(self._deleted)
File without changes
ragx/providers/base.py ADDED
@@ -0,0 +1,46 @@
1
+ """Provider protocols. Implementations live beside this module; factories in registry.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, Sequence, runtime_checkable
6
+
7
+
8
+ @runtime_checkable
9
+ class Embedder(Protocol):
10
+ """Text → vector. Implementations apply doc/query prefixes themselves."""
11
+
12
+ model: str
13
+
14
+ def dimension(self) -> int:
15
+ """Vector dimension. May lazily probe the backend once and cache."""
16
+ ...
17
+
18
+ def embed_documents(self, texts: Sequence[str]) -> list[list[float]]:
19
+ """Embed corpus chunks (applies doc_prefix if configured). Preserves order."""
20
+ ...
21
+
22
+ def embed_queries(self, texts: Sequence[str]) -> list[list[float]]:
23
+ """Embed search queries (applies query_prefix if configured). Preserves order."""
24
+ ...
25
+
26
+
27
+ @runtime_checkable
28
+ class Generator(Protocol):
29
+ """LLM text generation, used only by the optional query-expansion stage."""
30
+
31
+ model: str
32
+
33
+ def generate(self, system: str, prompt: str, *, max_tokens: int = 1024) -> str:
34
+ """Single completion. Returns raw text; caller parses any JSON."""
35
+ ...
36
+
37
+
38
+ @runtime_checkable
39
+ class Reranker(Protocol):
40
+ """Cross-encoder relevance scoring."""
41
+
42
+ model: str
43
+
44
+ def score(self, query: str, texts: Sequence[str]) -> list[float]:
45
+ """Relevance score per text, same order. Higher is more relevant."""
46
+ ...