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 ADDED
@@ -0,0 +1,3 @@
1
+ """Local semantic and hybrid RAG: ollama, turbovec, and FTS5/BM25."""
2
+
3
+ __version__ = "0.6.1"
indexkit/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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))
@@ -0,0 +1,3 @@
1
+ from .markdown import Chunk, load_markdown, iter_corpus
2
+
3
+ __all__ = ["Chunk", "load_markdown", "iter_corpus"]