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.
- sqlite_sparse/__init__.py +6 -0
- sqlite_sparse/api.py +118 -0
- sqlite_sparse/cli.py +50 -0
- sqlite_sparse/convert.py +52 -0
- sqlite_sparse/encoder.py +39 -0
- sqlite_sparse/loadable.py +45 -0
- sqlite_sparse/models.py +69 -0
- sqlite_sparse/search.py +144 -0
- sqlite_sparse/store.py +126 -0
- sqlite_sparse-0.1.0.dist-info/METADATA +46 -0
- sqlite_sparse-0.1.0.dist-info/RECORD +15 -0
- sqlite_sparse-0.1.0.dist-info/WHEEL +5 -0
- sqlite_sparse-0.1.0.dist-info/entry_points.txt +2 -0
- sqlite_sparse-0.1.0.dist-info/licenses/LICENSE +21 -0
- sqlite_sparse-0.1.0.dist-info/top_level.txt +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())
|
sqlite_sparse/convert.py
ADDED
|
@@ -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")
|
sqlite_sparse/encoder.py
ADDED
|
@@ -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
|
sqlite_sparse/models.py
ADDED
|
@@ -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
|
sqlite_sparse/search.py
ADDED
|
@@ -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,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
|