sqlite-sparse 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.
@@ -0,0 +1,6 @@
1
+ from .api import SparseIndex
2
+ from .loadable import load, loadable_path
3
+ from .models import MODELS, fetch, register
4
+
5
+ __all__ = ["SparseIndex", "load", "loadable_path", "MODELS", "fetch", "register"]
6
+ __version__ = "0.1.0"
sqlite_sparse/api.py ADDED
@@ -0,0 +1,118 @@
1
+ """User-facing API: SparseIndex with create/attach/add/commit/search/sync."""
2
+ import numpy as np
3
+
4
+ from .store import SparseStore
5
+ from .search import QueryEngine
6
+
7
+
8
+ class SparseIndex:
9
+ def __init__(self, path, model=None, max_seq=256, device=None):
10
+ self.store = SparseStore(path)
11
+ self._enc = None
12
+ self._model = model or self.store.get_meta("model_id")
13
+ self._max_seq, self._device = max_seq, device
14
+ if self.store.get_meta("format"):
15
+ self.engine = QueryEngine(self.store.db)
16
+ else:
17
+ assert model, "new index needs model= ('mini' | 'base' | HF id)"
18
+ enc = self.encoder()
19
+ self.store.init_model(enc.model_id, enc.vocab(), enc.qlut())
20
+ self.engine = QueryEngine(self.store.db)
21
+
22
+ @classmethod
23
+ def create(cls, path, model="mini", **kw):
24
+ return cls(path, model=model, **kw)
25
+
26
+ def encoder(self):
27
+ if self._enc is None:
28
+ from .encoder import TorchEncoder
29
+ self._enc = TorchEncoder(self._model, max_seq=self._max_seq, device=self._device)
30
+ return self._enc
31
+
32
+ def add(self, id, text, title=""):
33
+ self.store.queue(id, text, title)
34
+
35
+ def commit(self, batch_size=256):
36
+ return self.sync(batch_size=batch_size)
37
+
38
+ def sync(self, batch_size=256):
39
+ """Drain the pending queue through the encoder. Returns docs synced."""
40
+ total = 0
41
+ while True:
42
+ rows = self.store.drain_pending(limit=batch_size)
43
+ if not rows:
44
+ break
45
+ enc = self.encoder()
46
+ terms = enc.encode([r[3] for r in rows])
47
+ self.store.clear_pending([r[0] for r in rows])
48
+ self.store.add_encoded(
49
+ [(r[1], r[2], r[3], t) for r, t in zip(rows, terms)])
50
+ total += len(rows)
51
+ self.engine.reload()
52
+ return total
53
+
54
+ def delete(self, id):
55
+ self.store.delete(id)
56
+
57
+ def attach(self, table, columns, id_col="rowid"):
58
+ cols = " || ' ' || ".join(f"COALESCE(NEW.{c},'')" for c in columns)
59
+ db = self.store.db
60
+ for ev in ("INSERT", "UPDATE"):
61
+ db.execute(f"""
62
+ CREATE TRIGGER IF NOT EXISTS sparse_cap_{table}_{ev.lower()}
63
+ AFTER {ev} ON {table} BEGIN
64
+ INSERT INTO pending(ext_id, title, body) VALUES (NEW.{id_col}, '', {cols});
65
+ END""")
66
+ db.execute(f"""
67
+ CREATE TRIGGER IF NOT EXISTS sparse_cap_{table}_delete
68
+ AFTER DELETE ON {table} BEGIN
69
+ UPDATE docs SET deleted=1 WHERE ext_id = CAST(OLD.{id_col} AS TEXT) AND deleted=0;
70
+ END""")
71
+ sel = " || ' ' || ".join(f"COALESCE({c},'')" for c in columns)
72
+ db.execute(f"INSERT INTO pending(ext_id, title, body) SELECT {id_col}, '', {sel} FROM {table}")
73
+ db.commit()
74
+
75
+ def search(self, text, k=10, auto_sync=False):
76
+ if auto_sync and self.store.pending_count():
77
+ self.sync()
78
+ return self.engine.search(text, k=k)
79
+
80
+
81
+ def build_bulk(path, docs, ids, model="mini", batch_size=256, device=None,
82
+ titles=None, log=None):
83
+ """Bulk load: encode everything, accumulate postings in memory, write once."""
84
+ from collections import defaultdict
85
+ from .encoder import TorchEncoder
86
+ from .store import SparseStore
87
+
88
+ if log is None:
89
+ def log(m):
90
+ print(m, flush=True)
91
+ enc = TorchEncoder(model, device=device)
92
+ st = SparseStore(path)
93
+ st.init_model(enc.model_id, enc.vocab(), enc.qlut())
94
+ scale = float(st.get_meta("weight_scale", 40.0))
95
+ titles = titles or ["" for _ in docs]
96
+ acc_d, acc_w = defaultdict(list), defaultdict(list)
97
+ st.db.executemany("INSERT INTO docs(id, ext_id, title) VALUES(?,?,?)",
98
+ [(i + 1, str(ids[i]), titles[i]) for i in range(len(docs))])
99
+ import time
100
+ t0 = time.time()
101
+ for c0 in range(0, len(docs), batch_size * 8):
102
+ chunk = docs[c0:c0 + batch_size * 8]
103
+ for off, terms in enumerate(enc.encode(chunk, batch_size=batch_size)):
104
+ did = c0 + off + 1
105
+ for t, w in terms.items():
106
+ acc_d[t].append(did)
107
+ acc_w[t].append(w)
108
+ if (c0 // (batch_size * 8)) % 10 == 0:
109
+ log(f"[bulk] {min(c0 + batch_size*8, len(docs))}/{len(docs)} ({time.time()-t0:.0f}s)")
110
+ for t in sorted(acc_d):
111
+ d = np.array(acc_d[t], dtype="<i4")
112
+ w = np.clip(np.rint(np.array(acc_w[t]) * scale), 1, 255).astype(np.uint8)
113
+ st.db.execute("INSERT INTO postings VALUES(?,?,?)", (t, d.tobytes(), w.tobytes()))
114
+ st.set_meta("ndocs", len(docs))
115
+ st.db.commit()
116
+ st.db.execute("VACUUM")
117
+ log(f"[bulk] DONE {len(docs)} docs ({time.time()-t0:.0f}s)")
118
+ return SparseIndex(path)
sqlite_sparse/cli.py ADDED
@@ -0,0 +1,50 @@
1
+ """CLI: sqlite-sparse build|search|sync|attach|info"""
2
+ import argparse
3
+ import json
4
+ import sys
5
+
6
+
7
+ def main():
8
+ p = argparse.ArgumentParser(prog="sqlite-sparse")
9
+ sub = p.add_subparsers(dest="cmd", required=True)
10
+ b = sub.add_parser("build", help="index a JSONL file of {id, text[, title]}")
11
+ b.add_argument("db"); b.add_argument("jsonl"); b.add_argument("--model", default="mini")
12
+ s = sub.add_parser("search"); s.add_argument("db"); s.add_argument("query"); s.add_argument("-k", type=int, default=10)
13
+ y = sub.add_parser("sync"); y.add_argument("db")
14
+ a = sub.add_parser("attach"); a.add_argument("db"); a.add_argument("table"); a.add_argument("columns", help="comma-separated"); a.add_argument("--id-col", default="rowid"); a.add_argument("--model", default="mini")
15
+ i = sub.add_parser("info"); i.add_argument("db")
16
+ c = sub.add_parser("convert", help="write the .sprs sidecar for an inference-free HF checkpoint")
17
+ c.add_argument("model_id"); c.add_argument("out")
18
+ c.add_argument("--double-log", action="store_true", help="v3 models (log1p applied twice)")
19
+ args = p.parse_args()
20
+ from .api import SparseIndex
21
+
22
+ if args.cmd == "build":
23
+ ix = SparseIndex(args.db, model=args.model)
24
+ for line in open(args.jsonl):
25
+ r = json.loads(line)
26
+ ix.add(id=r["id"], text=r["text"], title=r.get("title", ""))
27
+ n = ix.commit()
28
+ print(f"indexed {n} docs -> {args.db}")
29
+ elif args.cmd == "search":
30
+ ix = SparseIndex(args.db)
31
+ for r in ix.search(args.query, k=args.k):
32
+ print(f"{r['score']:8.3f} {r['ext_id']} {r['title'][:70]}")
33
+ elif args.cmd == "sync":
34
+ print(f"synced {SparseIndex(args.db).sync()} docs")
35
+ elif args.cmd == "attach":
36
+ ix = SparseIndex(args.db, model=args.model)
37
+ ix.attach(args.table, args.columns.split(","), id_col=args.id_col)
38
+ print(f"attached to {args.table}({args.columns}); synced {ix.sync()} rows")
39
+ elif args.cmd == "convert":
40
+ from .convert import extract
41
+ extract(args.model_id, args.out, act_flag=1 if args.double_log else 0)
42
+ elif args.cmd == "info":
43
+ ix = SparseIndex(args.db)
44
+ st = ix.store
45
+ print(json.dumps({"model": st.get_meta("model_id"), "ndocs": st.get_meta("ndocs"),
46
+ "pending": st.pending_count(), "weight_mode": st.get_meta("weight_mode")}, indent=2))
47
+
48
+
49
+ if __name__ == "__main__":
50
+ sys.exit(main())
@@ -0,0 +1,52 @@
1
+ """Write the .sprs sidecar: the MLM head llama.cpp's converter drops, the static
2
+ query weight table and the vocabulary. Little-endian throughout.
3
+
4
+ 'SPRS' | u32 version=1 | u32 hidden | u32 vocab_n | u32 weight fmt (0=f32)
5
+ | u32 activation (0 log1p(relu), 1 log1p(log1p(relu)) for v3) | 8 pad
6
+ | dense.W (H*H f32) | dense.b (H) | ln.gamma (H) | ln.beta (H)
7
+ | decoder.W (vocab_n*H f32, row-major) | decoder.b (vocab_n)
8
+ | qlut (vocab_n f32) | u32 len | vocab blob (tokens joined by \n)
9
+ """
10
+ import struct
11
+ import sys
12
+
13
+ import numpy as np
14
+
15
+
16
+ def extract(model_id, out_path, act_flag=0):
17
+ from transformers import AutoModelForMaskedLM, AutoTokenizer
18
+ from sentence_transformers import SparseEncoder
19
+
20
+ se = SparseEncoder(model_id, device="cpu")
21
+ qlut = se[0].sub_modules["query"][0].weight.detach().float().numpy().ravel()
22
+ m = AutoModelForMaskedLM.from_pretrained(model_id)
23
+ sd = {k: v.detach().float().numpy() for k, v in m.state_dict().items()}
24
+ H = sd["cls.predictions.transform.dense.weight"].shape[0]
25
+ dec_w = sd["bert.embeddings.word_embeddings.weight"]
26
+ vocab_n = dec_w.shape[0]
27
+ tok = AutoTokenizer.from_pretrained(model_id)
28
+ vocab = tok.convert_ids_to_tokens(list(range(vocab_n)))
29
+ vocab_blob = "\n".join(vocab).encode("utf-8")
30
+
31
+ with open(out_path, "wb") as f:
32
+ f.write(b"SPRS")
33
+ f.write(struct.pack("<IIIII8x", 1, H, vocab_n, 0, act_flag))
34
+ for arr in (sd["cls.predictions.transform.dense.weight"],
35
+ sd["cls.predictions.transform.dense.bias"],
36
+ sd["cls.predictions.transform.LayerNorm.weight"],
37
+ sd["cls.predictions.transform.LayerNorm.bias"],
38
+ dec_w,
39
+ sd["cls.predictions.bias"],
40
+ qlut.astype(np.float32)):
41
+ f.write(np.ascontiguousarray(arr, dtype="<f4").tobytes())
42
+ f.write(struct.pack("<I", len(vocab_blob)))
43
+ f.write(vocab_blob)
44
+ import os
45
+ print(f"wrote {out_path}: H={H} vocab={vocab_n} bytes={os.path.getsize(out_path)}")
46
+ return H, vocab_n
47
+
48
+
49
+ if __name__ == "__main__":
50
+ extract(sys.argv[1] if len(sys.argv) > 1
51
+ else "opensearch-project/opensearch-neural-sparse-encoding-doc-v2-mini",
52
+ sys.argv[2] if len(sys.argv) > 2 else "mini.sprs")
@@ -0,0 +1,39 @@
1
+ """Document encoders for the reference implementation (torch)."""
2
+
3
+ MODELS = {
4
+ "mini": "opensearch-project/opensearch-neural-sparse-encoding-doc-v2-mini",
5
+ "base": "opensearch-project/opensearch-neural-sparse-encoding-doc-v3-distill",
6
+ "multilingual": "opensearch-project/opensearch-neural-sparse-encoding-multilingual-v1",
7
+ }
8
+
9
+
10
+ def resolve(model):
11
+ return MODELS.get(model, model)
12
+
13
+
14
+ class TorchEncoder:
15
+ def __init__(self, model, max_seq=256, device=None):
16
+ import torch
17
+ from sentence_transformers import SparseEncoder
18
+ self.model_id = resolve(model)
19
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
20
+ kw = {"trust_remote_code": True} if "gte" in self.model_id or "multilingual" in self.model_id else {}
21
+ self.m = SparseEncoder(self.model_id, device=device, **kw)
22
+ self.m.max_seq_length = max_seq
23
+
24
+ def qlut(self):
25
+ return self.m[0].sub_modules["query"][0].weight.detach().float().cpu().numpy().ravel()
26
+
27
+ def vocab(self):
28
+ return self.m.tokenizer.convert_ids_to_tokens(list(range(len(self.qlut()))))
29
+
30
+ def encode(self, texts, batch_size=32, wmin=0.01):
31
+ """returns list[dict[term_id, weight]]"""
32
+ emb = self.m.encode_document(texts, batch_size=batch_size,
33
+ convert_to_sparse_tensor=True, show_progress_bar=False).coalesce()
34
+ idx, val = emb.indices().cpu().numpy(), emb.values().cpu().numpy()
35
+ out = [dict() for _ in texts]
36
+ keep = val >= wmin
37
+ for r, c, v in zip(idx[0][keep], idx[1][keep], val[keep]):
38
+ out[r][int(c)] = float(v)
39
+ return out
@@ -0,0 +1,45 @@
1
+ """Locate and load the sparse0 extension. Search order: the copy bundled in
2
+ the package, $SQLITE_SPARSE_EXT, then the repo's build tree."""
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ _SUFFIX = {"darwin": ".dylib", "win32": ".dll"}.get(sys.platform, ".so")
8
+
9
+
10
+ def _candidates():
11
+ here = Path(__file__).parent
12
+ yield here / f"sparse0{_SUFFIX}"
13
+ env = os.environ.get("SQLITE_SPARSE_EXT")
14
+ if env:
15
+ yield Path(env)
16
+ yield here.parents[2] / "build" / f"sparse0{_SUFFIX}"
17
+
18
+
19
+ def loadable_path():
20
+ """Absolute path of the extension binary."""
21
+ tried = []
22
+ for p in _candidates():
23
+ tried.append(str(p))
24
+ if p.exists():
25
+ return str(p)
26
+ raise FileNotFoundError(
27
+ "sparse0 extension not found. Tried:\n " + "\n ".join(tried) +
28
+ "\nBuild it with `make` at the repo root, or set SQLITE_SPARSE_EXT to the binary."
29
+ )
30
+
31
+
32
+ def load(conn):
33
+ """Load sparse0 into a connection. Returns the path used."""
34
+ if not hasattr(conn, "enable_load_extension"):
35
+ raise RuntimeError(
36
+ "this Python's sqlite3 module was built without loadable-extension "
37
+ "support (common with the python.org and GitHub Actions macOS builds). "
38
+ "Use a Python from Homebrew or conda, or `pip install sqlean.py` and "
39
+ "connect with `import sqlean as sqlite3`."
40
+ )
41
+ path = loadable_path()
42
+ conn.enable_load_extension(True)
43
+ conn.load_extension(path)
44
+ conn.enable_load_extension(False)
45
+ return path
@@ -0,0 +1,69 @@
1
+ """Named models: download the GGUF and .sprs for an alias into
2
+ ~/.cache/sqlite-sparse and register them. urllib only; HF_TOKEN honored."""
3
+ import os
4
+ import sys
5
+ import urllib.request
6
+ from pathlib import Path
7
+
8
+ HUB = "https://huggingface.co"
9
+
10
+ # alias -> (repo, file stem, encoder params)
11
+ MODELS = {
12
+ "mini": ("arbazsiddiqui/opensearch-neural-sparse-doc-v2-mini-GGUF", "mini", "23M"),
13
+ "base": ("arbazsiddiqui/opensearch-neural-sparse-doc-v3-distill-GGUF", "base", "67M"),
14
+ "multilingual": ("arbazsiddiqui/opensearch-neural-sparse-multilingual-v1-GGUF", "multilingual", "168M"),
15
+ }
16
+
17
+
18
+ def cache_dir():
19
+ root = os.environ.get("SQLITE_SPARSE_CACHE") or os.path.join(
20
+ os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")), "sqlite-sparse")
21
+ Path(root).mkdir(parents=True, exist_ok=True)
22
+ return root
23
+
24
+
25
+ def _download(url, dest, label):
26
+ req = urllib.request.Request(url)
27
+ tok = os.environ.get("HF_TOKEN")
28
+ if tok:
29
+ req.add_header("Authorization", f"Bearer {tok}")
30
+ tmp = dest + ".part"
31
+ with urllib.request.urlopen(req) as r, open(tmp, "wb") as f:
32
+ total = int(r.headers.get("Content-Length") or 0)
33
+ done = 0
34
+ while True:
35
+ chunk = r.read(1 << 20)
36
+ if not chunk:
37
+ break
38
+ f.write(chunk)
39
+ done += len(chunk)
40
+ if total and sys.stderr.isatty():
41
+ sys.stderr.write(f"\r{label}: {done * 100 // total}%")
42
+ if total and sys.stderr.isatty():
43
+ sys.stderr.write("\n")
44
+ os.replace(tmp, dest)
45
+
46
+
47
+ def fetch(alias="mini", quant="q8"):
48
+ """Download the GGUF and sidecar for `alias` if not cached. Returns (gguf, sprs)."""
49
+ if alias not in MODELS:
50
+ raise KeyError(f"unknown model alias {alias!r}; known: {sorted(MODELS)}")
51
+ repo, stem, _ = MODELS[alias]
52
+ if quant not in ("q8", "f16"):
53
+ raise ValueError("quant must be 'q8' or 'f16'")
54
+ d = os.path.join(cache_dir(), alias)
55
+ Path(d).mkdir(parents=True, exist_ok=True)
56
+ out = []
57
+ for fname in (f"{stem}_{quant}.gguf", f"{stem}.sprs"):
58
+ dest = os.path.join(d, fname)
59
+ if not os.path.exists(dest):
60
+ _download(f"{HUB}/{repo}/resolve/main/{fname}", dest, fname)
61
+ out.append(dest)
62
+ return tuple(out)
63
+
64
+
65
+ def register(conn, alias="mini", quant="q8", max_seq=512):
66
+ """fetch() then sparse_register(). Returns the alias."""
67
+ gguf, sprs = fetch(alias, quant)
68
+ conn.execute("SELECT sparse_register(?, ?, ?, ?)", (alias, gguf, sprs, max_seq)).fetchone()
69
+ return alias
@@ -0,0 +1,144 @@
1
+ """Query engine: tokenizer + static table + exact scoring over an open
2
+ sqlite-sparse database. numpy only. No model, no network."""
3
+ import json
4
+ import unicodedata
5
+
6
+ import numpy as np
7
+
8
+ _MAX_WORD_CHARS = 100
9
+
10
+
11
+ def _is_punctuation(ch):
12
+ cp = ord(ch)
13
+ if 33 <= cp <= 47 or 58 <= cp <= 64 or 91 <= cp <= 96 or 123 <= cp <= 126:
14
+ return True
15
+ return unicodedata.category(ch).startswith("P")
16
+
17
+
18
+ def _is_cjk(cp):
19
+ return (0x4E00 <= cp <= 0x9FFF or 0x3400 <= cp <= 0x4DBF or 0x20000 <= cp <= 0x2A6DF
20
+ or 0x2A700 <= cp <= 0x2B73F or 0x2B740 <= cp <= 0x2B81F or 0x2B820 <= cp <= 0x2CEAF
21
+ or 0xF900 <= cp <= 0xFAFF or 0x2F800 <= cp <= 0x2FA1F)
22
+
23
+
24
+ def _basic_tokenize(text):
25
+ """BERT BasicTokenizer: clean, isolate CJK, split on whitespace, lowercase,
26
+ strip combining marks, split punctuation."""
27
+ cleaned = []
28
+ for ch in text:
29
+ cp = ord(ch)
30
+ cat = unicodedata.category(ch)
31
+ if cp == 0 or cp == 0xFFFD or (cat.startswith("C") and ch not in "\t\n\r"):
32
+ continue
33
+ if ch in " \t\n\r" or cat == "Zs":
34
+ cleaned.append(" ")
35
+ elif _is_cjk(cp):
36
+ cleaned.append(f" {ch} ")
37
+ else:
38
+ cleaned.append(ch)
39
+ words = []
40
+ for w in "".join(cleaned).split():
41
+ w = w.lower()
42
+ w = "".join(c for c in unicodedata.normalize("NFD", w) if unicodedata.category(c) != "Mn")
43
+ cur = []
44
+ for c in w:
45
+ if _is_punctuation(c):
46
+ if cur:
47
+ words.append("".join(cur))
48
+ cur = []
49
+ words.append(c)
50
+ else:
51
+ cur.append(c)
52
+ if cur:
53
+ words.append("".join(cur))
54
+ return words
55
+
56
+
57
+ class QueryEngine:
58
+ def __init__(self, db):
59
+ self.db = db
60
+ self.reload()
61
+
62
+ def reload(self):
63
+ meta = dict(self.db.execute("SELECT k, v FROM meta"))
64
+ assert meta.get("format") == "sqlite-sparse/1", f"not sqlite-sparse: {meta.get('format')}"
65
+ self.model_id = meta["model_id"]
66
+ self.ndocs = int(meta.get("ndocs") or 0)
67
+ self.weight_mode = meta.get("weight_mode", "u8")
68
+ self.weight_scale = float(meta.get("weight_scale", 40.0))
69
+ self._v2i = {t: i for i, t in enumerate(json.loads(meta["vocab"]))}
70
+ self._unk = self._v2i.get("[UNK]")
71
+ self._qlut = dict(self.db.execute("SELECT t, w FROM qlut"))
72
+ self._dead = {r[0] for r in self.db.execute("SELECT id FROM docs WHERE deleted=1")}
73
+
74
+ def _wordpiece_word(self, w):
75
+ if len(w) > _MAX_WORD_CHARS:
76
+ return [self._unk]
77
+ ids, s = [], 0
78
+ while s < len(w):
79
+ e = len(w)
80
+ while e > s:
81
+ piece = w[s:e] if s == 0 else "##" + w[s:e]
82
+ if piece in self._v2i:
83
+ ids.append(self._v2i[piece])
84
+ break
85
+ e -= 1
86
+ else:
87
+ return [self._unk]
88
+ s = e
89
+ return ids
90
+
91
+ def _wordpiece(self, text):
92
+ out = []
93
+ for w in _basic_tokenize(text):
94
+ for t in self._wordpiece_word(w):
95
+ if t is not None:
96
+ out.append(t)
97
+ if len(out) >= 512:
98
+ return out[:512]
99
+ return out
100
+
101
+ def encode_query(self, text):
102
+ qw = {}
103
+ for t in self._wordpiece(text):
104
+ w = self._qlut.get(t)
105
+ if w:
106
+ qw[t] = qw.get(t, 0.0) + w
107
+ return qw
108
+
109
+ def search(self, text, k=10):
110
+ qw = self.encode_query(text)
111
+ if not qw or not self.ndocs:
112
+ return []
113
+ score = np.zeros(self.ndocs + 1, dtype=np.float64)
114
+ for t, w in qw.items():
115
+ row = self.db.execute("SELECT docs, ws FROM postings WHERE t=?", (t,)).fetchone()
116
+ if not row:
117
+ continue
118
+ docs = np.frombuffer(row[0], dtype="<i4")
119
+ if self.weight_mode == "u8":
120
+ ws = np.frombuffer(row[1], dtype=np.uint8).astype(np.float32) / self.weight_scale
121
+ else:
122
+ ws = np.frombuffer(row[1], dtype="<f4")
123
+ np.add.at(score, docs, ws * w)
124
+ want = k + len(self._dead)
125
+ if want >= len(score):
126
+ top = np.argsort(-score)
127
+ else:
128
+ part = np.argpartition(score, -want)[-want:]
129
+ top = part[np.argsort(-score[part])]
130
+ out = []
131
+ for d in top:
132
+ d = int(d)
133
+ if score[d] <= 0 or d in self._dead:
134
+ continue
135
+ out.append(d)
136
+ if len(out) == k:
137
+ break
138
+ if not out:
139
+ return []
140
+ ph = ",".join("?" * len(out))
141
+ rows = {r[0]: r for r in self.db.execute(
142
+ f"SELECT id, ext_id, title FROM docs WHERE id IN ({ph})", out)}
143
+ return [{"id": d, "ext_id": rows[d][1], "title": rows[d][2], "score": float(score[d])}
144
+ for d in out if d in rows]
sqlite_sparse/store.py ADDED
@@ -0,0 +1,126 @@
1
+ """Storage layer for the sqlite-sparse/1 format: schema, incremental posting
2
+ merges, deletions, compaction. All writes go through SparseStore."""
3
+ import json
4
+ import sqlite3
5
+ import time
6
+ from collections import defaultdict
7
+
8
+ import numpy as np
9
+
10
+ SCHEMA = """
11
+ CREATE TABLE IF NOT EXISTS meta(k TEXT PRIMARY KEY, v TEXT);
12
+ CREATE TABLE IF NOT EXISTS qlut(t INTEGER PRIMARY KEY, w REAL);
13
+ CREATE TABLE IF NOT EXISTS postings(t INTEGER PRIMARY KEY, docs BLOB, ws BLOB);
14
+ CREATE TABLE IF NOT EXISTS docs(id INTEGER PRIMARY KEY AUTOINCREMENT,
15
+ ext_id TEXT UNIQUE, title TEXT, body TEXT, meta TEXT, deleted INTEGER DEFAULT 0,
16
+ ntokens INTEGER, truncated INTEGER);
17
+ CREATE TABLE IF NOT EXISTS pending(id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ ext_id TEXT, title TEXT, body TEXT NOT NULL);
19
+ """
20
+
21
+
22
+ class SparseStore:
23
+ def __init__(self, path):
24
+ self.path = path
25
+ self.db = sqlite3.connect(path)
26
+ self.db.executescript(SCHEMA)
27
+
28
+ def get_meta(self, k, default=None):
29
+ r = self.db.execute("SELECT v FROM meta WHERE k=?", (k,)).fetchone()
30
+ return r[0] if r else default
31
+
32
+ def set_meta(self, k, v):
33
+ self.db.execute("INSERT OR REPLACE INTO meta VALUES(?,?)", (k, str(v)))
34
+
35
+ def init_model(self, model_id, vocab, qlut_weights, weight_mode="u8", weight_scale=40.0):
36
+ if self.get_meta("format"):
37
+ assert self.get_meta("model_id") == model_id, \
38
+ f"index built with {self.get_meta('model_id')}, not {model_id}"
39
+ return
40
+ for k, v in [("format", "sqlite-sparse/1"), ("model_id", model_id),
41
+ ("weight_mode", weight_mode), ("weight_scale", weight_scale),
42
+ ("vocab", json.dumps(vocab)),
43
+ ("created_utc", time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))]:
44
+ self.set_meta(k, v)
45
+ nz = np.nonzero(qlut_weights)[0]
46
+ self.db.executemany("INSERT OR REPLACE INTO qlut VALUES(?,?)",
47
+ [(int(t), float(qlut_weights[t])) for t in nz])
48
+ self.db.commit()
49
+
50
+ def queue(self, ext_id, text, title=""):
51
+ self.db.execute("INSERT INTO pending(ext_id, title, body) VALUES(?,?,?)",
52
+ (str(ext_id), title, text))
53
+ self.db.commit()
54
+
55
+ def pending_count(self):
56
+ return self.db.execute("SELECT COUNT(*) FROM pending").fetchone()[0]
57
+
58
+ def drain_pending(self, limit=1000):
59
+ rows = self.db.execute(
60
+ "SELECT id, ext_id, title, body FROM pending ORDER BY id LIMIT ?", (limit,)).fetchall()
61
+ return rows
62
+
63
+ def clear_pending(self, ids):
64
+ # No commit here: sync() runs this inside add_encoded's transaction so
65
+ # the queue drain and the postings write land atomically.
66
+ self.db.executemany("DELETE FROM pending WHERE id=?", [(i,) for i in ids])
67
+
68
+ def add_encoded(self, items, store_body=False):
69
+ """items: list of (ext_id, title, body, {term: weight}). Merges into
70
+ postings. Re-adding an existing ext_id marks the old row deleted."""
71
+ mode = self.get_meta("weight_mode", "u8")
72
+ scale = float(self.get_meta("weight_scale", 40.0))
73
+ acc = defaultdict(lambda: ([], []))
74
+ for ext_id, title, body, terms in items:
75
+ old = self.db.execute("SELECT id FROM docs WHERE ext_id=? AND deleted=0",
76
+ (str(ext_id),)).fetchone()
77
+ if old:
78
+ self.db.execute("UPDATE docs SET deleted=1, ext_id=ext_id||':del:'||id WHERE id=?", (old[0],))
79
+ cur = self.db.execute("INSERT INTO docs(ext_id, title, body) VALUES(?,?,?)",
80
+ (str(ext_id), title, body if store_body else None))
81
+ did = cur.lastrowid
82
+ for t, w in terms.items():
83
+ d, ws = acc[int(t)]
84
+ d.append(did)
85
+ ws.append(w)
86
+ for t, (d, ws) in acc.items():
87
+ row = self.db.execute("SELECT docs, ws FROM postings WHERE t=?", (t,)).fetchone()
88
+ nd = np.array(d, dtype="<i4")
89
+ nw = np.array(ws, dtype=np.float32)
90
+ if mode == "u8":
91
+ nwb = np.clip(np.rint(nw * scale), 1, 255).astype(np.uint8)
92
+ else:
93
+ nwb = nw.astype("<f4")
94
+ if row:
95
+ nd = np.concatenate([np.frombuffer(row[0], dtype="<i4"), nd])
96
+ old_w = np.frombuffer(row[1], dtype=np.uint8 if mode == "u8" else "<f4")
97
+ nwb = np.concatenate([old_w, nwb])
98
+ self.db.execute("INSERT OR REPLACE INTO postings VALUES(?,?,?)",
99
+ (t, nd.tobytes(), nwb.tobytes()))
100
+ self.set_meta("ndocs", self.db.execute("SELECT MAX(id) FROM docs").fetchone()[0] or 0)
101
+ self.db.commit()
102
+
103
+ def delete(self, ext_id):
104
+ self.db.execute("UPDATE docs SET deleted=1 WHERE ext_id=? AND deleted=0", (str(ext_id),))
105
+ self.db.commit()
106
+
107
+ def deleted_ids(self):
108
+ return np.array([r[0] for r in self.db.execute("SELECT id FROM docs WHERE deleted=1")],
109
+ dtype=np.int64)
110
+
111
+ def compact(self):
112
+ """Rewrite postings without deleted docs and VACUUM."""
113
+ dead = set(int(i) for i in self.deleted_ids())
114
+ if dead:
115
+ for t, db_, wb in self.db.execute("SELECT t, docs, ws FROM postings").fetchall():
116
+ d = np.frombuffer(db_, dtype="<i4")
117
+ keep = ~np.isin(d, list(dead))
118
+ if keep.all():
119
+ continue
120
+ mode = self.get_meta("weight_mode", "u8")
121
+ w = np.frombuffer(wb, dtype=np.uint8 if mode == "u8" else "<f4")
122
+ self.db.execute("UPDATE postings SET docs=?, ws=? WHERE t=?",
123
+ (d[keep].tobytes(), w[keep].tobytes(), t))
124
+ self.db.execute("DELETE FROM docs WHERE deleted=1")
125
+ self.db.commit()
126
+ self.db.execute("VACUUM")
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlite-sparse
3
+ Version: 0.1.0
4
+ Summary: Semantic search in one SQLite file. No model, no server at query time.
5
+ Author-email: Arbaz Siddiqui <arbaz00@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/arbazsiddiqui/sqlite-sparse
8
+ Project-URL: Repository, https://github.com/arbazsiddiqui/sqlite-sparse
9
+ Project-URL: Issues, https://github.com/arbazsiddiqui/sqlite-sparse/issues
10
+ Keywords: sqlite,search,semantic-search,sparse,retrieval,splade,embedded
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Database
16
+ Classifier: Topic :: Text Processing :: Indexing
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: numpy>=1.24
21
+ Provides-Extra: build
22
+ Requires-Dist: onnxruntime>=1.17; extra == "build"
23
+ Requires-Dist: tokenizers>=0.15; extra == "build"
24
+ Requires-Dist: huggingface_hub>=0.20; extra == "build"
25
+ Provides-Extra: build-torch
26
+ Requires-Dist: sentence-transformers>=5.0; extra == "build-torch"
27
+ Requires-Dist: torch; extra == "build-torch"
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=7; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # sqlite-sparse (Python)
33
+
34
+ Python binding for [sqlite-sparse](https://github.com/arbazsiddiqui/sqlite-sparse),
35
+ semantic search in one SQLite file with no model at query time.
36
+
37
+ ```python
38
+ import sqlite3, sqlite_sparse
39
+ db = sqlite3.connect("notes.db")
40
+ sqlite_sparse.load(db) # loads the sparse0 extension
41
+ sqlite_sparse.register(db, "mini") # downloads the model on first use
42
+ db.execute("CREATE VIRTUAL TABLE notes USING sparse0(model='mini')")
43
+ ```
44
+
45
+ Also contains the pure-Python reference implementation of the file format
46
+ (`SparseIndex`) and the sidecar converter. Full documentation in the repository README.
@@ -0,0 +1,15 @@
1
+ sqlite_sparse/__init__.py,sha256=8Qw_SMijJmv8jlXM5D8vMcKjwKAUIlrbv3JeC3Yjohk,220
2
+ sqlite_sparse/api.py,sha256=T7blGH7-ZKc5b6XuMsjTVVOYftd3RFxNQlnsMSenvPY,4644
3
+ sqlite_sparse/cli.py,sha256=qLwmkJAT48V-4GDd9T8rW5c4V81K6gZ3kPo__MNXHy4,2454
4
+ sqlite_sparse/convert.py,sha256=83qm-MAsQymzCLl-IJbeq7lmQJGldpBGWBsbhDqYFbg,2289
5
+ sqlite_sparse/encoder.py,sha256=b0WzUF74L7KYB3a0JZrnv8Z7rx3PVroDrjYFDAfxduI,1645
6
+ sqlite_sparse/loadable.py,sha256=32UKWKPwiThuLmiEcG5jWuvvLcFtxT5mPX35h__jq0E,1507
7
+ sqlite_sparse/models.py,sha256=VA_Ru6HseNFNDO0u74SjdRTuKOue4j4yatp_ndYxGVM,2560
8
+ sqlite_sparse/search.py,sha256=JgP1nGQoqjY0A7Sr9Qv78mGqZbd6z7vlDUACrH7fJHU,4918
9
+ sqlite_sparse/store.py,sha256=7GQOuAoC-dxBJUH59o_cCueJaZs5BejxxDNdqTuYXtY,5870
10
+ sqlite_sparse-0.1.0.dist-info/licenses/LICENSE,sha256=E2xU48tQ--vIEgBKFehhUPsd49UFzxzP7VN8joDz6Hc,1071
11
+ sqlite_sparse-0.1.0.dist-info/METADATA,sha256=3-O_fFgmrpMS71mufSAWwc8KpkaNRGAmb0bpbUj92Sw,1894
12
+ sqlite_sparse-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ sqlite_sparse-0.1.0.dist-info/entry_points.txt,sha256=T0NlTs4vDA6bKeNO6roWdRVqfj8A9PpBkxKcDzeBkDQ,57
14
+ sqlite_sparse-0.1.0.dist-info/top_level.txt,sha256=GBq7GGgMxL9B4Dv4bTRsFDvFz6-HApR0iv1Gct2So6I,14
15
+ sqlite_sparse-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,2 @@
1
+ [console_scripts]
2
+ sqlite-sparse = sqlite_sparse.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arbaz Siddiqui
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
+ sqlite_sparse