indexkit 0.6.1__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.
- indexkit/__init__.py +3 -0
- indexkit/__main__.py +4 -0
- indexkit/cli.py +254 -0
- indexkit/embed.py +56 -0
- indexkit/engine.py +272 -0
- indexkit/index.py +59 -0
- indexkit/loaders/__init__.py +3 -0
- indexkit/loaders/markdown.py +136 -0
- indexkit/storage.py +245 -0
- indexkit/store.py +211 -0
- indexkit-0.6.1.dist-info/METADATA +222 -0
- indexkit-0.6.1.dist-info/RECORD +15 -0
- indexkit-0.6.1.dist-info/WHEEL +4 -0
- indexkit-0.6.1.dist-info/entry_points.txt +2 -0
- indexkit-0.6.1.dist-info/licenses/LICENSE +21 -0
indexkit/__init__.py
ADDED
indexkit/__main__.py
ADDED
indexkit/cli.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .embed import OllamaEmbedder
|
|
12
|
+
from .engine import Engine, slug
|
|
13
|
+
from .storage import list_indexes, remove_index, validate_index_name
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _first_env(*names: str) -> str | None:
|
|
17
|
+
for name in names:
|
|
18
|
+
value = os.environ.get(name)
|
|
19
|
+
if value:
|
|
20
|
+
return value
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _legacy_data_dir() -> Path:
|
|
25
|
+
"""Where a plugin install kept index data before standalone packaging.
|
|
26
|
+
|
|
27
|
+
Kept resolvable so upgrading does not silently orphan existing indexes.
|
|
28
|
+
This is deliberate compatibility, not dead code — see the governing records.
|
|
29
|
+
"""
|
|
30
|
+
# @adr 0006
|
|
31
|
+
# @adr 0007
|
|
32
|
+
return Path.home() / ".claude/plugins/data/indexkit"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _default_data_dir() -> Path:
|
|
36
|
+
"""Host-neutral default for a plain `pip install indexkit`.
|
|
37
|
+
|
|
38
|
+
A user who installed from PyPI has no plugin host, so writing under
|
|
39
|
+
`~/.claude/` would create a directory for an unrelated tool. Follow the XDG
|
|
40
|
+
base-directory spec instead, honoring `XDG_DATA_HOME` when set.
|
|
41
|
+
|
|
42
|
+
An existing plugin-managed directory still wins when the new location has
|
|
43
|
+
not been created yet, so upgrading in place keeps working (ADR-0006).
|
|
44
|
+
"""
|
|
45
|
+
xdg = os.environ.get("XDG_DATA_HOME")
|
|
46
|
+
default = (Path(xdg).expanduser() if xdg else Path.home() / ".local/share") / "indexkit"
|
|
47
|
+
if not default.exists():
|
|
48
|
+
legacy = _legacy_data_dir()
|
|
49
|
+
if legacy.is_dir():
|
|
50
|
+
return legacy
|
|
51
|
+
return default
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _data_dir() -> Path:
|
|
55
|
+
"""Resolve where indexes live.
|
|
56
|
+
|
|
57
|
+
Explicit configuration wins, then the host-provided plugin data directory,
|
|
58
|
+
then a host-neutral default. `CLAUDE_PLUGIN_DATA` is set by the plugin host,
|
|
59
|
+
so honoring it keeps plugin installs pointed at host-managed storage while a
|
|
60
|
+
standalone install never touches it.
|
|
61
|
+
"""
|
|
62
|
+
configured = _first_env("CONTEXT_KIT_DATA", "PRODUCTIVITY_SKILLS_DATA", "CLAUDE_PLUGIN_DATA")
|
|
63
|
+
if configured:
|
|
64
|
+
return Path(configured).expanduser()
|
|
65
|
+
return _default_data_dir().expanduser()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _make_embedder(args):
|
|
69
|
+
model = (
|
|
70
|
+
getattr(args, "model", None)
|
|
71
|
+
or _first_env(
|
|
72
|
+
"CONTEXT_KIT_EMBED_MODEL",
|
|
73
|
+
"PRODUCTIVITY_SKILLS_EMBED_MODEL",
|
|
74
|
+
"CLAUDE_PLUGIN_OPTION_EMBED_MODEL",
|
|
75
|
+
)
|
|
76
|
+
or "nomic-embed-text"
|
|
77
|
+
)
|
|
78
|
+
host = (
|
|
79
|
+
_first_env(
|
|
80
|
+
"CONTEXT_KIT_OLLAMA_HOST",
|
|
81
|
+
"PRODUCTIVITY_SKILLS_OLLAMA_HOST",
|
|
82
|
+
"CLAUDE_PLUGIN_OPTION_OLLAMA_HOST",
|
|
83
|
+
)
|
|
84
|
+
or "http://localhost:11434"
|
|
85
|
+
)
|
|
86
|
+
return OllamaEmbedder(
|
|
87
|
+
model=model,
|
|
88
|
+
host=host,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _name_for(args, corpus=None) -> str:
|
|
93
|
+
if getattr(args, "name", None):
|
|
94
|
+
return validate_index_name(args.name)
|
|
95
|
+
return validate_index_name(slug(corpus) if corpus else "default")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _index_name(value: str) -> str:
|
|
99
|
+
try:
|
|
100
|
+
return validate_index_name(value)
|
|
101
|
+
except ValueError as error:
|
|
102
|
+
raise argparse.ArgumentTypeError(str(error)) from error
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _read_allowlist(value) -> list[str] | None:
|
|
106
|
+
if not value:
|
|
107
|
+
return None
|
|
108
|
+
if value == "-":
|
|
109
|
+
return [ln.strip() for ln in sys.stdin if ln.strip()]
|
|
110
|
+
lines = Path(value).read_text(encoding="utf-8").splitlines()
|
|
111
|
+
return [ln.strip() for ln in lines if ln.strip()]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _missing_index(name: str) -> str:
|
|
115
|
+
return f"error: no index named '{name}'. Run 'rag index <path> --name {name}' first."
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def main(argv=None) -> int:
|
|
119
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
120
|
+
p = argparse.ArgumentParser(
|
|
121
|
+
prog="indexkit",
|
|
122
|
+
description="Hybrid semantic and lexical retrieval over a local index.",
|
|
123
|
+
)
|
|
124
|
+
# Exposed so an integrating adapter can pin/report a provider version the
|
|
125
|
+
# same way it does for other retrieval backends.
|
|
126
|
+
p.add_argument("--version", action="version", version=f"indexkit {__version__}")
|
|
127
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
128
|
+
|
|
129
|
+
pi = sub.add_parser("index")
|
|
130
|
+
pi.add_argument("path")
|
|
131
|
+
pi.add_argument("--name", type=_index_name)
|
|
132
|
+
pi.add_argument("--model")
|
|
133
|
+
pi.add_argument("--include", action="append")
|
|
134
|
+
pi.add_argument("--exclude", action="append")
|
|
135
|
+
|
|
136
|
+
pq = sub.add_parser("query")
|
|
137
|
+
pq.add_argument("text")
|
|
138
|
+
pq.add_argument("--name", type=_index_name)
|
|
139
|
+
pq.add_argument("--model")
|
|
140
|
+
pq.add_argument("--k", type=int, default=10)
|
|
141
|
+
pq.add_argument("--allowlist")
|
|
142
|
+
pq.add_argument(
|
|
143
|
+
"--hybrid",
|
|
144
|
+
action="store_true",
|
|
145
|
+
help="Fuse semantic and SQLite FTS5/BM25 candidates with reciprocal-rank fusion.",
|
|
146
|
+
)
|
|
147
|
+
pq.add_argument("--json", action="store_true")
|
|
148
|
+
|
|
149
|
+
ps = sub.add_parser("status")
|
|
150
|
+
ps.add_argument("--name", type=_index_name)
|
|
151
|
+
ps.add_argument("--model")
|
|
152
|
+
sub.add_parser("list")
|
|
153
|
+
|
|
154
|
+
pr = sub.add_parser("remove")
|
|
155
|
+
pr.add_argument("--name", required=True, type=_index_name)
|
|
156
|
+
pr.add_argument(
|
|
157
|
+
"--yes",
|
|
158
|
+
action="store_true",
|
|
159
|
+
help="Confirm non-interactive, permanent removal of the named index.",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
args = p.parse_args(argv)
|
|
163
|
+
data = _data_dir()
|
|
164
|
+
|
|
165
|
+
if args.cmd == "list":
|
|
166
|
+
for name in list_indexes(data):
|
|
167
|
+
print(name)
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
if args.cmd == "remove":
|
|
171
|
+
if not args.yes:
|
|
172
|
+
print(
|
|
173
|
+
f"error: refusing to remove index '{args.name}' without --yes",
|
|
174
|
+
file=sys.stderr,
|
|
175
|
+
)
|
|
176
|
+
return 2
|
|
177
|
+
try:
|
|
178
|
+
removed = remove_index(data, args.name)
|
|
179
|
+
except (OSError, RuntimeError) as error:
|
|
180
|
+
print(f"error: {error}", file=sys.stderr)
|
|
181
|
+
return 1
|
|
182
|
+
print(f"removed={args.name} artifacts={removed}")
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
if args.cmd == "index":
|
|
186
|
+
eng = None
|
|
187
|
+
try:
|
|
188
|
+
eng = Engine(_name_for(args, args.path), data, _make_embedder(args))
|
|
189
|
+
res = eng.index(args.path, args.include, args.exclude)
|
|
190
|
+
except Exception as e:
|
|
191
|
+
print(f"error: {e}", file=sys.stderr)
|
|
192
|
+
return 1
|
|
193
|
+
finally:
|
|
194
|
+
if eng is not None:
|
|
195
|
+
eng.close()
|
|
196
|
+
print(
|
|
197
|
+
f"indexed={res['indexed']} skipped={res['skipped']} "
|
|
198
|
+
f"files={res['files']} chunks={res['chunks']}"
|
|
199
|
+
)
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
if args.cmd == "status":
|
|
203
|
+
name = _name_for(args)
|
|
204
|
+
eng = None
|
|
205
|
+
try:
|
|
206
|
+
eng = Engine(name, data, _make_embedder(args), create=False)
|
|
207
|
+
print(json.dumps(eng.store.stats()))
|
|
208
|
+
except FileNotFoundError:
|
|
209
|
+
print(_missing_index(name), file=sys.stderr)
|
|
210
|
+
return 1
|
|
211
|
+
except (OSError, RuntimeError, sqlite3.Error) as error:
|
|
212
|
+
print(f"error: {error}", file=sys.stderr)
|
|
213
|
+
return 1
|
|
214
|
+
finally:
|
|
215
|
+
if eng is not None:
|
|
216
|
+
eng.close()
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
if args.cmd == "query":
|
|
220
|
+
name = _name_for(args)
|
|
221
|
+
eng = None
|
|
222
|
+
try:
|
|
223
|
+
eng = Engine(name, data, _make_embedder(args), create=False)
|
|
224
|
+
hits = eng.query(
|
|
225
|
+
args.text,
|
|
226
|
+
k=args.k,
|
|
227
|
+
allowlist_paths=_read_allowlist(args.allowlist),
|
|
228
|
+
hybrid=args.hybrid,
|
|
229
|
+
)
|
|
230
|
+
except FileNotFoundError:
|
|
231
|
+
print(_missing_index(name), file=sys.stderr)
|
|
232
|
+
return 1
|
|
233
|
+
except Exception as e:
|
|
234
|
+
print(f"error: {e}", file=sys.stderr)
|
|
235
|
+
return 1
|
|
236
|
+
finally:
|
|
237
|
+
if eng is not None:
|
|
238
|
+
eng.close()
|
|
239
|
+
if args.json:
|
|
240
|
+
print(json.dumps(hits))
|
|
241
|
+
else:
|
|
242
|
+
for h in hits:
|
|
243
|
+
loc = f"{h['path']}" + (f" > {h['heading']}" if h["heading"] else "")
|
|
244
|
+
snippet = h["snippet"].replace("\n", " ")
|
|
245
|
+
score = f"[{h['score']:.3f}]"
|
|
246
|
+
if h["retrieval_mode"] == "hybrid":
|
|
247
|
+
score = f"[{h['score']:.3f} hybrid]"
|
|
248
|
+
print(f"{score} {loc}\n {snippet}")
|
|
249
|
+
return 0
|
|
250
|
+
return 2
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == "__main__":
|
|
254
|
+
raise SystemExit(main())
|
indexkit/embed.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EmbedError(RuntimeError):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OllamaEmbedder:
|
|
11
|
+
def __init__(self, model: str = "nomic-embed-text", host: str = "http://localhost:11434"):
|
|
12
|
+
self.model = model
|
|
13
|
+
self.host = host.rstrip("/")
|
|
14
|
+
self._client = httpx.Client()
|
|
15
|
+
self._dim: int | None = None
|
|
16
|
+
|
|
17
|
+
def _embed_one(self, text: str) -> list[float]:
|
|
18
|
+
try:
|
|
19
|
+
resp = self._client.post(
|
|
20
|
+
f"{self.host}/api/embeddings",
|
|
21
|
+
json={"model": self.model, "prompt": text},
|
|
22
|
+
timeout=60.0,
|
|
23
|
+
)
|
|
24
|
+
resp.raise_for_status()
|
|
25
|
+
except httpx.ConnectError as e:
|
|
26
|
+
raise EmbedError(
|
|
27
|
+
f"Could not reach ollama at {self.host}: {e}. "
|
|
28
|
+
f"Start it with 'ollama serve' and ensure the model is pulled: "
|
|
29
|
+
f"'ollama pull {self.model}'."
|
|
30
|
+
) from e
|
|
31
|
+
except httpx.HTTPStatusError as e:
|
|
32
|
+
raise EmbedError(
|
|
33
|
+
f"ollama returned an error for model '{self.model}'. "
|
|
34
|
+
f"Pull it with 'ollama pull {self.model}'. ({e})"
|
|
35
|
+
) from e
|
|
36
|
+
vec = resp.json().get("embedding")
|
|
37
|
+
if not vec:
|
|
38
|
+
raise EmbedError(f"ollama returned no embedding for model '{self.model}'.")
|
|
39
|
+
return vec
|
|
40
|
+
|
|
41
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
42
|
+
return [self._embed_one(t) for t in texts]
|
|
43
|
+
|
|
44
|
+
def dim(self) -> int:
|
|
45
|
+
if self._dim is None:
|
|
46
|
+
self._dim = len(self._embed_one("dimension probe"))
|
|
47
|
+
return self._dim
|
|
48
|
+
|
|
49
|
+
def close(self) -> None:
|
|
50
|
+
self._client.close()
|
|
51
|
+
|
|
52
|
+
def __enter__(self) -> "OllamaEmbedder":
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def __exit__(self, *exc) -> None:
|
|
56
|
+
self.close()
|
indexkit/engine.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import ExitStack
|
|
4
|
+
import hashlib
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .embed import OllamaEmbedder
|
|
9
|
+
from .index import VecIndex
|
|
10
|
+
from .loaders.markdown import iter_corpus, load_markdown
|
|
11
|
+
from .storage import IndexLock, ensure_index_dir, existing_index_dir
|
|
12
|
+
from .store import MetaStore
|
|
13
|
+
|
|
14
|
+
RRF_K = 60
|
|
15
|
+
SEMANTIC_RRF_WEIGHT = 1.0
|
|
16
|
+
LEXICAL_RRF_WEIGHT = 1.0
|
|
17
|
+
HYBRID_CANDIDATE_MULTIPLIER = 3
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def slug(path) -> str:
|
|
21
|
+
s = re.sub(r"[^a-zA-Z0-9]+", "-", str(Path(path).resolve())).strip("-").lower()
|
|
22
|
+
return s[-80:].lstrip("-") or "default"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def reciprocal_rank_fusion(
|
|
26
|
+
semantic_hits: list[tuple[int, float]],
|
|
27
|
+
lexical_hits: list[tuple[int, float]],
|
|
28
|
+
) -> list[dict]:
|
|
29
|
+
"""Fuse ranked sources with ``weight / (RRF_K + rank)``.
|
|
30
|
+
|
|
31
|
+
The returned order breaks equal RRF scores by best source rank, then chunk id,
|
|
32
|
+
making fusion reproducible even when a source assigns equal scores.
|
|
33
|
+
"""
|
|
34
|
+
candidates: dict[int, dict] = {}
|
|
35
|
+
for rank, (chunk_id, score) in enumerate(semantic_hits, start=1):
|
|
36
|
+
hit = candidates.setdefault(chunk_id, {"id": chunk_id})
|
|
37
|
+
hit["semantic_rank"] = rank
|
|
38
|
+
hit["semantic_score"] = score
|
|
39
|
+
for rank, (chunk_id, score) in enumerate(lexical_hits, start=1):
|
|
40
|
+
hit = candidates.setdefault(chunk_id, {"id": chunk_id})
|
|
41
|
+
hit["lexical_rank"] = rank
|
|
42
|
+
hit["lexical_score"] = score
|
|
43
|
+
for hit in candidates.values():
|
|
44
|
+
hit["fused_score"] = (
|
|
45
|
+
SEMANTIC_RRF_WEIGHT / (RRF_K + hit["semantic_rank"]) if "semantic_rank" in hit else 0.0
|
|
46
|
+
) + (LEXICAL_RRF_WEIGHT / (RRF_K + hit["lexical_rank"]) if "lexical_rank" in hit else 0.0)
|
|
47
|
+
return sorted(
|
|
48
|
+
candidates.values(),
|
|
49
|
+
key=lambda hit: (
|
|
50
|
+
-hit["fused_score"],
|
|
51
|
+
min(hit.get("semantic_rank", float("inf")), hit.get("lexical_rank", float("inf"))),
|
|
52
|
+
hit["id"],
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Engine:
|
|
58
|
+
def __init__(self, name: str, data_dir, embedder=None, *, create: bool = True):
|
|
59
|
+
self.name = name
|
|
60
|
+
embedder_instance = embedder or OllamaEmbedder()
|
|
61
|
+
with ExitStack() as cleanup:
|
|
62
|
+
cleanup.callback(embedder_instance.close)
|
|
63
|
+
lock = IndexLock(data_dir, name)
|
|
64
|
+
lock.acquire()
|
|
65
|
+
cleanup.callback(lock.release)
|
|
66
|
+
directory = (
|
|
67
|
+
ensure_index_dir(data_dir, name) if create else existing_index_dir(data_dir, name)
|
|
68
|
+
)
|
|
69
|
+
store = MetaStore(directory / "meta.sqlite")
|
|
70
|
+
cleanup.callback(store.close)
|
|
71
|
+
store.init_schema()
|
|
72
|
+
self.dir = directory
|
|
73
|
+
self.embedder = embedder_instance
|
|
74
|
+
self.store = store
|
|
75
|
+
self.index_path = self.dir / "index.tvim"
|
|
76
|
+
self._lock = lock
|
|
77
|
+
cleanup.pop_all()
|
|
78
|
+
|
|
79
|
+
def close(self) -> None:
|
|
80
|
+
try:
|
|
81
|
+
try:
|
|
82
|
+
store = getattr(self, "store", None)
|
|
83
|
+
if store is not None:
|
|
84
|
+
store.close()
|
|
85
|
+
finally:
|
|
86
|
+
embedder = getattr(self, "embedder", None)
|
|
87
|
+
if embedder is not None:
|
|
88
|
+
embedder.close()
|
|
89
|
+
finally:
|
|
90
|
+
lock = getattr(self, "_lock", None)
|
|
91
|
+
if lock is not None:
|
|
92
|
+
lock.release()
|
|
93
|
+
|
|
94
|
+
def _dim(self) -> int:
|
|
95
|
+
d = self.embedder.dim()
|
|
96
|
+
stored = self.store.get_meta("dim")
|
|
97
|
+
if stored is None:
|
|
98
|
+
self.store.set_meta("dim", str(d))
|
|
99
|
+
self.store.set_meta("model", self.embedder.model)
|
|
100
|
+
elif int(stored) != d:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"Embedding dim {d} (model '{self.embedder.model}') != index dim {stored} "
|
|
103
|
+
f"(model '{self.store.get_meta('model')}'). Reindex with --name NEW or a matching model."
|
|
104
|
+
)
|
|
105
|
+
return d
|
|
106
|
+
|
|
107
|
+
def _load_index(self, dim: int) -> VecIndex:
|
|
108
|
+
if self.index_path.exists():
|
|
109
|
+
return VecIndex.load(dim=dim, path=self.index_path)
|
|
110
|
+
return VecIndex(dim=dim, path=self.index_path)
|
|
111
|
+
|
|
112
|
+
def index(self, root, include=None, exclude=None) -> dict:
|
|
113
|
+
dim = self._dim()
|
|
114
|
+
idx = self._load_index(dim)
|
|
115
|
+
root = Path(root)
|
|
116
|
+
indexed = skipped = remove_failures = 0
|
|
117
|
+
seen: set[str] = set()
|
|
118
|
+
for fp in iter_corpus(root, include, exclude):
|
|
119
|
+
rel = fp.relative_to(root).as_posix()
|
|
120
|
+
seen.add(rel)
|
|
121
|
+
raw = fp.read_text(encoding="utf-8", errors="replace")
|
|
122
|
+
h = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
123
|
+
if self.store.file_hash(rel) == h:
|
|
124
|
+
skipped += 1
|
|
125
|
+
continue
|
|
126
|
+
for cid in self.store.chunk_ids_for_paths([rel]):
|
|
127
|
+
try:
|
|
128
|
+
idx.remove(cid)
|
|
129
|
+
except Exception:
|
|
130
|
+
remove_failures += 1
|
|
131
|
+
chunks = load_markdown(raw, rel)
|
|
132
|
+
if not chunks:
|
|
133
|
+
self.store.upsert_file(rel, h, [])
|
|
134
|
+
continue
|
|
135
|
+
vecs = self.embedder.embed([c.text for c in chunks])
|
|
136
|
+
ids = self.store.upsert_file(rel, h, chunks)
|
|
137
|
+
idx.add(ids, vecs)
|
|
138
|
+
indexed += 1
|
|
139
|
+
for gone in set(self.store.all_paths()) - seen:
|
|
140
|
+
for cid in self.store.chunk_ids_for_paths([gone]):
|
|
141
|
+
try:
|
|
142
|
+
idx.remove(cid)
|
|
143
|
+
except Exception:
|
|
144
|
+
remove_failures += 1
|
|
145
|
+
self.store.delete_file(gone)
|
|
146
|
+
self.store.set_meta("root", str(Path(root).resolve()))
|
|
147
|
+
idx.save()
|
|
148
|
+
st = self.store.stats()
|
|
149
|
+
return {
|
|
150
|
+
"indexed": indexed,
|
|
151
|
+
"skipped": skipped,
|
|
152
|
+
"chunks": st["chunks"],
|
|
153
|
+
"files": st["files"],
|
|
154
|
+
"remove_failures": remove_failures,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
def _normalize_allowlist(self, paths: list[str]) -> list[str]:
|
|
158
|
+
"""Map incoming file paths (absolute, $VAULT-prefixed, relative, or
|
|
159
|
+
basename) to the corpus-relative keys used in the store."""
|
|
160
|
+
stored = self.store.all_paths()
|
|
161
|
+
have = set(stored)
|
|
162
|
+
root_meta = self.store.get_meta("root")
|
|
163
|
+
root = Path(root_meta) if root_meta else None
|
|
164
|
+
out: list[str] = []
|
|
165
|
+
for raw in paths:
|
|
166
|
+
if raw in have: # already a stored relative key
|
|
167
|
+
out.append(raw)
|
|
168
|
+
continue
|
|
169
|
+
p = Path(raw)
|
|
170
|
+
matched = False
|
|
171
|
+
if root is not None:
|
|
172
|
+
try:
|
|
173
|
+
rel = p.resolve().relative_to(root).as_posix()
|
|
174
|
+
except ValueError:
|
|
175
|
+
rel = None
|
|
176
|
+
if rel and rel in have:
|
|
177
|
+
out.append(rel)
|
|
178
|
+
matched = True
|
|
179
|
+
if not matched:
|
|
180
|
+
# basename fallback (handles $VAULT-relative prefixes)
|
|
181
|
+
base = p.name
|
|
182
|
+
for h in stored:
|
|
183
|
+
if h == base or Path(h).name == base or h.endswith("/" + base):
|
|
184
|
+
out.append(h)
|
|
185
|
+
matched = True
|
|
186
|
+
# de-dup preserving order
|
|
187
|
+
seen: set[str] = set()
|
|
188
|
+
return [x for x in out if not (x in seen or seen.add(x))]
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _result(
|
|
192
|
+
ch: dict,
|
|
193
|
+
*,
|
|
194
|
+
score: float,
|
|
195
|
+
retrieval_mode: str,
|
|
196
|
+
semantic_rank: int | None = None,
|
|
197
|
+
semantic_score: float | None = None,
|
|
198
|
+
lexical_rank: int | None = None,
|
|
199
|
+
lexical_score: float | None = None,
|
|
200
|
+
fused_rank: int | None = None,
|
|
201
|
+
fused_score: float | None = None,
|
|
202
|
+
) -> dict:
|
|
203
|
+
return {
|
|
204
|
+
"path": ch["path"],
|
|
205
|
+
"heading": ch["heading"],
|
|
206
|
+
"score": score,
|
|
207
|
+
"snippet": ch["text"][:240],
|
|
208
|
+
"start": ch["start"],
|
|
209
|
+
"end": ch["end"],
|
|
210
|
+
"retrieval_mode": retrieval_mode,
|
|
211
|
+
"semantic_rank": semantic_rank,
|
|
212
|
+
"semantic_score": semantic_score,
|
|
213
|
+
"lexical_rank": lexical_rank,
|
|
214
|
+
"lexical_score": lexical_score,
|
|
215
|
+
"fused_rank": fused_rank,
|
|
216
|
+
"fused_score": fused_score,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
def query(
|
|
220
|
+
self, text: str, k: int = 10, allowlist_paths=None, hybrid: bool = False
|
|
221
|
+
) -> list[dict]:
|
|
222
|
+
if k <= 0:
|
|
223
|
+
return []
|
|
224
|
+
if hybrid and not self.store.fts5_available:
|
|
225
|
+
raise RuntimeError("SQLite FTS5 is unavailable; --hybrid cannot be used.")
|
|
226
|
+
dim = int(self.store.get_meta("dim") or self.embedder.dim())
|
|
227
|
+
idx = self._load_index(dim)
|
|
228
|
+
qv = self.embedder.embed([text])[0]
|
|
229
|
+
allow = None
|
|
230
|
+
if allowlist_paths is not None:
|
|
231
|
+
allow = self.store.chunk_ids_for_paths(self._normalize_allowlist(allowlist_paths))
|
|
232
|
+
candidate_depth = k * HYBRID_CANDIDATE_MULTIPLIER if hybrid else k
|
|
233
|
+
semantic_hits = idx.search(qv, k=candidate_depth, allowlist=allow)
|
|
234
|
+
if hybrid:
|
|
235
|
+
lexical_hits = self.store.lexical_search(text, k=candidate_depth, allowlist=allow)
|
|
236
|
+
fused_hits = reciprocal_rank_fusion(semantic_hits, lexical_hits)[:k]
|
|
237
|
+
out = []
|
|
238
|
+
for fused_rank, hit in enumerate(fused_hits, start=1):
|
|
239
|
+
try:
|
|
240
|
+
ch = self.store.get_chunk(hit["id"])
|
|
241
|
+
except KeyError:
|
|
242
|
+
continue
|
|
243
|
+
out.append(
|
|
244
|
+
self._result(
|
|
245
|
+
ch,
|
|
246
|
+
score=hit["fused_score"],
|
|
247
|
+
retrieval_mode="hybrid",
|
|
248
|
+
semantic_rank=hit.get("semantic_rank"),
|
|
249
|
+
semantic_score=hit.get("semantic_score"),
|
|
250
|
+
lexical_rank=hit.get("lexical_rank"),
|
|
251
|
+
lexical_score=hit.get("lexical_score"),
|
|
252
|
+
fused_rank=fused_rank,
|
|
253
|
+
fused_score=hit["fused_score"],
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
return out
|
|
257
|
+
out = []
|
|
258
|
+
for semantic_rank, (cid, score) in enumerate(semantic_hits, start=1):
|
|
259
|
+
try:
|
|
260
|
+
ch = self.store.get_chunk(cid)
|
|
261
|
+
except KeyError:
|
|
262
|
+
continue
|
|
263
|
+
out.append(
|
|
264
|
+
self._result(
|
|
265
|
+
ch,
|
|
266
|
+
score=score,
|
|
267
|
+
retrieval_mode="semantic",
|
|
268
|
+
semantic_rank=semantic_rank,
|
|
269
|
+
semantic_score=score,
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
return out
|
indexkit/index.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from turbovec import IdMapIndex
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class VecIndex:
|
|
10
|
+
"""Thin wrapper around turbovec's ``IdMapIndex``.
|
|
11
|
+
|
|
12
|
+
Isolates all turbovec-specific quirks (2-D batch queries, numpy
|
|
13
|
+
dtypes, return shapes) so the rest of ``indexkit`` works with plain
|
|
14
|
+
Python ``int``/``float`` ids and scores.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, dim: int, path) -> None:
|
|
18
|
+
self.dim = dim
|
|
19
|
+
self.path = Path(path)
|
|
20
|
+
self._idx = IdMapIndex(dim=dim, bit_width=4)
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def load(cls, dim: int, path) -> "VecIndex":
|
|
24
|
+
obj = cls.__new__(cls)
|
|
25
|
+
obj.dim = dim
|
|
26
|
+
obj.path = Path(path)
|
|
27
|
+
obj._idx = IdMapIndex.load(str(Path(path)))
|
|
28
|
+
return obj
|
|
29
|
+
|
|
30
|
+
def add(self, ids: list[int], vectors: list[list[float]]) -> None:
|
|
31
|
+
v = np.asarray(vectors, dtype=np.float32)
|
|
32
|
+
i = np.asarray(ids, dtype=np.uint64)
|
|
33
|
+
self._idx.add_with_ids(v, i)
|
|
34
|
+
|
|
35
|
+
def remove(self, chunk_id: int) -> None:
|
|
36
|
+
self._idx.remove(int(chunk_id))
|
|
37
|
+
|
|
38
|
+
def search(
|
|
39
|
+
self,
|
|
40
|
+
vector: list[float],
|
|
41
|
+
k: int,
|
|
42
|
+
allowlist: list[int] | None = None,
|
|
43
|
+
) -> list[tuple[int, float]]:
|
|
44
|
+
# turbovec expects a 2-D (nq, dim) query and returns
|
|
45
|
+
# (scores, ids) as (nq, effective_k) arrays. We submit a single
|
|
46
|
+
# row and read row 0 back.
|
|
47
|
+
q = np.asarray([vector], dtype=np.float32)
|
|
48
|
+
if allowlist is not None:
|
|
49
|
+
if not allowlist:
|
|
50
|
+
return []
|
|
51
|
+
allow = np.asarray(allowlist, dtype=np.uint64)
|
|
52
|
+
scores, ids = self._idx.search(q, k=k, allowlist=allow)
|
|
53
|
+
else:
|
|
54
|
+
scores, ids = self._idx.search(q, k=k)
|
|
55
|
+
return [(int(i), float(s)) for i, s in zip(ids[0], scores[0])]
|
|
56
|
+
|
|
57
|
+
def save(self) -> None:
|
|
58
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
self._idx.write(str(self.path))
|