sift-rag 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.
sift/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """sift: fast local search over text and Markdown files, built for AI agents."""
2
+
3
+ __version__ = "0.1.0"
sift/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
sift/cli.py ADDED
@@ -0,0 +1,109 @@
1
+ """Command-line interface."""
2
+
3
+ import argparse
4
+ import json
5
+ import sqlite3
6
+ import sys
7
+ import textwrap
8
+
9
+ from . import __version__, store
10
+
11
+
12
+ def _filters(pairs):
13
+ out = {}
14
+ for pair in pairs:
15
+ key, sep, value = pair.partition("=")
16
+ if not sep:
17
+ raise SystemExit(f"filter must be KEY=VALUE, got {pair!r}")
18
+ try:
19
+ out[key] = json.loads(value)
20
+ except ValueError:
21
+ out[key] = value
22
+ return out
23
+
24
+
25
+ def _print_hits(hits, as_json):
26
+ if as_json:
27
+ print(json.dumps(hits, ensure_ascii=False, indent=2))
28
+ return
29
+ for h in hits:
30
+ print(f"{h['score']:>8.4f} {h['id']}:{h['lines']} {h['title']}")
31
+ print(textwrap.indent(textwrap.shorten(h["text"], 300), " " * 10))
32
+
33
+
34
+ def main(argv=None):
35
+ p = argparse.ArgumentParser(prog="sift", description="Fast local search over text and Markdown files.")
36
+ p.add_argument("--version", action="version", version=f"sift {__version__}")
37
+ sub = p.add_subparsers(dest="cmd", required=True)
38
+
39
+ s = sub.add_parser("add", help="register a folder as a collection")
40
+ s.add_argument("path")
41
+ s.add_argument("--name", help="collection name (default: folder name)")
42
+ s = sub.add_parser("rm", help="remove a collection from the index (its files stay on disk)")
43
+ s.add_argument("name")
44
+ s = sub.add_parser("index", help="scan collections and update the keyword index")
45
+ s.add_argument("names", nargs="*", help="collections to scan (default: all)")
46
+ s = sub.add_parser("embed", help="embed all chunks that have no vector yet")
47
+ s.add_argument("--reset", action="store_true", help="drop all vectors first (after changing the model)")
48
+ for name, desc in (
49
+ ("search", "exact keyword search (BM25)"),
50
+ ("vsearch", "semantic search"),
51
+ ("query", "hybrid search with reranking"),
52
+ ):
53
+ s = sub.add_parser(name, help=desc)
54
+ s.add_argument("query")
55
+ s.add_argument("-k", type=int, default=10, help="number of results (default: 10)")
56
+ s.add_argument("-c", "--collection")
57
+ s.add_argument("--path", help='path glob within the collection, e.g. "2024/*"')
58
+ s.add_argument(
59
+ "-f", "--filter", action="append", default=[], metavar="KEY=VALUE", help="front-matter filter, repeatable"
60
+ )
61
+ s.add_argument("--json", action="store_true", help="print results as JSON")
62
+ s = sub.add_parser("get", help="print a document by its id (collection/path)")
63
+ s.add_argument("id")
64
+ sub.add_parser("status", help="show collections and index statistics")
65
+ s = sub.add_parser("serve", help="run the MCP server")
66
+ s.add_argument("--host", default="127.0.0.1")
67
+ s.add_argument("--port", type=int, default=8765)
68
+ s.add_argument("--stdio", action="store_true", help="serve over stdio instead of HTTP")
69
+
70
+ a = p.parse_args(argv)
71
+ if (sys.stdout.encoding or "").lower() != "utf-8": # e.g. a Windows pipe: titles may be any script
72
+ sys.stdout.reconfigure(encoding="utf-8")
73
+ db = store.connect()
74
+ try:
75
+ if a.cmd == "add":
76
+ name = store.add_collection(db, a.path, a.name)
77
+ print(f"added collection {name!r}; run `sift index` next", file=sys.stderr)
78
+ elif a.cmd == "rm":
79
+ n = store.remove_collection(db, a.name)
80
+ documents = "document" if n == 1 else "documents"
81
+ print(f"removed collection {a.name!r} and its {n} {documents} from the index;", end=" ", file=sys.stderr)
82
+ print("the files on disk are untouched", file=sys.stderr)
83
+ elif a.cmd == "index":
84
+ store.index(db, a.names)
85
+ elif a.cmd == "embed":
86
+ from .embed import embed_pending
87
+
88
+ embed_pending(db, a.reset)
89
+ elif a.cmd in ("search", "vsearch", "query"):
90
+ from .search import Index
91
+
92
+ fn = getattr(Index(db), a.cmd)
93
+ _print_hits(fn(a.query, a.k, a.collection, a.path, _filters(a.filter)), a.json)
94
+ elif a.cmd == "get":
95
+ from .search import Index
96
+
97
+ sys.stdout.write(Index(db).get(a.id))
98
+ elif a.cmd == "status":
99
+ from .search import Index
100
+
101
+ print(json.dumps(Index(db).status(), indent=2))
102
+ else: # serve
103
+ from .server import serve
104
+
105
+ serve(a.host, a.port, a.stdio)
106
+ except (LookupError, ValueError, sqlite3.Error) as e:
107
+ raise SystemExit(f"error: {e}") from None
108
+ except KeyboardInterrupt:
109
+ raise SystemExit(130) from None
sift/embed.py ADDED
@@ -0,0 +1,259 @@
1
+ """Embedding and reranking models, and the batched GPU embedding pipeline."""
2
+
3
+ import contextlib
4
+ import logging
5
+ import os
6
+ import sys
7
+ import time
8
+ from functools import cache
9
+
10
+ import numpy as np
11
+ import torch
12
+
13
+ from . import store
14
+
15
+ # Loading Sentence Transformers makes PyTorch log a harmless note about distributed training on Windows and macOS.
16
+ logging.getLogger("torch.distributed.elastic.multiprocessing.redirects").setLevel(logging.ERROR)
17
+ # The MCP server logs at INFO, where loading a model logs every HTTP request to Hugging Face.
18
+ for _name in ("httpx", "huggingface_hub", "sentence_transformers"):
19
+ logging.getLogger(_name).setLevel(logging.WARNING)
20
+
21
+ GiB = 2**30
22
+
23
+
24
+ def _device():
25
+ """An NVIDIA GPU (CUDA) if there is one, else an Apple GPU (MPS), else the CPU."""
26
+ if torch.cuda.is_available():
27
+ return "cuda"
28
+ return "mps" if torch.backends.mps.is_available() else "cpu"
29
+
30
+
31
+ EMBED_MODEL = os.environ.get("SIFT_EMBED_MODEL", "Qwen/Qwen3-Embedding-0.6B")
32
+ RERANK_MODEL = os.environ.get("SIFT_RERANK_MODEL", "Alibaba-NLP/gte-reranker-modernbert-base")
33
+ DEVICE = _device()
34
+ MAX_TOKENS = 1024
35
+ SLAB = 20000 # chunks per commit; length sorting happens within a slab
36
+ HEADROOM = 0.1 # share of the GPU left free for other programs, at least 1 GiB
37
+ MIN_BUDGET = 2 * GiB # with less GPU memory than this, the models run on the CPU
38
+ FILL = 0.9 # share of the memory budget a calibrated batch may use
39
+ MAX_BATCH = 1024
40
+ PROGRESS_BATCHES = 8 # report progress after this many batches
41
+
42
+ _batch_size = 128 # until calibrated on CUDA; a good size for CPUs and Apple GPUs
43
+
44
+
45
+ def fp16():
46
+ """Whether models and vectors use fp16: yes on GPUs, no on CPUs, which are slow at fp16 matmul."""
47
+ return torch.device(DEVICE).type in ("cuda", "mps")
48
+
49
+
50
+ def _free_gpu_memory():
51
+ """Free and total memory of the GPU. On Windows (WDDM), CUDA counts memory other programs hold as free,
52
+ since the driver could page it out; NVML, which nvidia-smi reads, does not. The smaller value wins."""
53
+ free, total = torch.cuda.mem_get_info(DEVICE)
54
+ with contextlib.suppress(Exception): # NVML missing or unusable: CUDA's figure is all there is
55
+ free = min(free, total - torch.cuda.device_memory_used(DEVICE))
56
+ return free, total
57
+
58
+
59
+ def cuda_pytorch_command(platform=sys.platform):
60
+ """How to replace a CPU-only PyTorch with the CUDA build. Windows wheels bundle their CUDA libraries, so
61
+ torch's other dependencies can stay; Linux wheels depend on nvidia-* packages, which must come along."""
62
+ no_deps = "--no-deps " if platform == "win32" else ""
63
+ return f"pip install --force-reinstall {no_deps}torch --index-url https://download.pytorch.org/whl/cu128"
64
+
65
+
66
+ def _unused_nvidia_gpu():
67
+ """Why an NVIDIA GPU sits unused, if there is one; None otherwise."""
68
+ try:
69
+ import pynvml
70
+
71
+ pynvml.nvmlInit()
72
+ if not pynvml.nvmlDeviceGetCount():
73
+ return None
74
+ except Exception: # no NVML, no driver, no GPU
75
+ return None
76
+ if torch.version.cuda is None:
77
+ return (
78
+ "found an NVIDIA GPU, but this PyTorch build has no CUDA support, so sift runs on the CPU (much slower). "
79
+ "To use the GPU, reinstall with uv: `uv tool install --reinstall --torch-backend auto sift-rag` "
80
+ f"(or run `uvx --torch-backend auto sift-rag ...`), or with pip: `{cuda_pytorch_command()}`"
81
+ )
82
+ return f"found an NVIDIA GPU, but PyTorch (CUDA {torch.version.cuda}) can't use it; update the NVIDIA driver"
83
+
84
+
85
+ @cache
86
+ def limit_gpu_memory():
87
+ """Cap PyTorch's GPU memory at what is free now, minus headroom for other programs, and return that budget.
88
+
89
+ Past the cap, PyTorch raises out-of-memory inside sift instead of taking memory that other programs need.
90
+ With too little free memory, sift runs on the CPU instead.
91
+ """
92
+ global DEVICE
93
+ if torch.device(DEVICE).type != "cuda":
94
+ if DEVICE == "cpu" and (reason := _unused_nvidia_gpu()):
95
+ print(reason, file=sys.stderr)
96
+ return None
97
+ free, total = _free_gpu_memory()
98
+ budget = free - max(HEADROOM * total, GiB)
99
+ if budget < MIN_BUDGET:
100
+ print(f"only {free / GiB:.1f} GB of GPU memory is free; using the CPU (slower)", file=sys.stderr)
101
+ DEVICE = "cpu"
102
+ return None
103
+ index = torch.device(DEVICE).index # PyTorch wants an index here; plain "cuda" means the current device
104
+ torch.cuda.set_per_process_memory_fraction(budget / total, torch.cuda.current_device() if index is None else index)
105
+ print(f"using at most {budget / GiB:.1f} of {total / GiB:.1f} GB of GPU memory", file=sys.stderr)
106
+ return budget
107
+
108
+
109
+ @cache
110
+ def embedder():
111
+ from sentence_transformers import SentenceTransformer
112
+
113
+ limit_gpu_memory()
114
+ model = SentenceTransformer(EMBED_MODEL, device=DEVICE)
115
+ if fp16():
116
+ model.half()
117
+ model.max_seq_length = min(model.max_seq_length or MAX_TOKENS, MAX_TOKENS)
118
+ return model
119
+
120
+
121
+ @cache
122
+ def reranker():
123
+ from sentence_transformers import CrossEncoder
124
+
125
+ limit_gpu_memory()
126
+ model = CrossEncoder(RERANK_MODEL, device=DEVICE, max_length=MAX_TOKENS)
127
+ if fp16():
128
+ model.model.half()
129
+ return model
130
+
131
+
132
+ def _oom_safe(fn, items):
133
+ """Run fn(items, batch_size), halving the batch size when the GPU runs out of memory."""
134
+ global _batch_size
135
+ while True:
136
+ try:
137
+ return fn(items, _batch_size)
138
+ except RuntimeError as e: # CUDA raises OutOfMemoryError, a RuntimeError; MPS a plain RuntimeError
139
+ cuda = isinstance(e, torch.cuda.OutOfMemoryError)
140
+ if _batch_size == 1 or not (cuda or "MPS backend out of memory" in str(e)):
141
+ raise
142
+ (torch.cuda if cuda else torch.mps).empty_cache()
143
+ _batch_size //= 2
144
+ print(f"\nout of GPU memory, batch size now {_batch_size}", file=sys.stderr)
145
+
146
+
147
+ def calibrate_batch_size(sample=None):
148
+ """Measure the largest batch that fits the GPU memory budget, and use it from now on.
149
+
150
+ Batches of the sample, which should be the longest text to embed (default: a text of the model's maximum
151
+ length), run with sizes 1, 2, 4, ... until one uses half of the budget. Memory grows linearly with the batch
152
+ size, so the last measurement predicts the size that fills FILL of the budget. A size that ran out of memory
153
+ is never chosen.
154
+ """
155
+ global _batch_size
156
+ model = embedder()
157
+ budget = limit_gpu_memory()
158
+ if budget is None:
159
+ return _batch_size
160
+ longest = sample or "x " * (4 * model.max_seq_length) # the default is truncated to the longest input
161
+ base = torch.cuda.memory_allocated(DEVICE)
162
+ target = FILL * budget - base
163
+
164
+ def cost(n):
165
+ torch.cuda.reset_peak_memory_stats(DEVICE)
166
+ model.encode_document([longest] * n, batch_size=n, show_progress_bar=False)
167
+ return torch.cuda.max_memory_allocated(DEVICE) - base
168
+
169
+ one = used = cost(1)
170
+ n, failed = 1, MAX_BATCH + 1
171
+ while n < MAX_BATCH and used < target / 2:
172
+ try:
173
+ used = cost(2 * n)
174
+ except torch.cuda.OutOfMemoryError:
175
+ failed = 2 * n
176
+ break
177
+ n *= 2
178
+ torch.cuda.empty_cache()
179
+ per_text = (used - one) / (n - 1) if n > 1 else one
180
+ _batch_size = max(1, min(failed - 1, MAX_BATCH, 1 + int((target - one) / per_text)))
181
+ print(f"batch size {_batch_size}, calibrated to the GPU memory budget", file=sys.stderr)
182
+ return _batch_size
183
+
184
+
185
+ def encode(texts, query=False):
186
+ """Normalised fp16 embeddings, one row per text. Sentence Transformers sorts by length to minimise padding."""
187
+ model = embedder()
188
+ fn = model.encode_query if query else model.encode_document
189
+ return _oom_safe(
190
+ lambda t, bs: fn(t, batch_size=bs, normalize_embeddings=True, convert_to_numpy=True, show_progress_bar=False),
191
+ texts,
192
+ ).astype(np.float16)
193
+
194
+
195
+ def rerank(query, texts):
196
+ model = reranker()
197
+ return _oom_safe(
198
+ lambda t, bs: model.predict([(query, x) for x in t], batch_size=bs, show_progress_bar=False), texts
199
+ )
200
+
201
+
202
+ def _text(seq, title, text):
203
+ """What gets embedded: chunks after the first repeat their document title, unless they already start with it."""
204
+ return text if seq == 0 or text.lstrip("# ").startswith(title) else f"{title}\n\n{text}"
205
+
206
+
207
+ def embed_pending(db, reset=False):
208
+ """Embed every chunk without a vector, slab by slab. Safe to interrupt and resume."""
209
+ if reset:
210
+ db.execute("DELETE FROM vectors")
211
+ db.execute("DELETE FROM meta WHERE key IN ('model', 'dim')")
212
+ store.touch(db)
213
+ db.commit()
214
+ model = store.get_meta(db, "model")
215
+ if model and model != EMBED_MODEL:
216
+ raise SystemExit(f"index was embedded with {model}; run `sift embed --reset` to switch to {EMBED_MODEL}")
217
+ pending = """SELECT c.id, c.seq, fts.title, fts.text FROM chunks c JOIN fts ON fts.rowid = c.id
218
+ WHERE c.id > ? AND NOT EXISTS (SELECT 1 FROM vectors v WHERE v.chunk = c.id) ORDER BY c.id LIMIT ?"""
219
+ total = db.execute(
220
+ "SELECT count(*) FROM chunks c WHERE NOT EXISTS (SELECT 1 FROM vectors v WHERE v.chunk = c.id)"
221
+ ).fetchone()[0]
222
+ if not total:
223
+ print("nothing to embed", file=sys.stderr)
224
+ return
225
+ # calibrate on the longest text of this run: no batch can need more memory per text
226
+ candidates = db.execute(
227
+ """SELECT c.seq, fts.title, fts.text FROM chunks c JOIN fts ON fts.rowid = c.id
228
+ WHERE NOT EXISTS (SELECT 1 FROM vectors v WHERE v.chunk = c.id)
229
+ ORDER BY length(fts.text) + length(fts.title) DESC LIMIT 100"""
230
+ ).fetchall()
231
+ calibrate_batch_size(max((_text(*row) for row in candidates), key=len))
232
+ done, last, t0 = 0, 0, time.perf_counter()
233
+
234
+ def progress(n):
235
+ rate = n / (time.perf_counter() - t0)
236
+ left = max(total - n, 0) / rate / 60
237
+ print(f"\rembedded {n}/{total} chunks, {rate:.0f}/s, {left:.1f} min left ", end="", file=sys.stderr, flush=True)
238
+
239
+ while rows := db.execute(pending, (last, SLAB)).fetchall():
240
+ texts = [_text(seq, title, text) for _, seq, title, text in rows]
241
+ # longest first, in steps of a few batches: similar lengths per batch, and progress every few seconds
242
+ order = sorted(range(len(texts)), key=lambda i: -len(texts[i]))
243
+ vecs, step = None, _batch_size * PROGRESS_BATCHES
244
+ for start in range(0, len(order), step):
245
+ part = order[start : start + step]
246
+ out = encode([texts[i] for i in part])
247
+ if vecs is None:
248
+ vecs = np.empty((len(texts), out.shape[1]), np.float16)
249
+ vecs[part] = out
250
+ progress(done + start + len(part))
251
+ db.executemany(
252
+ "INSERT OR REPLACE INTO vectors(chunk, vec) VALUES(?, ?)", [(r[0], v.tobytes()) for r, v in zip(rows, vecs)]
253
+ )
254
+ store.set_meta(db, "model", EMBED_MODEL)
255
+ store.set_meta(db, "dim", vecs.shape[1])
256
+ store.touch(db) # tells a running server to reload vectors
257
+ db.commit()
258
+ done, last = done + len(rows), rows[-1][0]
259
+ print(f"\rembedded {done} chunks in {(time.perf_counter() - t0) / 60:.1f} min" + " " * 20, file=sys.stderr)
sift/search.py ADDED
@@ -0,0 +1,176 @@
1
+ """The three search modes: exact keyword (BM25), semantic (exact GPU vector search) and hybrid with reranking."""
2
+
3
+ import re
4
+ import sqlite3
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+
9
+ from . import store
10
+
11
+ STOPWORDS = set(
12
+ "a an and are as at be by can do does for from has have how i in is it of on or that the this to "
13
+ "was what when where which who why with".split()
14
+ )
15
+ RRF_K = 60
16
+
17
+
18
+ def _where(db, collection=None, path=None, filters=None):
19
+ """SQL condition on docs `d`. Filters match front-matter fields; list fields match if they contain the value."""
20
+ sql, args = ["1"], []
21
+ if collection:
22
+ row = db.execute("SELECT id FROM collections WHERE name = ?", (collection,)).fetchone()
23
+ if not row:
24
+ raise LookupError(f"no collection named {collection!r}")
25
+ sql.append("d.collection = ?")
26
+ args.append(row[0])
27
+ if path:
28
+ sql.append("d.path GLOB ?")
29
+ args.append(path)
30
+ for key, value in (filters or {}).items():
31
+ if not re.fullmatch(r"\w+", key):
32
+ raise ValueError(f"invalid filter field {key!r}")
33
+ if not isinstance(value, str | int | float):
34
+ raise ValueError(f"filter {key!r} needs a single string or number, got {value!r}")
35
+ sql.append(f"EXISTS (SELECT 1 FROM json_each(d.meta, '$.{key}') WHERE value = ?)")
36
+ args.append(value)
37
+ return " AND ".join(sql), args
38
+
39
+
40
+ def _check_k(k):
41
+ if not isinstance(k, int) or k < 1:
42
+ raise ValueError(f"k must be a positive integer, got {k!r}")
43
+ return k
44
+
45
+
46
+ def _phrases(query):
47
+ """Fallback for input that isn't valid FTS5 syntax: every word becomes an exact phrase, all required."""
48
+ return " ".join('"' + w.replace('"', '""') + '"' for w in query.split())
49
+
50
+
51
+ def _any_word(query):
52
+ """OR query over the meaningful words, used to collect keyword candidates for hybrid search."""
53
+ words = [w for w in re.findall(r"\w+", query.lower()) if w not in STOPWORDS and len(w) > 1]
54
+ return " OR ".join(f'"{w}"' for w in dict.fromkeys(words))
55
+
56
+
57
+ class Index:
58
+ def __init__(self, db=None):
59
+ self.db = db or store.connect()
60
+ self.version = None
61
+
62
+ # --- candidates as [(chunk_id, score)] -----------------------------------
63
+
64
+ def _keyword(self, match, k, where, args):
65
+ if not match.strip():
66
+ return []
67
+ sql = f"""SELECT fts.rowid, -bm25(fts, 2.0, 1.0) FROM fts JOIN chunks c ON c.id = fts.rowid
68
+ JOIN docs d ON d.id = c.doc WHERE fts MATCH ? AND {where} ORDER BY bm25(fts, 2.0, 1.0) LIMIT ?"""
69
+ for attempt in (match, _phrases(match)):
70
+ try:
71
+ return self.db.execute(sql, [attempt, *args, k]).fetchall()
72
+ except sqlite3.OperationalError as e:
73
+ error = e
74
+ raise ValueError(f"invalid search query {match!r}: {error}")
75
+
76
+ def _vectors(self):
77
+ """Load all vectors into one (GPU) matrix; reload when the index has changed since the last load."""
78
+ import torch
79
+
80
+ from .embed import DEVICE, fp16
81
+
82
+ version = store.get_meta(self.db, "version")
83
+ if version is None or version != self.version:
84
+ n = self.db.execute("SELECT count(*) FROM vectors").fetchone()[0]
85
+ if not n:
86
+ raise LookupError("no embeddings yet, run `sift embed`")
87
+ ids, mat = np.empty(n, np.int64), np.empty((n, int(store.get_meta(self.db, "dim"))), np.float16)
88
+ for row, (chunk, vec) in enumerate(self.db.execute("SELECT chunk, vec FROM vectors ORDER BY chunk")):
89
+ ids[row], mat[row] = chunk, np.frombuffer(vec, np.float16)
90
+ self.ids, self.mat = ids, torch.from_numpy(mat).to(DEVICE, torch.float16 if fp16() else torch.float32)
91
+ self.version = version
92
+ return self.ids, self.mat
93
+
94
+ def _semantic(self, query, k, where, args):
95
+ import torch
96
+
97
+ from .embed import encode
98
+
99
+ ids, mat = self._vectors()
100
+ q = torch.from_numpy(encode([query], query=True)[0]).to(mat.device, mat.dtype)
101
+ scores = mat @ q
102
+ if args: # restrict to chunks of matching documents
103
+ sql = f"SELECT c.id FROM chunks c JOIN docs d ON d.id = c.doc WHERE {where}"
104
+ allowed = np.array([c for (c,) in self.db.execute(sql, args)], np.int64)
105
+ pos = np.searchsorted(ids, allowed)
106
+ pos = pos[(pos < len(ids)) & (ids[np.minimum(pos, len(ids) - 1)] == allowed)]
107
+ keep = torch.zeros(len(ids), dtype=torch.bool, device=mat.device)
108
+ keep[torch.from_numpy(pos).to(mat.device)] = True
109
+ scores = scores.masked_fill(~keep, float("-inf"))
110
+ top = torch.topk(scores.float(), min(k, len(ids)))
111
+ return [(int(ids[i]), s) for s, i in zip(top.values.tolist(), top.indices.tolist()) if s > float("-inf")]
112
+
113
+ def _hits(self, ranked):
114
+ sql = """SELECT co.name, d.path, d.title, c.line_start, c.line_end, fts.text FROM chunks c
115
+ JOIN docs d ON d.id = c.doc JOIN collections co ON co.id = d.collection JOIN fts ON fts.rowid = c.id
116
+ WHERE c.id = ?"""
117
+ hits = []
118
+ for chunk, score in ranked:
119
+ row = self.db.execute(sql, (chunk,)).fetchone()
120
+ if row is None: # removed by a concurrent `sift index`
121
+ continue
122
+ name, path, title, start, end, text = row
123
+ hit = {"id": f"{name}/{path}", "title": title, "lines": f"{start}-{end}", "score": round(score, 4)}
124
+ hits.append(hit | {"text": text})
125
+ return hits
126
+
127
+ # --- public API ----------------------------------------------------------
128
+
129
+ def search(self, query, k=10, collection=None, path=None, filters=None):
130
+ """Exact keyword search, ranked by BM25. Accepts FTS5 syntax: "phrases", prefix*, AND, OR, NOT."""
131
+ return self._hits(self._keyword(query, _check_k(k), *_where(self.db, collection, path, filters)))
132
+
133
+ def vsearch(self, query, k=10, collection=None, path=None, filters=None):
134
+ """Semantic search: cosine similarity between the query and every chunk."""
135
+ return self._hits(self._semantic(query, _check_k(k), *_where(self.db, collection, path, filters)))
136
+
137
+ def query(self, query, k=10, collection=None, path=None, filters=None, candidates=40):
138
+ """Hybrid search for RAG: keyword and semantic candidates, fused by reciprocal rank, then reranked."""
139
+ from .embed import rerank
140
+
141
+ k, where = _check_k(k), _where(self.db, collection, path, filters)
142
+ fused = {}
143
+ for ranked in (self._keyword(_any_word(query), candidates, *where), self._semantic(query, candidates, *where)):
144
+ for rank, (chunk, _) in enumerate(ranked):
145
+ fused[chunk] = fused.get(chunk, 0) + 1 / (RRF_K + rank)
146
+ hits = self._hits(sorted(fused.items(), key=lambda x: -x[1]))
147
+ if not hits:
148
+ return []
149
+ scores = rerank(query, [f"{h['title']}\n\n{h['text']}" for h in hits])
150
+ for h, s in zip(hits, scores):
151
+ h["score"] = round(float(s), 4)
152
+ return sorted(hits, key=lambda h: -h["score"])[:k]
153
+
154
+ def get(self, id):
155
+ """Full text of a document, by its `collection/path` id."""
156
+ name, _, rel = id.partition("/")
157
+ row = self.db.execute("SELECT path FROM collections WHERE name = ?", (name,)).fetchone()
158
+ root = Path(row[0]).resolve() if row else None
159
+ path = (root / rel).resolve() if root and rel else None
160
+ if path is None or not path.is_relative_to(root):
161
+ raise LookupError(f"unknown document {id!r}")
162
+ try:
163
+ return path.read_text("utf-8-sig", "replace")
164
+ except OSError as e:
165
+ raise LookupError(f"cannot read {id!r}: {e.strerror or e}") from None
166
+
167
+ def status(self):
168
+ rows = self.db.execute("""
169
+ SELECT co.name, co.path, count(DISTINCT d.id), count(c.id), count(v.chunk) FROM collections co
170
+ LEFT JOIN docs d ON d.collection = co.id LEFT JOIN chunks c ON c.doc = d.id
171
+ LEFT JOIN vectors v ON v.chunk = c.id GROUP BY co.id ORDER BY co.name""").fetchall()
172
+ return {
173
+ "index": self.db.execute("PRAGMA database_list").fetchone()[2],
174
+ "model": store.get_meta(self.db, "model"),
175
+ "collections": [{"name": n, "path": p, "docs": d, "chunks": c, "embedded": e} for n, p, d, c, e in rows],
176
+ }
sift/server.py ADDED
@@ -0,0 +1,110 @@
1
+ """MCP server exposing search, vsearch, query, get and status."""
2
+
3
+ import sys
4
+ from typing import Any
5
+
6
+ from mcp.server.mcpserver import MCPServer
7
+ from mcp.server.mcpserver.exceptions import ToolError
8
+
9
+ from . import embed
10
+ from .search import Index
11
+
12
+ INSTRUCTIONS = """Search the user's local text and Markdown files, indexed by sift.
13
+ When a question touches what these collections cover, search here before answering from memory or searching the web,
14
+ and cite what you find; say so when nothing relevant turns up.
15
+
16
+ {collections}
17
+
18
+ - `query`: hybrid search with reranking. Best default for questions; slower than the others.
19
+ - `search`: exact keyword search (BM25). Best for names, identifiers, exact terms and "quoted phrases".
20
+ - `vsearch`: semantic search. Best when the wording of the answer is unknown.
21
+ Results carry `id` (collection/path), `lines`, `title`, `score` and chunk `text`. Use `get` for a full document.
22
+ All search tools accept `collection`, a `path` glob such as "2024/*", and `filters` on front-matter fields,
23
+ e.g. {{"categories": "cs.RO"}}; a list field matches when it contains the value."""
24
+
25
+
26
+ def _collections(index):
27
+ """The indexed collections with their sizes, read when the server starts."""
28
+ rows = index.db.execute("""SELECT co.name, count(d.id) FROM collections co LEFT JOIN docs d ON d.collection = co.id
29
+ GROUP BY co.id ORDER BY co.name""").fetchall()
30
+ if not rows:
31
+ return "No collections are indexed yet."
32
+ lines = [f"- {name}: {n:,} document{'' if n == 1 else 's'}" for name, n in rows]
33
+ return "Indexed collections:\n" + "\n".join(lines)
34
+
35
+
36
+ def _run(fn, *args):
37
+ """Call an Index method, turning expected failures into ToolError, the only kind whose message reaches the
38
+ agent; anything else counts as a crash, and the MCP library tells the agent just "Error executing tool"."""
39
+ try:
40
+ return fn(*args)
41
+ except (LookupError, ValueError) as e:
42
+ raise ToolError(str(e)) from None
43
+
44
+
45
+ def build(index=None):
46
+ # Tools are async, so they run one at a time on the event-loop thread. That serialises GPU and SQLite work, and
47
+ # keeps PyTorch out of worker threads, where it crashes on Python 3.15 (Windows, macOS).
48
+ index = index or Index()
49
+ mcp = MCPServer("sift", instructions=INSTRUCTIONS.format(collections=_collections(index)))
50
+
51
+ @mcp.tool()
52
+ async def search(
53
+ query: str,
54
+ k: int = 10,
55
+ collection: str | None = None,
56
+ path: str | None = None,
57
+ filters: dict[str, Any] | None = None,
58
+ ) -> list[dict]:
59
+ """Exact keyword search ranked by BM25. Supports "phrases", prefix*, AND, OR, NOT."""
60
+ return _run(index.search, query, k, collection, path, filters)
61
+
62
+ @mcp.tool()
63
+ async def vsearch(
64
+ query: str,
65
+ k: int = 10,
66
+ collection: str | None = None,
67
+ path: str | None = None,
68
+ filters: dict[str, Any] | None = None,
69
+ ) -> list[dict]:
70
+ """Semantic search: chunks closest in meaning to the query."""
71
+ return _run(index.vsearch, query, k, collection, path, filters)
72
+
73
+ @mcp.tool()
74
+ async def query(
75
+ query: str,
76
+ k: int = 10,
77
+ collection: str | None = None,
78
+ path: str | None = None,
79
+ filters: dict[str, Any] | None = None,
80
+ ) -> list[dict]:
81
+ """Hybrid search for RAG: keyword and semantic candidates, reranked by a cross-encoder."""
82
+ return _run(index.query, query, k, collection, path, filters)
83
+
84
+ @mcp.tool()
85
+ async def get(id: str) -> str:
86
+ """Full text of a document, by the `id` shown in search results."""
87
+ return _run(index.get, id)
88
+
89
+ @mcp.tool()
90
+ async def status() -> dict:
91
+ """Collections, document and chunk counts, and the embedding model."""
92
+ return _run(index.status)
93
+
94
+ return mcp, index
95
+
96
+
97
+ def serve(host="127.0.0.1", port=8765, stdio=False):
98
+ mcp, index = build()
99
+ print("loading models and vectors ...", file=sys.stderr)
100
+ embed.embedder()
101
+ embed.reranker()
102
+ try:
103
+ index._vectors()
104
+ except LookupError as e:
105
+ print(f"warning: {e}", file=sys.stderr)
106
+ if stdio:
107
+ mcp.run("stdio")
108
+ else:
109
+ print(f"sift MCP server on http://{host}:{port}/mcp", file=sys.stderr)
110
+ mcp.run("streamable-http", host=host, port=port)
sift/store.py ADDED
@@ -0,0 +1,296 @@
1
+ """SQLite store: collections, documents, chunks, keyword index and vectors."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import sqlite3
8
+ import sys
9
+ import time
10
+ import uuid
11
+ from concurrent.futures import ProcessPoolExecutor
12
+ from contextlib import nullcontext
13
+ from pathlib import Path
14
+
15
+ HOME = Path(os.environ.get("SIFT_HOME", Path.home() / ".sift"))
16
+ EXTS = (".md", ".markdown", ".txt")
17
+ SKIP_DIRS = {"node_modules", "__pycache__"}
18
+ CHUNK_CHARS, OVERLAP_CHARS = 3000, 300 # ~750 tokens, well inside the models' 1024-token window
19
+ WRITE_BATCH = 5000
20
+ FILES_PER_WORKER = 1000 # a worker process only pays off for at least this many files
21
+
22
+ SCHEMA = """
23
+ CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT);
24
+ CREATE TABLE IF NOT EXISTS collections(id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL, path TEXT NOT NULL);
25
+ CREATE TABLE IF NOT EXISTS docs(id INTEGER PRIMARY KEY, collection INTEGER NOT NULL, path TEXT NOT NULL,
26
+ mtime INTEGER, size INTEGER, hash TEXT, title TEXT, meta TEXT, UNIQUE(collection, path));
27
+ CREATE TABLE IF NOT EXISTS chunks(id INTEGER PRIMARY KEY, doc INTEGER NOT NULL, seq INTEGER,
28
+ line_start INTEGER, line_end INTEGER);
29
+ CREATE INDEX IF NOT EXISTS chunks_doc ON chunks(doc);
30
+ CREATE TABLE IF NOT EXISTS vectors(chunk INTEGER PRIMARY KEY, vec BLOB NOT NULL);
31
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(title, text, tokenize='unicode61 remove_diacritics 2');
32
+ """
33
+
34
+
35
+ def connect(home=None) -> sqlite3.Connection:
36
+ home = Path(home or HOME)
37
+ home.mkdir(parents=True, exist_ok=True)
38
+ db = sqlite3.connect(home / "sift.db", timeout=30, check_same_thread=False)
39
+ db.execute("PRAGMA journal_mode=WAL")
40
+ db.execute("PRAGMA synchronous=NORMAL")
41
+ db.execute("PRAGMA cache_size=-262144") # 256 MB
42
+ db.executescript(SCHEMA)
43
+ return db
44
+
45
+
46
+ def get_meta(db, key, default=None):
47
+ row = db.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
48
+ return row[0] if row else default
49
+
50
+
51
+ def set_meta(db, key, value):
52
+ db.execute("INSERT OR REPLACE INTO meta(key, value) VALUES(?, ?)", (key, str(value)))
53
+
54
+
55
+ def touch(db):
56
+ """Mark the vectors as changed, so a running server reloads them. Random, as clocks can tick too slowly."""
57
+ set_meta(db, "version", uuid.uuid4().hex)
58
+
59
+
60
+ # --- collections -------------------------------------------------------------
61
+
62
+
63
+ def add_collection(db, path, name=None):
64
+ path = Path(path).resolve()
65
+ if not path.is_dir():
66
+ raise SystemExit(f"not a directory: {path}")
67
+ name = name or path.name
68
+ if not re.fullmatch(r"[\w.-]+", name):
69
+ raise SystemExit(f"invalid collection name {name!r}: use letters, digits, '_', '-' and '.'")
70
+ try:
71
+ db.execute("INSERT INTO collections(name, path) VALUES(?, ?)", (name, str(path)))
72
+ except sqlite3.IntegrityError:
73
+ raise SystemExit(f"collection {name!r} already exists") from None
74
+ db.commit()
75
+ return name
76
+
77
+
78
+ def remove_collection(db, name):
79
+ """Remove a collection from the index; its files stay on disk. Returns the number of documents removed."""
80
+ row = db.execute("SELECT id FROM collections WHERE name=?", (name,)).fetchone()
81
+ if not row:
82
+ raise SystemExit(f"no collection named {name!r}")
83
+ docs = [d for (d,) in db.execute("SELECT id FROM docs WHERE collection=?", row)]
84
+ delete_docs(db, docs)
85
+ db.execute("DELETE FROM collections WHERE id=?", row)
86
+ db.commit()
87
+ return len(docs)
88
+
89
+
90
+ def delete_docs(db, doc_ids):
91
+ for i in range(0, len(doc_ids), 500):
92
+ batch = doc_ids[i : i + 500]
93
+ marks = ",".join("?" * len(batch))
94
+ chunk_ids = f"SELECT id FROM chunks WHERE doc IN ({marks})"
95
+ db.execute(f"DELETE FROM fts WHERE rowid IN ({chunk_ids})", batch)
96
+ db.execute(f"DELETE FROM vectors WHERE chunk IN ({chunk_ids})", batch)
97
+ db.execute(f"DELETE FROM chunks WHERE doc IN ({marks})", batch)
98
+ db.execute(f"DELETE FROM docs WHERE id IN ({marks})", batch)
99
+ if doc_ids:
100
+ touch(db)
101
+
102
+
103
+ # --- parsing and chunking ----------------------------------------------------
104
+
105
+ FRONT_MATTER = re.compile(r"\A---\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)", re.DOTALL)
106
+ HEADING = re.compile(r"^#{1,6}\s+(.+?)\s*#*\s*$", re.MULTILINE)
107
+
108
+
109
+ def _value(text):
110
+ """A front-matter value: JSON if it parses (lists, numbers, quoted strings), else plain text."""
111
+ try:
112
+ return json.loads(text, parse_constant=str) # NaN/Infinity stay strings: SQLite's JSON rejects them
113
+ except ValueError:
114
+ return text.strip("'\"")
115
+
116
+
117
+ def parse(text, fallback_title):
118
+ """Split off YAML front matter (flat `key: value` lines). Returns title, meta, body, body's first line."""
119
+ meta, first_line = {}, 1
120
+ m = FRONT_MATTER.match(text)
121
+ if m:
122
+ for line in m.group(1).splitlines():
123
+ key, sep, value = line.partition(":")
124
+ if sep and key.strip() and not key[0].isspace():
125
+ meta[key.strip()] = _value(value.strip())
126
+ first_line += text.count("\n", 0, m.end())
127
+ text = text[m.end() :]
128
+ title = meta.get("title") if isinstance(meta.get("title"), str) and meta["title"].strip() else None
129
+ if not title:
130
+ h = HEADING.search(text)
131
+ title = h.group(1) if h else fallback_title
132
+ return title, meta, text, first_line
133
+
134
+
135
+ def chunk(text, first_line=1):
136
+ """Split text into pieces of at most CHUNK_CHARS on line boundaries, preferring headings, with a small overlap.
137
+
138
+ Yields (first line, last line, text).
139
+ """
140
+ lines = []
141
+ for n, line in enumerate(text.splitlines(keepends=True), first_line):
142
+ while len(line) > CHUNK_CHARS: # break overlong lines at whitespace
143
+ cut = line.rfind(" ", 0, CHUNK_CHARS) + 1 or CHUNK_CHARS
144
+ lines.append((n, line[:cut]))
145
+ line = line[cut:]
146
+ lines.append((n, line))
147
+
148
+ pieces, buf, size = [], [], 0
149
+ for n, line in lines:
150
+ heading = line.startswith("#")
151
+ if buf and (size + len(line) > CHUNK_CHARS or (heading and size > CHUNK_CHARS // 2)):
152
+ pieces.append(buf)
153
+ buf, size = [], 0 # carry trailing lines over as overlap, unless a new section starts
154
+ for item in reversed(pieces[-1] if not heading else []):
155
+ if size + len(item[1]) > min(OVERLAP_CHARS, CHUNK_CHARS - len(line)):
156
+ break
157
+ buf.insert(0, item)
158
+ size += len(item[1])
159
+ buf.append((n, line))
160
+ size += len(line)
161
+ pieces.append(buf)
162
+
163
+ for piece in pieces:
164
+ body = "".join(text for _, text in piece).strip()
165
+ if body:
166
+ yield piece[0][0], piece[-1][0], body
167
+
168
+
169
+ def load(path):
170
+ """Read and parse one file (runs in worker processes). Returns None if unreadable."""
171
+ try:
172
+ raw = Path(path).read_bytes()
173
+ except OSError:
174
+ return None
175
+ text = raw.decode("utf-8-sig", "replace").replace("\r\n", "\n") # same chunks on every OS
176
+ title, meta, body, first_line = parse(text, Path(path).stem)
177
+ return hashlib.blake2b(raw, digest_size=16).hexdigest(), title, meta, list(chunk(body, first_line))
178
+
179
+
180
+ # --- indexing ----------------------------------------------------------------
181
+
182
+
183
+ def _cpus():
184
+ """CPUs this process may use, honouring affinity and container limits where the platform reports them."""
185
+ if hasattr(os, "process_cpu_count"): # Python 3.13+
186
+ return os.process_cpu_count() or 1
187
+ if hasattr(os, "sched_getaffinity"): # Linux
188
+ return len(os.sched_getaffinity(0))
189
+ return os.cpu_count() or 1
190
+
191
+
192
+ def workers(files):
193
+ """Worker processes for parsing `files` files; 0 or 1 means parse in-process.
194
+
195
+ One per CPU except the one the main process uses for writing, and at most one per FILES_PER_WORKER files.
196
+ """
197
+ return max(0, min(_cpus() - 1, files // FILES_PER_WORKER))
198
+
199
+
200
+ def walk(root):
201
+ """Yield (path, mtime_ns, size) for matching files. DirEntry.stat() is free on Windows."""
202
+ stack = [root]
203
+ while stack:
204
+ try:
205
+ entries = os.scandir(stack.pop())
206
+ except OSError:
207
+ continue
208
+ with entries:
209
+ for e in entries:
210
+ if e.is_dir(follow_symlinks=False):
211
+ if not e.name.startswith(".") and e.name not in SKIP_DIRS:
212
+ stack.append(e.path)
213
+ elif e.name.lower().endswith(EXTS):
214
+ try:
215
+ st = e.stat()
216
+ except OSError: # e.g. a broken symlink
217
+ continue
218
+ yield e.path, st.st_mtime_ns, st.st_size
219
+
220
+
221
+ def index(db, names=None):
222
+ """Bring the keyword index in sync with the files on disk. Only new or changed files are read."""
223
+ collections = db.execute("SELECT id, name, path FROM collections ORDER BY name").fetchall()
224
+ unknown = set(names or ()) - {name for _, name, _ in collections}
225
+ if unknown:
226
+ raise SystemExit(f"no collection named {', '.join(map(repr, sorted(unknown)))}")
227
+ for cid, name, root in collections:
228
+ if names and name not in names:
229
+ continue
230
+ if not os.path.isdir(root): # never treat an unplugged drive as "all files deleted"
231
+ print(f"{name}: folder not found, skipped: {root}", file=sys.stderr)
232
+ continue
233
+ t0 = time.perf_counter()
234
+ known = {
235
+ p: (i, m, s, h)
236
+ for i, p, m, s, h in db.execute("SELECT id, path, mtime, size, hash FROM docs WHERE collection=?", (cid,))
237
+ }
238
+ todo, seen = [], set()
239
+ for path, mtime, size in walk(root):
240
+ rel = os.path.relpath(path, root).replace(os.sep, "/")
241
+ seen.add(rel)
242
+ k = known.get(rel)
243
+ if not k or k[1] != mtime or k[2] != size:
244
+ todo.append((path, rel, mtime, size))
245
+ gone = [known[p][0] for p in known.keys() - seen]
246
+ delete_docs(db, gone)
247
+
248
+ added = changed = failed = 0
249
+ batches = [todo[i : i + WRITE_BATCH] for i in range(0, len(todo), WRITE_BATCH)]
250
+ n = workers(len(todo)) # parsing is CPU-bound, so it runs in processes rather than threads
251
+ with ProcessPoolExecutor(n) if n > 1 else nullcontext() as pool:
252
+
253
+ def read(batch): # pool.map starts all work at once; builtin map reads lazily
254
+ paths = [t[0] for t in batch]
255
+ return pool.map(load, paths, chunksize=64) if pool else map(load, paths)
256
+
257
+ # double buffering: the next batch is parsed while the current one is written
258
+ pending = read(batches[0]) if batches else None
259
+ for b, batch in enumerate(batches):
260
+ results = pending
261
+ if b + 1 < len(batches):
262
+ pending = read(batches[b + 1])
263
+ for (path, rel, mtime, size), res in zip(batch, results):
264
+ if res is None: # unreadable now; retried on the next run
265
+ print(f"\nunreadable, skipped: {path}", file=sys.stderr)
266
+ failed += 1
267
+ continue
268
+ digest, title, meta, chunks = res
269
+ k = known.get(rel)
270
+ if k and k[3] == digest: # touched but unchanged
271
+ db.execute("UPDATE docs SET mtime=?, size=? WHERE id=?", (mtime, size, k[0]))
272
+ continue
273
+ if k:
274
+ delete_docs(db, [k[0]])
275
+ changed += 1
276
+ else:
277
+ added += 1
278
+ doc = db.execute(
279
+ "INSERT INTO docs(collection, path, mtime, size, hash, title, meta) VALUES(?,?,?,?,?,?,?)",
280
+ (cid, rel, mtime, size, digest, title, json.dumps(meta, ensure_ascii=False)),
281
+ ).lastrowid
282
+ for seq, (start, end, text) in enumerate(chunks):
283
+ c = db.execute(
284
+ "INSERT INTO chunks(doc, seq, line_start, line_end) VALUES(?,?,?,?)", (doc, seq, start, end)
285
+ ).lastrowid
286
+ db.execute("INSERT INTO fts(rowid, title, text) VALUES(?,?,?)", (c, title, text))
287
+ db.commit()
288
+ done = min((b + 1) * WRITE_BATCH, len(todo))
289
+ print(f"\r{name}: {done}/{len(todo)} files read", end="", file=sys.stderr, flush=True)
290
+ db.commit()
291
+ print(
292
+ f"\r{name}: {len(seen)} files, {added} added, {changed} changed, {len(gone)} removed"
293
+ + (f", {failed} unreadable" if failed else "")
294
+ + f" ({time.perf_counter() - t0:.1f}s)",
295
+ file=sys.stderr,
296
+ )
@@ -0,0 +1,210 @@
1
+ Metadata-Version: 2.4
2
+ Name: sift-rag
3
+ Version: 0.1.0
4
+ Summary: Fast local search over text and Markdown files, built for AI agents.
5
+ Author: Tobias Würth
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/tobiaswuerth/sift
8
+ Project-URL: Documentation, https://github.com/tobiaswuerth/sift#readme
9
+ Project-URL: Issues, https://github.com/tobiaswuerth/sift/issues
10
+ Project-URL: Changelog, https://github.com/tobiaswuerth/sift/blob/main/CHANGELOG.md
11
+ Keywords: search,rag,embeddings,mcp,markdown,bm25,semantic-search
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Environment :: GPU :: NVIDIA CUDA
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: Science/Research
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Operating System :: Microsoft :: Windows
19
+ Classifier: Operating System :: POSIX :: Linux
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Programming Language :: Python :: 3.15
26
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
27
+ Classifier: Topic :: Text Processing :: Indexing
28
+ Requires-Python: >=3.11
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Requires-Dist: numpy>=1.26
32
+ Requires-Dist: torch>=2.4
33
+ Requires-Dist: sentence-transformers>=5.0
34
+ Requires-Dist: mcp>=2.0
35
+ Requires-Dist: nvidia-ml-py>=12
36
+ Provides-Extra: dev
37
+ Requires-Dist: pytest>=8; extra == "dev"
38
+ Requires-Dist: pytest-cov>=5; extra == "dev"
39
+ Requires-Dist: ruff>=0.6; extra == "dev"
40
+ Requires-Dist: pre-commit>=4; extra == "dev"
41
+ Requires-Dist: build>=1.2; extra == "dev"
42
+ Requires-Dist: twine>=6; extra == "dev"
43
+ Dynamic: license-file
44
+
45
+ # sift
46
+
47
+ [![PyPI](https://img.shields.io/pypi/v/sift-rag)](https://pypi.org/project/sift-rag/)
48
+ [![Python](https://img.shields.io/pypi/pyversions/sift-rag)](https://pypi.org/project/sift-rag/)
49
+ [![CI](https://github.com/tobiaswuerth/sift/actions/workflows/ci.yml/badge.svg)](https://github.com/tobiaswuerth/sift/actions/workflows/ci.yml)
50
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/tobiaswuerth/sift/actions/workflows/ci.yml)
51
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
52
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
53
+
54
+ Fast local search over your text and Markdown files, built for AI agents.
55
+
56
+ sift indexes folders of `.md` and `.txt` files, embeds them on your GPU, and runs a server that agents query over MCP.
57
+ It offers three kinds of search: exact keyword matching, semantic search, and hybrid retrieval with reranking for RAG.
58
+ Everything runs locally. No cloud services, no API keys.
59
+
60
+ ## Install
61
+
62
+ Requires Python 3.11 or newer. With [uv](https://docs.astral.sh/uv/):
63
+
64
+ ```bash
65
+ uv tool install --torch-backend auto sift-rag # installs the `sift` command
66
+ ```
67
+
68
+ Or run it without installing: `uvx --torch-backend auto sift-rag serve`. With pip, in a virtual environment:
69
+ `pip install sift-rag`.
70
+
71
+ On first use, sift downloads its two models from Hugging Face (about 1.5 GB).
72
+
73
+ ### GPU support
74
+
75
+ sift uses an NVIDIA GPU (CUDA) if there is one, else an Apple GPU, else the CPU, which works but embeds slowly.
76
+
77
+ - **Linux, macOS:** nothing to do. PyTorch from PyPI includes CUDA on Linux and uses the Apple GPU on macOS.
78
+ - **Windows:** PyTorch from PyPI has no CUDA support. uv's `--torch-backend auto`, used above, picks the CUDA build
79
+ that matches your driver. With pip, install it afterwards:
80
+ `pip install --force-reinstall --no-deps torch --index-url https://download.pytorch.org/whl/cu128`.
81
+
82
+ If an NVIDIA GPU sits unused, sift says so on start-up and prints the command that fixes it.
83
+
84
+ ## Usage
85
+
86
+ ```bash
87
+ sift add ~/papers --name papers # register a folder as a collection
88
+ sift index # scan files, chunk them, build the keyword index
89
+ sift embed # embed every chunk that has no vector yet
90
+ sift search '"reward shaping"' # exact keyword search
91
+ sift vsearch "how do robots plan brush strokes"
92
+ sift query "mixing pigments to match a target colour"
93
+ sift get papers/2401.02225.md # full document
94
+ sift status # collections and counts
95
+ sift rm papers # remove a collection from the index; its files stay on disk
96
+ sift serve # MCP server on http://127.0.0.1:8765/mcp
97
+ ```
98
+
99
+ `sift index` and `sift embed` are incremental: run them again after files change, and only new or changed files are
100
+ processed. Deleted files are removed. An interrupted `sift embed` continues where it stopped. A running server picks up
101
+ new vectors on its own.
102
+
103
+ Search commands accept `-k` (number of results), `-c` (collection), `--path` (glob within the collection, e.g.
104
+ `"2024/*"`), `-f KEY=VALUE` (front-matter filter, repeatable) and `--json`.
105
+
106
+ ### Front matter
107
+
108
+ YAML front matter with flat `key: value` lines becomes filterable metadata. A list field matches when it contains the
109
+ value:
110
+
111
+ ```markdown
112
+ ---
113
+ title: "Brush stroke planning"
114
+ categories: ["cs.RO", "cs.GR"]
115
+ year: 2024
116
+ ---
117
+ ```
118
+
119
+ ```bash
120
+ sift query "stroke order" -f categories=cs.RO -f year=2024
121
+ ```
122
+
123
+ The `title` field, or else the first heading, or else the file name, becomes the document title.
124
+
125
+ ## MCP
126
+
127
+ | Tool | Purpose |
128
+ |---|---|
129
+ | `search` | Exact keyword search, ranked by BM25. Supports `"phrases"`, `prefix*`, `AND`, `OR`, `NOT`. |
130
+ | `vsearch` | Semantic search. |
131
+ | `query` | Hybrid search for RAG: keyword and semantic candidates, reranked by a cross-encoder. |
132
+ | `get` | Full text of a document by its id. |
133
+ | `status` | Collections and counts. |
134
+
135
+ The search tools take `query`, `k`, `collection`, `path` and `filters`. Every result carries `id`
136
+ (`collection/path`), `title`, `lines`, `score` and the chunk `text`, so agents can cite their sources.
137
+
138
+ Claude Code, with the server running (`sift serve`), shared by any number of agents:
139
+
140
+ ```bash
141
+ claude mcp add --transport http sift http://127.0.0.1:8765/mcp
142
+ ```
143
+
144
+ Or let Claude Code start its own server over stdio:
145
+
146
+ ```bash
147
+ claude mcp add sift -- sift serve --stdio
148
+ ```
149
+
150
+ ## Configuration
151
+
152
+ None needed: sift picks the device, the GPU memory budget, the batch size and the number of worker processes itself.
153
+ Three optional settings exist for special cases:
154
+
155
+ | Variable | Default | Purpose |
156
+ |---|---|---|
157
+ | `SIFT_HOME` | `~/.sift` | Where the index lives |
158
+ | `SIFT_EMBED_MODEL` | `Qwen/Qwen3-Embedding-0.6B` | Any Sentence Transformers embedding model |
159
+ | `SIFT_RERANK_MODEL` | `Alibaba-NLP/gte-reranker-modernbert-base` | Any Sentence Transformers cross-encoder |
160
+
161
+ After changing the embedding model, run `sift embed --reset`. Models are downloaded from Hugging Face on first use.
162
+
163
+ ## How it works
164
+
165
+ - **Indexing:** worker processes read and parse files in parallel while the previous batch is written; small
166
+ updates skip the process start-up. Files are compared by modification time and size, then by content hash, so
167
+ unchanged files cost nothing. Markdown is split into chunks of about 3,000 characters (about 750 tokens) on line
168
+ boundaries, preferring headings, with a small overlap.
169
+ - **Storage:** one SQLite database in WAL mode holds documents, chunks, metadata, an FTS5 keyword index and the
170
+ vectors (fp16).
171
+ - **Embedding:** PyTorch in fp16 on the GPU (NVIDIA, else Apple), in batches sorted by length to keep padding low.
172
+ Each slab of 20,000 chunks is committed, so progress is never lost.
173
+ - **GPU memory:** sift shares the GPU with other programs. On NVIDIA GPUs it caps itself at the memory that is free
174
+ when it starts, minus headroom (10% of the GPU, at least 1 GB), asking NVML as well as CUDA, since on Windows CUDA
175
+ counts memory held by other programs as free. It then measures how much memory its longest text needs at growing
176
+ batch sizes and uses the largest batch that fills 90% of that budget; if a batch still doesn't fit, it is halved.
177
+ With less than 2 GB to spare, sift uses the CPU instead.
178
+ - **`search`:** SQLite FTS5 with BM25, titles weighted double. Input that isn't valid FTS5 syntax is treated as exact
179
+ phrases.
180
+ - **`vsearch`:** exact nearest-neighbour search as one matrix multiply over all vectors on the GPU. No approximate
181
+ index, so nothing to tune and no recall loss; a million 1024-dimensional vectors take about 2 GB of VRAM.
182
+ - **`query`:** the top 40 keyword and top 40 semantic candidates are merged by reciprocal rank fusion and reranked in
183
+ one GPU batch.
184
+ - **Server:** `sift serve` loads models and vectors once and keeps them in memory.
185
+
186
+ ## Scope
187
+
188
+ In scope: plain text and Markdown files; Linux, macOS and Windows; NVIDIA GPUs (CUDA) and Apple GPUs (MPS), otherwise
189
+ CPU; one user, any number of local agents.
190
+
191
+ Out of scope for now: PDF, Word and HTML parsing (convert to Markdown first); answer generation (sift retrieves, the
192
+ agent writes); approximate nearest-neighbour indexes; live file watching; remote access and authentication.
193
+
194
+ ## Development
195
+
196
+ ```bash
197
+ git clone https://github.com/tobiaswuerth/sift
198
+ cd sift
199
+ uv venv && uv pip install --torch-backend auto -e ".[dev]" # or with pip: pip install -r requirements.txt -e ".[dev]"
200
+ pre-commit install # lint, format and config checks on every commit
201
+ pytest --cov # 100% line and branch coverage is required
202
+ ```
203
+
204
+ The tests replace the embedding and reranking models with small deterministic fakes, so they need no GPU and no
205
+ model downloads; everything else, including the MCP server, runs for real. See [CONTRIBUTING.md](CONTRIBUTING.md)
206
+ for how changes and releases work.
207
+
208
+ ## License
209
+
210
+ [MIT](LICENSE)
@@ -0,0 +1,13 @@
1
+ sift/__init__.py,sha256=2Vu_dwmy3r1As0_rBVLNHdF9RJHX5PaWCGcLV2zLV4U,104
2
+ sift/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
3
+ sift/cli.py,sha256=0q4WgMC2Vbv8nI1y2WOjsOEIxUjTYHRr2L5zPPxUws0,4488
4
+ sift/embed.py,sha256=EtaQsVCwagDGNU5uVFyLu7Ze1p7jbNam0Crc-7WihHI,11111
5
+ sift/search.py,sha256=flzCKi7UFW6_Lc3qOvosfyhOG59ddS3zOnRYeko5nYA,8337
6
+ sift/server.py,sha256=ZpdaDx9nbqJ38gyUgR0KTzfwp5Qdelco6gnQn-lm5S8,4306
7
+ sift/store.py,sha256=Qqdb84wY574OMVWBrPlTPDTCZDMksHdbglAq4Uo-oRY,12582
8
+ sift_rag-0.1.0.dist-info/licenses/LICENSE,sha256=duqhUf4jtG5_2fPTQdbZYfACgSSQqDZ8cjiEe_Aj9Qo,1070
9
+ sift_rag-0.1.0.dist-info/METADATA,sha256=XvJJo9FXBNTWSdc8JHUCnziV5_Q9Uua47fTDqbS08ls,9803
10
+ sift_rag-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ sift_rag-0.1.0.dist-info/entry_points.txt,sha256=EU1NrF_e-YlCf89B36yoGyBsCW_4UAUwBY3X0I3O5LY,64
12
+ sift_rag-0.1.0.dist-info/top_level.txt,sha256=g_pdIrH3sx3vFPzSrmKWbngar3ays85WLehOhjxYlys,5
13
+ sift_rag-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ sift = sift.cli:main
3
+ sift-rag = sift.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tobias Würth
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ sift