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/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
|
+
...
|