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 +3 -0
- sift/__main__.py +3 -0
- sift/cli.py +109 -0
- sift/embed.py +259 -0
- sift/search.py +176 -0
- sift/server.py +110 -0
- sift/store.py +296 -0
- sift_rag-0.1.0.dist-info/METADATA +210 -0
- sift_rag-0.1.0.dist-info/RECORD +13 -0
- sift_rag-0.1.0.dist-info/WHEEL +5 -0
- sift_rag-0.1.0.dist-info/entry_points.txt +3 -0
- sift_rag-0.1.0.dist-info/licenses/LICENSE +21 -0
- sift_rag-0.1.0.dist-info/top_level.txt +1 -0
sift/__init__.py
ADDED
sift/__main__.py
ADDED
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
|
+
[](https://pypi.org/project/sift-rag/)
|
|
48
|
+
[](https://pypi.org/project/sift-rag/)
|
|
49
|
+
[](https://github.com/tobiaswuerth/sift/actions/workflows/ci.yml)
|
|
50
|
+
[](https://github.com/tobiaswuerth/sift/actions/workflows/ci.yml)
|
|
51
|
+
[](https://github.com/astral-sh/ruff)
|
|
52
|
+
[](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,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
|