sqlite-sparse 1.0.0__tar.gz → 1.1.0__tar.gz
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-1.0.0/sqlite_sparse.egg-info → sqlite_sparse-1.1.0}/PKG-INFO +100 -100
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/README.md +99 -99
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/pyproject.toml +1 -1
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/__init__.py +1 -1
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/api.py +31 -0
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/search.py +20 -1
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0/sqlite_sparse.egg-info}/PKG-INFO +100 -100
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/LICENSE +0 -0
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/setup.cfg +0 -0
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/cli.py +0 -0
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/convert.py +0 -0
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/encoder.py +0 -0
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/loadable.py +0 -0
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/models.py +0 -0
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/store.py +0 -0
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse.egg-info/SOURCES.txt +0 -0
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse.egg-info/dependency_links.txt +0 -0
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse.egg-info/entry_points.txt +0 -0
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse.egg-info/requires.txt +0 -0
- {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlite-sparse
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Semantic search in one SQLite file. No model, no server at query time.
|
|
5
5
|
Author-email: Arbaz Siddiqui <arbaz00@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -55,27 +55,33 @@ but the keywords were chosen by a transformer. OpenSearch's inference-free varia
|
|
|
55
55
|
the encoder only on documents; each query token gets one learned weight from a lookup
|
|
56
56
|
table, and retrieval is an exact dot product.
|
|
57
57
|
|
|
58
|
-
Dense retrieval (vector search)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
the
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
58
|
+
Dense retrieval (vector search) runs an embedding model on every query. A learned sparse
|
|
59
|
+
index moves all model work to write time, and you can see which terms matched and with
|
|
60
|
+
what weight. The cost is some quality against good dense models of the same size and much
|
|
61
|
+
slower indexing; the numbers are under Benchmarks.
|
|
62
|
+
|
|
63
|
+
SPLADE encoders are BERT models with their masked-language-model head still attached. BERT
|
|
64
|
+
was trained to fill in blanks: shown `aspirin prevents [MASK]`, that head scores every
|
|
65
|
+
word in the vocabulary as a candidate for the blank. SPLADE points the same head at every
|
|
66
|
+
token of a document and keeps the best score each word gets, so a sentence about heart
|
|
67
|
+
attacks earns a weight for `cardiac` even though the word is not in it. Those per-word
|
|
68
|
+
scores are the sparse vector; the head is the entire trick. llama.cpp runs BERT-family
|
|
69
|
+
models for embeddings only: its converter drops the head (the `cls.predictions` tensors,
|
|
70
|
+
along with the pooler) and its graph stops at the per-token vectors, so `llama-embedding`
|
|
71
|
+
on one of these models returns embeddings and no way to turn them back into words.
|
|
72
|
+
|
|
73
|
+
sqlite-sparse keeps the head. The converter copies its weights out of the checkpoint into
|
|
74
|
+
a small `.sprs` file next to the GGUF, along with the query weight table. At insert time
|
|
75
|
+
llama.cpp runs the encoder as usual and the extension runs the head over the token vectors
|
|
76
|
+
itself, in C on ggml: a dense layer, GELU, LayerNorm, then a score for every word in the
|
|
77
|
+
vocabulary, keeping the highest score each word received across the tokens and applying
|
|
78
|
+
log(1 + ReLU) so the weights are positive and compressed. That turns an encoder llama.cpp
|
|
79
|
+
can already run into a sparse retriever. The rest is what a search cluster provides and
|
|
80
|
+
SQLite does not: the virtual table, posting lists stored as rows, the query-time
|
|
81
|
+
scatter-add, and the file format with a reference implementation to test it against.
|
|
82
|
+
[sqlite-vec](https://github.com/asg017/sqlite-vec) did this for embeddings in SQLite; this
|
|
83
|
+
does it for learned sparse, which so far has lived inside OpenSearch, Elasticsearch and
|
|
84
|
+
Vespa.
|
|
79
85
|
|
|
80
86
|
## Install
|
|
81
87
|
|
|
@@ -87,15 +93,14 @@ Or take the binary from the [releases page](https://github.com/arbazsiddiqui/sql
|
|
|
87
93
|
and use it from any language.
|
|
88
94
|
|
|
89
95
|
```
|
|
90
|
-
tar xzf sparse0-1.
|
|
96
|
+
tar xzf sparse0-1.1.0-loadable-linux-x86_64.tar.gz # or -macos-arm64
|
|
91
97
|
sqlite3 notes.db
|
|
92
98
|
sqlite> .load ./sparse0
|
|
93
99
|
```
|
|
94
100
|
|
|
95
101
|
Keep the filename `sparse0.so` / `sparse0.dylib`, since SQLite derives the entry point
|
|
96
|
-
from it. On macOS
|
|
97
|
-
|
|
98
|
-
sqlite3`.
|
|
102
|
+
from it. On macOS the python.org `sqlite3` module cannot load extensions; use Homebrew or
|
|
103
|
+
conda Python, or `pip install sqlean.py` and `import sqlean as sqlite3`.
|
|
99
104
|
|
|
100
105
|
## Quickstart
|
|
101
106
|
|
|
@@ -106,87 +111,63 @@ db = sqlite3.connect("notes.db")
|
|
|
106
111
|
sqlite_sparse.load(db) # loads the sparse0 extension
|
|
107
112
|
sqlite_sparse.register(db, "mini") # downloads the model on first use
|
|
108
113
|
db.execute("CREATE VIRTUAL TABLE notes USING sparse0(model='mini')")
|
|
109
|
-
db.execute("INSERT INTO notes(rowid, text) VALUES (1, 'Aspirin lowers heart attack risk')")
|
|
114
|
+
db.execute("INSERT INTO notes(rowid, text) VALUES (1, 'Aspirin lowers heart attack risk')") # the model runs here
|
|
110
115
|
db.commit()
|
|
111
|
-
db.execute("SELECT rowid, score FROM notes WHERE notes MATCH ? LIMIT 5",
|
|
116
|
+
db.execute("SELECT rowid, score FROM notes WHERE notes MATCH ? LIMIT 5", # and never here
|
|
112
117
|
("what prevents cardiac arrest",)).fetchall()
|
|
113
118
|
```
|
|
114
119
|
|
|
115
|
-
The model runs at INSERT only; MATCH never loads it. Searching an existing index needs
|
|
116
|
-
no model at all, on any machine.
|
|
117
|
-
|
|
118
120
|
```python
|
|
121
|
+
# Another machine, no model downloaded: MATCH only reads the file.
|
|
119
122
|
db = sqlite3.connect("notes.db")
|
|
120
123
|
sqlite_sparse.load(db)
|
|
121
|
-
db.execute("CREATE VIRTUAL TABLE temp.notes USING sparse0()") # adopts the file
|
|
124
|
+
db.execute("CREATE VIRTUAL TABLE temp.notes USING sparse0()") # adopts the index in the file
|
|
122
125
|
db.execute("SELECT rowid, score FROM temp.notes WHERE notes MATCH 'heart medication' LIMIT 5")
|
|
123
126
|
```
|
|
124
127
|
|
|
125
|
-
A database file holds one sparse index; another `sparse0` table in the same file
|
|
126
|
-
attaches to the same index rather than creating a second one.
|
|
127
|
-
|
|
128
|
-
Because results are rows, semantic search composes with plain SQL. Ask for extra
|
|
129
|
-
candidates with `k`, then filter and join like any other table.
|
|
130
|
-
|
|
131
128
|
```sql
|
|
129
|
+
-- Results are rows: ask for k candidates, then filter and join like any other table.
|
|
132
130
|
SELECT n.rowid, n.score, d.title
|
|
133
131
|
FROM notes n JOIN documents d ON d.id = n.rowid
|
|
134
132
|
WHERE n.text MATCH 'heart medication' AND k = 50 AND d.folder = 'work'
|
|
135
133
|
ORDER BY n.score DESC LIMIT 10;
|
|
136
134
|
```
|
|
137
135
|
|
|
138
|
-
Indexing is the expensive half
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
`LIMIT n` and `AND k = n` both work, and `ORDER BY score DESC` is honoured without a sort
|
|
143
|
-
step. `DELETE FROM notes WHERE rowid = ?` marks a document deleted; run
|
|
144
|
-
`SELECT sparse_compact()` now and then on an index with heavy churn to reclaim its
|
|
145
|
-
postings. Documents longer than `max_seq` tokens (default 512) are truncated at insert, and
|
|
146
|
-
each row in the `docs` table records `ntokens` and `truncated`. The full surface, including
|
|
147
|
-
the Python helpers, is in [docs/api.md](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/docs/api.md).
|
|
136
|
+
Indexing is the expensive half. Build a large corpus once on a GPU machine with
|
|
137
|
+
`sqlite-sparse build` and ship the `.db` to wherever the reads happen. Deletes,
|
|
138
|
+
compaction, truncation and the rest of the surface are in [docs/api.md](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/docs/api.md).
|
|
148
139
|
|
|
149
140
|
## How it works
|
|
150
141
|
|
|
151
142
|

|
|
152
143
|
|
|
153
|
-
**INSERT.** The
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
query weight × stored weight into the running total of every document listed; that
|
|
167
|
-
accumulation is the scatter-add. `prevents` contributes 6.72 × 0.18 and `cardiac`
|
|
168
|
-
6.53 × 0.42 to document 1, total 3.95. The documents touched are sorted and the top k
|
|
169
|
-
returned. Scoring is exact over the stored weights, with no candidate stage and no
|
|
170
|
-
approximate index, and ties break on the lower rowid. Nothing from the GGUF or the
|
|
171
|
-
sidecar is read at query time.
|
|
144
|
+
**INSERT.** The text is tokenized, the encoder runs through llama.cpp, and the head scores
|
|
145
|
+
every vocabulary word against the token vectors. For the aspirin sentence that leaves 157
|
|
146
|
+
weighted terms (`heart` 0.95, `stroke` 0.92, `risk` 0.78, `reduce` 0.70, `cardiac` 0.42,
|
|
147
|
+
`prevents` 0.18, and so on). Each term is appended to that word's posting list, a row in
|
|
148
|
+
the file listing the documents it scored and the weight as one byte.
|
|
149
|
+
|
|
150
|
+
**MATCH.** The query is tokenized the same way and each token gets its weight from the
|
|
151
|
+
table stored in the file (`what` 2.77, `prevents` 6.72, `cardiac` 6.53, `arrest` 6.87).
|
|
152
|
+
For each query word the extension walks that word's posting list and adds query weight ×
|
|
153
|
+
stored weight into every listed document's total, the scatter-add: `prevents` contributes
|
|
154
|
+
6.72 × 0.18 and `cardiac` 6.53 × 0.42 to document 1, total 3.95. Scoring is exact over the
|
|
155
|
+
stored weights, with no candidate stage or approximate index, and nothing from the GGUF or
|
|
156
|
+
the sidecar is read.
|
|
172
157
|
|
|
173
158
|
The file layout is in [FORMAT.md](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/FORMAT.md). The format and the sidecar header carry
|
|
174
159
|
version 1, and files written by any 1.x release stay readable by later 1.x releases.
|
|
175
160
|
|
|
176
161
|
## Benchmarks
|
|
177
162
|
|
|
178
|
-
Three ways to search inside a SQLite file, each in its shipped form, on the same machine
|
|
179
|
-
FTS5
|
|
180
|
-
[sqlite-vec](https://github.com/asg017/sqlite-vec) int8
|
|
181
|
-
[mdbr-leaf-ir](https://huggingface.co/MongoDB/mdbr-leaf-ir) (23M) encoding queries on
|
|
182
|
-
CPU
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
because that is its real query path. This compares the brute-force vector path inside
|
|
187
|
-
SQLite, not an approximate nearest-neighbour index. The FTS5 query is the disjunction of
|
|
188
|
-
the query's tokens ranked by `bm25()`; a conjunction is faster but misses documents that
|
|
189
|
-
match only some of the terms.
|
|
163
|
+
Three ways to search inside a SQLite file, each in its shipped form, on the same machine:
|
|
164
|
+
FTS5, SQLite's built-in keyword search ranked by BM25; dense brute-force with
|
|
165
|
+
[sqlite-vec](https://github.com/asg017/sqlite-vec) int8 and
|
|
166
|
+
[mdbr-leaf-ir](https://huggingface.co/MongoDB/mdbr-leaf-ir) (23M) encoding queries on
|
|
167
|
+
torch CPU; and `sparse0` with `mini` (23M, Q8_0 encoder, u8 postings). Latency is end to
|
|
168
|
+
end, so dense includes encoding the query, because that is its real query path. FTS5 runs
|
|
169
|
+
the OR of the query's tokens ranked by `bm25()` (an AND is faster but misses partial
|
|
170
|
+
matches), and the dense lane is the brute-force scan, not an approximate index.
|
|
190
171
|
|
|
191
172
|
| msmarco, 1M documents | FTS5 BM25 | dense brute-force | sqlite-sparse |
|
|
192
173
|
|---|---|---|---|
|
|
@@ -199,7 +180,9 @@ match only some of the terms.
|
|
|
199
180
|
| model at query time | none | 23M transformer | none |
|
|
200
181
|
| retrieval | lexical | semantic | semantic |
|
|
201
182
|
|
|
202
|
-
At 100K documents the p50s are 54 ms, 82 ms and 0.26 ms respectively.
|
|
183
|
+
At 100K documents the p50s are 54 ms, 82 ms and 0.26 ms respectively. Measured on a GCE
|
|
184
|
+
`c3-standard-8` (8 vCPU, 4 physical cores); the scripts and raw results are attached to
|
|
185
|
+
each release.
|
|
203
186
|
|
|
204
187
|
### The extension does not lose the model's quality
|
|
205
188
|
|
|
@@ -218,14 +201,9 @@ the same documents and queries. The gain ranges from small (SciFact) to large (F
|
|
|
218
201
|
|
|
219
202
|
The same model run in torch at fp32 agrees with the extension on 96 to 98 percent of
|
|
220
203
|
top-10 results on every dataset, and storing weights as one byte instead of fp32 changed
|
|
221
|
-
nDCG@10 by less than 0.001.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
with each lane in its own fresh process. 4,000 samples × 5 repetitions per lane (900 × 3
|
|
225
|
-
for dense and 1,000 × 3 for FTS5 at 1M), median of repetition medians. Cold start is the
|
|
226
|
-
second of three fresh-process runs. RAM is peak RSS after 50 warm queries. The corpus is
|
|
227
|
-
the first 100K and 1M passages of MS MARCO with its dev queries. Benchmark scripts and raw
|
|
228
|
-
results are attached to each release.
|
|
204
|
+
nDCG@10 by less than 0.001. For context against dense models of the same size,
|
|
205
|
+
mdbr-leaf-ir (23M) reports 0.5355 BEIR average to `mini`'s 0.497; the two do very
|
|
206
|
+
different amounts of work at query time, so that is context, not a controlled comparison.
|
|
229
207
|
|
|
230
208
|
## Models
|
|
231
209
|
|
|
@@ -235,17 +213,16 @@ results are attached to each release.
|
|
|
235
213
|
| `base` | [doc-v3-distill](https://huggingface.co/opensearch-project/opensearch-neural-sparse-encoding-doc-v3-distill) | 67M | 0.517 | [arbazsiddiqui/opensearch-neural-sparse-doc-v3-distill-GGUF](https://huggingface.co/arbazsiddiqui/opensearch-neural-sparse-doc-v3-distill-GGUF) |
|
|
236
214
|
| `multilingual` | [multilingual-v1](https://huggingface.co/opensearch-project/opensearch-neural-sparse-encoding-multilingual-v1) | 168M | multilingual | [arbazsiddiqui/opensearch-neural-sparse-multilingual-v1-GGUF](https://huggingface.co/arbazsiddiqui/opensearch-neural-sparse-multilingual-v1-GGUF) |
|
|
237
215
|
|
|
238
|
-
All three are in the [sqlite-sparse
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
216
|
+
All three are in the [sqlite-sparse
|
|
217
|
+
models](https://huggingface.co/collections/arbazsiddiqui/sqlite-sparse-models-6a929c8e0cb15b0e8ed47d43)
|
|
218
|
+
collection, and `sqlite_sparse.register(db, alias)` fetches one into
|
|
219
|
+
`~/.cache/sqlite-sparse`. Weights are unmodified from the Apache-2.0 originals by the
|
|
220
|
+
OpenSearch project.
|
|
243
221
|
|
|
244
222
|
### Bring your own model
|
|
245
223
|
|
|
246
|
-
Any inference-free OpenSearch-style sparse encoder on Hugging Face works
|
|
247
|
-
|
|
248
|
-
vocabulary.
|
|
224
|
+
Any inference-free OpenSearch-style sparse encoder on Hugging Face works: the encoder as
|
|
225
|
+
GGUF plus a `.sprs` sidecar holding the head and the query weight table.
|
|
249
226
|
|
|
250
227
|
```
|
|
251
228
|
git clone --depth 1 https://github.com/ggml-org/llama.cpp
|
|
@@ -260,9 +237,29 @@ SELECT sparse_register('mine', 'model_q8.gguf', 'model.sprs');
|
|
|
260
237
|
CREATE VIRTUAL TABLE notes USING sparse0(model='mine');
|
|
261
238
|
```
|
|
262
239
|
|
|
263
|
-
The
|
|
264
|
-
|
|
265
|
-
|
|
240
|
+
The checkpoint must be a BERT-family encoder with a masked-LM head and a static query
|
|
241
|
+
weight table, and llama.cpp must support the architecture (it does not support GTE,
|
|
242
|
+
`doc-v3-gte`).
|
|
243
|
+
|
|
244
|
+
### Bring your own vectors
|
|
245
|
+
|
|
246
|
+
Any sparse model works if you run it yourself, including SPLADE models that encode the
|
|
247
|
+
query too, and models llama.cpp cannot run. Create the index from the model's vocabulary
|
|
248
|
+
and hand it `{"token": weight}` objects for documents and for queries. Nothing is
|
|
249
|
+
converted; the extension stores and scores, and the file is the same format.
|
|
250
|
+
|
|
251
|
+
```sql
|
|
252
|
+
CREATE VIRTUAL TABLE notes USING sparse0(vocab='vocab.txt'); -- one token per line, no model
|
|
253
|
+
INSERT INTO notes(rowid, terms) VALUES (1, '{"heart": 0.95, "cardiac": 0.42, "stroke": 0.92}');
|
|
254
|
+
SELECT rowid, score FROM notes WHERE notes.terms MATCH '{"cardiac": 6.53, "arrest": 6.87}' LIMIT 5;
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Tokens must be in the vocabulary (an unknown token is an error on insert and ignored in a
|
|
258
|
+
query), weights must be positive, and weights above 6.375 saturate the one-byte storage.
|
|
259
|
+
`terms MATCH` also works on an index one of the shipped models built, so a query encoded
|
|
260
|
+
by your own model can search it. Text queries on a vocabulary-only index are an error,
|
|
261
|
+
since there is no query weight table. In Python: `SparseIndex.create_external(path,
|
|
262
|
+
vocab)`, `add_terms(id, terms)`, `search_terms(terms)`.
|
|
266
263
|
|
|
267
264
|
## Development
|
|
268
265
|
|
|
@@ -275,7 +272,10 @@ make test # installs the Python binding and runs the suite
|
|
|
275
272
|
`src/` is the extension (`sparse0.c` virtual table, `wordpiece.c` tokenizer on utf8proc, `head.c`
|
|
276
273
|
scoring head on ggml, `scorer.c` scatter-add, `encoder.c` llama.cpp wrapper).
|
|
277
274
|
`bindings/python` is the reference implementation of the file format and the test oracle.
|
|
278
|
-
|
|
275
|
+
Each shipped conversion is validated against the original SentenceTransformers
|
|
276
|
+
implementation (encoder hidden states at cosine 0.9997 or better, term weights within
|
|
277
|
+
1.3e-3), and component agreement is logged in
|
|
278
|
+
[`tests/test_differential.md`](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/tests/test_differential.md).
|
|
279
279
|
|
|
280
280
|
## License
|
|
281
281
|
|
|
@@ -27,27 +27,33 @@ but the keywords were chosen by a transformer. OpenSearch's inference-free varia
|
|
|
27
27
|
the encoder only on documents; each query token gets one learned weight from a lookup
|
|
28
28
|
table, and retrieval is an exact dot product.
|
|
29
29
|
|
|
30
|
-
Dense retrieval (vector search)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
the
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
30
|
+
Dense retrieval (vector search) runs an embedding model on every query. A learned sparse
|
|
31
|
+
index moves all model work to write time, and you can see which terms matched and with
|
|
32
|
+
what weight. The cost is some quality against good dense models of the same size and much
|
|
33
|
+
slower indexing; the numbers are under Benchmarks.
|
|
34
|
+
|
|
35
|
+
SPLADE encoders are BERT models with their masked-language-model head still attached. BERT
|
|
36
|
+
was trained to fill in blanks: shown `aspirin prevents [MASK]`, that head scores every
|
|
37
|
+
word in the vocabulary as a candidate for the blank. SPLADE points the same head at every
|
|
38
|
+
token of a document and keeps the best score each word gets, so a sentence about heart
|
|
39
|
+
attacks earns a weight for `cardiac` even though the word is not in it. Those per-word
|
|
40
|
+
scores are the sparse vector; the head is the entire trick. llama.cpp runs BERT-family
|
|
41
|
+
models for embeddings only: its converter drops the head (the `cls.predictions` tensors,
|
|
42
|
+
along with the pooler) and its graph stops at the per-token vectors, so `llama-embedding`
|
|
43
|
+
on one of these models returns embeddings and no way to turn them back into words.
|
|
44
|
+
|
|
45
|
+
sqlite-sparse keeps the head. The converter copies its weights out of the checkpoint into
|
|
46
|
+
a small `.sprs` file next to the GGUF, along with the query weight table. At insert time
|
|
47
|
+
llama.cpp runs the encoder as usual and the extension runs the head over the token vectors
|
|
48
|
+
itself, in C on ggml: a dense layer, GELU, LayerNorm, then a score for every word in the
|
|
49
|
+
vocabulary, keeping the highest score each word received across the tokens and applying
|
|
50
|
+
log(1 + ReLU) so the weights are positive and compressed. That turns an encoder llama.cpp
|
|
51
|
+
can already run into a sparse retriever. The rest is what a search cluster provides and
|
|
52
|
+
SQLite does not: the virtual table, posting lists stored as rows, the query-time
|
|
53
|
+
scatter-add, and the file format with a reference implementation to test it against.
|
|
54
|
+
[sqlite-vec](https://github.com/asg017/sqlite-vec) did this for embeddings in SQLite; this
|
|
55
|
+
does it for learned sparse, which so far has lived inside OpenSearch, Elasticsearch and
|
|
56
|
+
Vespa.
|
|
51
57
|
|
|
52
58
|
## Install
|
|
53
59
|
|
|
@@ -59,15 +65,14 @@ Or take the binary from the [releases page](https://github.com/arbazsiddiqui/sql
|
|
|
59
65
|
and use it from any language.
|
|
60
66
|
|
|
61
67
|
```
|
|
62
|
-
tar xzf sparse0-1.
|
|
68
|
+
tar xzf sparse0-1.1.0-loadable-linux-x86_64.tar.gz # or -macos-arm64
|
|
63
69
|
sqlite3 notes.db
|
|
64
70
|
sqlite> .load ./sparse0
|
|
65
71
|
```
|
|
66
72
|
|
|
67
73
|
Keep the filename `sparse0.so` / `sparse0.dylib`, since SQLite derives the entry point
|
|
68
|
-
from it. On macOS
|
|
69
|
-
|
|
70
|
-
sqlite3`.
|
|
74
|
+
from it. On macOS the python.org `sqlite3` module cannot load extensions; use Homebrew or
|
|
75
|
+
conda Python, or `pip install sqlean.py` and `import sqlean as sqlite3`.
|
|
71
76
|
|
|
72
77
|
## Quickstart
|
|
73
78
|
|
|
@@ -78,87 +83,63 @@ db = sqlite3.connect("notes.db")
|
|
|
78
83
|
sqlite_sparse.load(db) # loads the sparse0 extension
|
|
79
84
|
sqlite_sparse.register(db, "mini") # downloads the model on first use
|
|
80
85
|
db.execute("CREATE VIRTUAL TABLE notes USING sparse0(model='mini')")
|
|
81
|
-
db.execute("INSERT INTO notes(rowid, text) VALUES (1, 'Aspirin lowers heart attack risk')")
|
|
86
|
+
db.execute("INSERT INTO notes(rowid, text) VALUES (1, 'Aspirin lowers heart attack risk')") # the model runs here
|
|
82
87
|
db.commit()
|
|
83
|
-
db.execute("SELECT rowid, score FROM notes WHERE notes MATCH ? LIMIT 5",
|
|
88
|
+
db.execute("SELECT rowid, score FROM notes WHERE notes MATCH ? LIMIT 5", # and never here
|
|
84
89
|
("what prevents cardiac arrest",)).fetchall()
|
|
85
90
|
```
|
|
86
91
|
|
|
87
|
-
The model runs at INSERT only; MATCH never loads it. Searching an existing index needs
|
|
88
|
-
no model at all, on any machine.
|
|
89
|
-
|
|
90
92
|
```python
|
|
93
|
+
# Another machine, no model downloaded: MATCH only reads the file.
|
|
91
94
|
db = sqlite3.connect("notes.db")
|
|
92
95
|
sqlite_sparse.load(db)
|
|
93
|
-
db.execute("CREATE VIRTUAL TABLE temp.notes USING sparse0()") # adopts the file
|
|
96
|
+
db.execute("CREATE VIRTUAL TABLE temp.notes USING sparse0()") # adopts the index in the file
|
|
94
97
|
db.execute("SELECT rowid, score FROM temp.notes WHERE notes MATCH 'heart medication' LIMIT 5")
|
|
95
98
|
```
|
|
96
99
|
|
|
97
|
-
A database file holds one sparse index; another `sparse0` table in the same file
|
|
98
|
-
attaches to the same index rather than creating a second one.
|
|
99
|
-
|
|
100
|
-
Because results are rows, semantic search composes with plain SQL. Ask for extra
|
|
101
|
-
candidates with `k`, then filter and join like any other table.
|
|
102
|
-
|
|
103
100
|
```sql
|
|
101
|
+
-- Results are rows: ask for k candidates, then filter and join like any other table.
|
|
104
102
|
SELECT n.rowid, n.score, d.title
|
|
105
103
|
FROM notes n JOIN documents d ON d.id = n.rowid
|
|
106
104
|
WHERE n.text MATCH 'heart medication' AND k = 50 AND d.folder = 'work'
|
|
107
105
|
ORDER BY n.score DESC LIMIT 10;
|
|
108
106
|
```
|
|
109
107
|
|
|
110
|
-
Indexing is the expensive half
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
`LIMIT n` and `AND k = n` both work, and `ORDER BY score DESC` is honoured without a sort
|
|
115
|
-
step. `DELETE FROM notes WHERE rowid = ?` marks a document deleted; run
|
|
116
|
-
`SELECT sparse_compact()` now and then on an index with heavy churn to reclaim its
|
|
117
|
-
postings. Documents longer than `max_seq` tokens (default 512) are truncated at insert, and
|
|
118
|
-
each row in the `docs` table records `ntokens` and `truncated`. The full surface, including
|
|
119
|
-
the Python helpers, is in [docs/api.md](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/docs/api.md).
|
|
108
|
+
Indexing is the expensive half. Build a large corpus once on a GPU machine with
|
|
109
|
+
`sqlite-sparse build` and ship the `.db` to wherever the reads happen. Deletes,
|
|
110
|
+
compaction, truncation and the rest of the surface are in [docs/api.md](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/docs/api.md).
|
|
120
111
|
|
|
121
112
|
## How it works
|
|
122
113
|
|
|
123
114
|

|
|
124
115
|
|
|
125
|
-
**INSERT.** The
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
query weight × stored weight into the running total of every document listed; that
|
|
139
|
-
accumulation is the scatter-add. `prevents` contributes 6.72 × 0.18 and `cardiac`
|
|
140
|
-
6.53 × 0.42 to document 1, total 3.95. The documents touched are sorted and the top k
|
|
141
|
-
returned. Scoring is exact over the stored weights, with no candidate stage and no
|
|
142
|
-
approximate index, and ties break on the lower rowid. Nothing from the GGUF or the
|
|
143
|
-
sidecar is read at query time.
|
|
116
|
+
**INSERT.** The text is tokenized, the encoder runs through llama.cpp, and the head scores
|
|
117
|
+
every vocabulary word against the token vectors. For the aspirin sentence that leaves 157
|
|
118
|
+
weighted terms (`heart` 0.95, `stroke` 0.92, `risk` 0.78, `reduce` 0.70, `cardiac` 0.42,
|
|
119
|
+
`prevents` 0.18, and so on). Each term is appended to that word's posting list, a row in
|
|
120
|
+
the file listing the documents it scored and the weight as one byte.
|
|
121
|
+
|
|
122
|
+
**MATCH.** The query is tokenized the same way and each token gets its weight from the
|
|
123
|
+
table stored in the file (`what` 2.77, `prevents` 6.72, `cardiac` 6.53, `arrest` 6.87).
|
|
124
|
+
For each query word the extension walks that word's posting list and adds query weight ×
|
|
125
|
+
stored weight into every listed document's total, the scatter-add: `prevents` contributes
|
|
126
|
+
6.72 × 0.18 and `cardiac` 6.53 × 0.42 to document 1, total 3.95. Scoring is exact over the
|
|
127
|
+
stored weights, with no candidate stage or approximate index, and nothing from the GGUF or
|
|
128
|
+
the sidecar is read.
|
|
144
129
|
|
|
145
130
|
The file layout is in [FORMAT.md](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/FORMAT.md). The format and the sidecar header carry
|
|
146
131
|
version 1, and files written by any 1.x release stay readable by later 1.x releases.
|
|
147
132
|
|
|
148
133
|
## Benchmarks
|
|
149
134
|
|
|
150
|
-
Three ways to search inside a SQLite file, each in its shipped form, on the same machine
|
|
151
|
-
FTS5
|
|
152
|
-
[sqlite-vec](https://github.com/asg017/sqlite-vec) int8
|
|
153
|
-
[mdbr-leaf-ir](https://huggingface.co/MongoDB/mdbr-leaf-ir) (23M) encoding queries on
|
|
154
|
-
CPU
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
because that is its real query path. This compares the brute-force vector path inside
|
|
159
|
-
SQLite, not an approximate nearest-neighbour index. The FTS5 query is the disjunction of
|
|
160
|
-
the query's tokens ranked by `bm25()`; a conjunction is faster but misses documents that
|
|
161
|
-
match only some of the terms.
|
|
135
|
+
Three ways to search inside a SQLite file, each in its shipped form, on the same machine:
|
|
136
|
+
FTS5, SQLite's built-in keyword search ranked by BM25; dense brute-force with
|
|
137
|
+
[sqlite-vec](https://github.com/asg017/sqlite-vec) int8 and
|
|
138
|
+
[mdbr-leaf-ir](https://huggingface.co/MongoDB/mdbr-leaf-ir) (23M) encoding queries on
|
|
139
|
+
torch CPU; and `sparse0` with `mini` (23M, Q8_0 encoder, u8 postings). Latency is end to
|
|
140
|
+
end, so dense includes encoding the query, because that is its real query path. FTS5 runs
|
|
141
|
+
the OR of the query's tokens ranked by `bm25()` (an AND is faster but misses partial
|
|
142
|
+
matches), and the dense lane is the brute-force scan, not an approximate index.
|
|
162
143
|
|
|
163
144
|
| msmarco, 1M documents | FTS5 BM25 | dense brute-force | sqlite-sparse |
|
|
164
145
|
|---|---|---|---|
|
|
@@ -171,7 +152,9 @@ match only some of the terms.
|
|
|
171
152
|
| model at query time | none | 23M transformer | none |
|
|
172
153
|
| retrieval | lexical | semantic | semantic |
|
|
173
154
|
|
|
174
|
-
At 100K documents the p50s are 54 ms, 82 ms and 0.26 ms respectively.
|
|
155
|
+
At 100K documents the p50s are 54 ms, 82 ms and 0.26 ms respectively. Measured on a GCE
|
|
156
|
+
`c3-standard-8` (8 vCPU, 4 physical cores); the scripts and raw results are attached to
|
|
157
|
+
each release.
|
|
175
158
|
|
|
176
159
|
### The extension does not lose the model's quality
|
|
177
160
|
|
|
@@ -190,14 +173,9 @@ the same documents and queries. The gain ranges from small (SciFact) to large (F
|
|
|
190
173
|
|
|
191
174
|
The same model run in torch at fp32 agrees with the extension on 96 to 98 percent of
|
|
192
175
|
top-10 results on every dataset, and storing weights as one byte instead of fp32 changed
|
|
193
|
-
nDCG@10 by less than 0.001.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
with each lane in its own fresh process. 4,000 samples × 5 repetitions per lane (900 × 3
|
|
197
|
-
for dense and 1,000 × 3 for FTS5 at 1M), median of repetition medians. Cold start is the
|
|
198
|
-
second of three fresh-process runs. RAM is peak RSS after 50 warm queries. The corpus is
|
|
199
|
-
the first 100K and 1M passages of MS MARCO with its dev queries. Benchmark scripts and raw
|
|
200
|
-
results are attached to each release.
|
|
176
|
+
nDCG@10 by less than 0.001. For context against dense models of the same size,
|
|
177
|
+
mdbr-leaf-ir (23M) reports 0.5355 BEIR average to `mini`'s 0.497; the two do very
|
|
178
|
+
different amounts of work at query time, so that is context, not a controlled comparison.
|
|
201
179
|
|
|
202
180
|
## Models
|
|
203
181
|
|
|
@@ -207,17 +185,16 @@ results are attached to each release.
|
|
|
207
185
|
| `base` | [doc-v3-distill](https://huggingface.co/opensearch-project/opensearch-neural-sparse-encoding-doc-v3-distill) | 67M | 0.517 | [arbazsiddiqui/opensearch-neural-sparse-doc-v3-distill-GGUF](https://huggingface.co/arbazsiddiqui/opensearch-neural-sparse-doc-v3-distill-GGUF) |
|
|
208
186
|
| `multilingual` | [multilingual-v1](https://huggingface.co/opensearch-project/opensearch-neural-sparse-encoding-multilingual-v1) | 168M | multilingual | [arbazsiddiqui/opensearch-neural-sparse-multilingual-v1-GGUF](https://huggingface.co/arbazsiddiqui/opensearch-neural-sparse-multilingual-v1-GGUF) |
|
|
209
187
|
|
|
210
|
-
All three are in the [sqlite-sparse
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
188
|
+
All three are in the [sqlite-sparse
|
|
189
|
+
models](https://huggingface.co/collections/arbazsiddiqui/sqlite-sparse-models-6a929c8e0cb15b0e8ed47d43)
|
|
190
|
+
collection, and `sqlite_sparse.register(db, alias)` fetches one into
|
|
191
|
+
`~/.cache/sqlite-sparse`. Weights are unmodified from the Apache-2.0 originals by the
|
|
192
|
+
OpenSearch project.
|
|
215
193
|
|
|
216
194
|
### Bring your own model
|
|
217
195
|
|
|
218
|
-
Any inference-free OpenSearch-style sparse encoder on Hugging Face works
|
|
219
|
-
|
|
220
|
-
vocabulary.
|
|
196
|
+
Any inference-free OpenSearch-style sparse encoder on Hugging Face works: the encoder as
|
|
197
|
+
GGUF plus a `.sprs` sidecar holding the head and the query weight table.
|
|
221
198
|
|
|
222
199
|
```
|
|
223
200
|
git clone --depth 1 https://github.com/ggml-org/llama.cpp
|
|
@@ -232,9 +209,29 @@ SELECT sparse_register('mine', 'model_q8.gguf', 'model.sprs');
|
|
|
232
209
|
CREATE VIRTUAL TABLE notes USING sparse0(model='mine');
|
|
233
210
|
```
|
|
234
211
|
|
|
235
|
-
The
|
|
236
|
-
|
|
237
|
-
|
|
212
|
+
The checkpoint must be a BERT-family encoder with a masked-LM head and a static query
|
|
213
|
+
weight table, and llama.cpp must support the architecture (it does not support GTE,
|
|
214
|
+
`doc-v3-gte`).
|
|
215
|
+
|
|
216
|
+
### Bring your own vectors
|
|
217
|
+
|
|
218
|
+
Any sparse model works if you run it yourself, including SPLADE models that encode the
|
|
219
|
+
query too, and models llama.cpp cannot run. Create the index from the model's vocabulary
|
|
220
|
+
and hand it `{"token": weight}` objects for documents and for queries. Nothing is
|
|
221
|
+
converted; the extension stores and scores, and the file is the same format.
|
|
222
|
+
|
|
223
|
+
```sql
|
|
224
|
+
CREATE VIRTUAL TABLE notes USING sparse0(vocab='vocab.txt'); -- one token per line, no model
|
|
225
|
+
INSERT INTO notes(rowid, terms) VALUES (1, '{"heart": 0.95, "cardiac": 0.42, "stroke": 0.92}');
|
|
226
|
+
SELECT rowid, score FROM notes WHERE notes.terms MATCH '{"cardiac": 6.53, "arrest": 6.87}' LIMIT 5;
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Tokens must be in the vocabulary (an unknown token is an error on insert and ignored in a
|
|
230
|
+
query), weights must be positive, and weights above 6.375 saturate the one-byte storage.
|
|
231
|
+
`terms MATCH` also works on an index one of the shipped models built, so a query encoded
|
|
232
|
+
by your own model can search it. Text queries on a vocabulary-only index are an error,
|
|
233
|
+
since there is no query weight table. In Python: `SparseIndex.create_external(path,
|
|
234
|
+
vocab)`, `add_terms(id, terms)`, `search_terms(terms)`.
|
|
238
235
|
|
|
239
236
|
## Development
|
|
240
237
|
|
|
@@ -247,7 +244,10 @@ make test # installs the Python binding and runs the suite
|
|
|
247
244
|
`src/` is the extension (`sparse0.c` virtual table, `wordpiece.c` tokenizer on utf8proc, `head.c`
|
|
248
245
|
scoring head on ggml, `scorer.c` scatter-add, `encoder.c` llama.cpp wrapper).
|
|
249
246
|
`bindings/python` is the reference implementation of the file format and the test oracle.
|
|
250
|
-
|
|
247
|
+
Each shipped conversion is validated against the original SentenceTransformers
|
|
248
|
+
implementation (encoder hidden states at cosine 0.9997 or better, term weights within
|
|
249
|
+
1.3e-3), and component agreement is logged in
|
|
250
|
+
[`tests/test_differential.md`](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/tests/test_differential.md).
|
|
251
251
|
|
|
252
252
|
## License
|
|
253
253
|
|
|
@@ -23,6 +23,20 @@ class SparseIndex:
|
|
|
23
23
|
def create(cls, path, model="mini", **kw):
|
|
24
24
|
return cls(path, model=model, **kw)
|
|
25
25
|
|
|
26
|
+
@classmethod
|
|
27
|
+
def create_external(cls, path, vocab):
|
|
28
|
+
"""An index that takes term vectors from the caller: no model and no query
|
|
29
|
+
weight table. vocab is the list of token strings, index = term id."""
|
|
30
|
+
ix = cls.__new__(cls)
|
|
31
|
+
ix.store = SparseStore(path)
|
|
32
|
+
ix._enc, ix._model, ix._max_seq, ix._device = None, "external", 256, None
|
|
33
|
+
if not ix.store.get_meta("format"):
|
|
34
|
+
vocab = list(vocab)
|
|
35
|
+
assert len(set(vocab)) == len(vocab), "vocabulary has duplicate tokens"
|
|
36
|
+
ix.store.init_model("external", vocab, np.zeros(len(vocab)))
|
|
37
|
+
ix.engine = QueryEngine(ix.store.db)
|
|
38
|
+
return ix
|
|
39
|
+
|
|
26
40
|
def encoder(self):
|
|
27
41
|
if self._enc is None:
|
|
28
42
|
from .encoder import TorchEncoder
|
|
@@ -51,6 +65,23 @@ class SparseIndex:
|
|
|
51
65
|
self.engine.reload()
|
|
52
66
|
return total
|
|
53
67
|
|
|
68
|
+
def add_terms(self, id, terms, title=""):
|
|
69
|
+
"""Store a document encoded by the caller, {token: weight}. Every token must
|
|
70
|
+
be in the index vocabulary; non-positive weights are dropped."""
|
|
71
|
+
v2i = self.engine._v2i
|
|
72
|
+
unknown = [t for t in terms if t not in v2i]
|
|
73
|
+
if unknown:
|
|
74
|
+
raise ValueError(f"terms not in the index vocabulary: {unknown[:5]}")
|
|
75
|
+
enc = {}
|
|
76
|
+
for tok, w in terms.items():
|
|
77
|
+
if w > 0:
|
|
78
|
+
enc[v2i[tok]] = enc.get(v2i[tok], 0.0) + float(w)
|
|
79
|
+
self.store.add_encoded([(id, title, None, enc)])
|
|
80
|
+
self.engine.reload()
|
|
81
|
+
|
|
82
|
+
def search_terms(self, terms, k=10):
|
|
83
|
+
return self.engine.search_terms(terms, k=k)
|
|
84
|
+
|
|
54
85
|
def delete(self, id):
|
|
55
86
|
self.store.delete(id)
|
|
56
87
|
|
|
@@ -106,8 +106,27 @@ class QueryEngine:
|
|
|
106
106
|
qw[t] = qw.get(t, 0.0) + w
|
|
107
107
|
return qw
|
|
108
108
|
|
|
109
|
+
def encode_terms(self, terms):
|
|
110
|
+
"""{token: weight} -> {term id: weight}. Unknown tokens and non-positive
|
|
111
|
+
weights are dropped, repeated tokens summed."""
|
|
112
|
+
qw = {}
|
|
113
|
+
for tok, w in terms.items():
|
|
114
|
+
t = self._v2i.get(tok)
|
|
115
|
+
if t is None or not w > 0:
|
|
116
|
+
continue
|
|
117
|
+
qw[t] = qw.get(t, 0.0) + float(w)
|
|
118
|
+
return qw
|
|
119
|
+
|
|
109
120
|
def search(self, text, k=10):
|
|
110
|
-
|
|
121
|
+
if not self._qlut:
|
|
122
|
+
raise ValueError("this index has no query weight table (built from a vocabulary alone); "
|
|
123
|
+
"use search_terms with a {token: weight} query")
|
|
124
|
+
return self._search_ids(self.encode_query(text), k)
|
|
125
|
+
|
|
126
|
+
def search_terms(self, terms, k=10):
|
|
127
|
+
return self._search_ids(self.encode_terms(terms), k)
|
|
128
|
+
|
|
129
|
+
def _search_ids(self, qw, k):
|
|
111
130
|
if not qw or not self.ndocs:
|
|
112
131
|
return []
|
|
113
132
|
score = np.zeros(self.ndocs + 1, dtype=np.float64)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlite-sparse
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Semantic search in one SQLite file. No model, no server at query time.
|
|
5
5
|
Author-email: Arbaz Siddiqui <arbaz00@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -55,27 +55,33 @@ but the keywords were chosen by a transformer. OpenSearch's inference-free varia
|
|
|
55
55
|
the encoder only on documents; each query token gets one learned weight from a lookup
|
|
56
56
|
table, and retrieval is an exact dot product.
|
|
57
57
|
|
|
58
|
-
Dense retrieval (vector search)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
the
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
58
|
+
Dense retrieval (vector search) runs an embedding model on every query. A learned sparse
|
|
59
|
+
index moves all model work to write time, and you can see which terms matched and with
|
|
60
|
+
what weight. The cost is some quality against good dense models of the same size and much
|
|
61
|
+
slower indexing; the numbers are under Benchmarks.
|
|
62
|
+
|
|
63
|
+
SPLADE encoders are BERT models with their masked-language-model head still attached. BERT
|
|
64
|
+
was trained to fill in blanks: shown `aspirin prevents [MASK]`, that head scores every
|
|
65
|
+
word in the vocabulary as a candidate for the blank. SPLADE points the same head at every
|
|
66
|
+
token of a document and keeps the best score each word gets, so a sentence about heart
|
|
67
|
+
attacks earns a weight for `cardiac` even though the word is not in it. Those per-word
|
|
68
|
+
scores are the sparse vector; the head is the entire trick. llama.cpp runs BERT-family
|
|
69
|
+
models for embeddings only: its converter drops the head (the `cls.predictions` tensors,
|
|
70
|
+
along with the pooler) and its graph stops at the per-token vectors, so `llama-embedding`
|
|
71
|
+
on one of these models returns embeddings and no way to turn them back into words.
|
|
72
|
+
|
|
73
|
+
sqlite-sparse keeps the head. The converter copies its weights out of the checkpoint into
|
|
74
|
+
a small `.sprs` file next to the GGUF, along with the query weight table. At insert time
|
|
75
|
+
llama.cpp runs the encoder as usual and the extension runs the head over the token vectors
|
|
76
|
+
itself, in C on ggml: a dense layer, GELU, LayerNorm, then a score for every word in the
|
|
77
|
+
vocabulary, keeping the highest score each word received across the tokens and applying
|
|
78
|
+
log(1 + ReLU) so the weights are positive and compressed. That turns an encoder llama.cpp
|
|
79
|
+
can already run into a sparse retriever. The rest is what a search cluster provides and
|
|
80
|
+
SQLite does not: the virtual table, posting lists stored as rows, the query-time
|
|
81
|
+
scatter-add, and the file format with a reference implementation to test it against.
|
|
82
|
+
[sqlite-vec](https://github.com/asg017/sqlite-vec) did this for embeddings in SQLite; this
|
|
83
|
+
does it for learned sparse, which so far has lived inside OpenSearch, Elasticsearch and
|
|
84
|
+
Vespa.
|
|
79
85
|
|
|
80
86
|
## Install
|
|
81
87
|
|
|
@@ -87,15 +93,14 @@ Or take the binary from the [releases page](https://github.com/arbazsiddiqui/sql
|
|
|
87
93
|
and use it from any language.
|
|
88
94
|
|
|
89
95
|
```
|
|
90
|
-
tar xzf sparse0-1.
|
|
96
|
+
tar xzf sparse0-1.1.0-loadable-linux-x86_64.tar.gz # or -macos-arm64
|
|
91
97
|
sqlite3 notes.db
|
|
92
98
|
sqlite> .load ./sparse0
|
|
93
99
|
```
|
|
94
100
|
|
|
95
101
|
Keep the filename `sparse0.so` / `sparse0.dylib`, since SQLite derives the entry point
|
|
96
|
-
from it. On macOS
|
|
97
|
-
|
|
98
|
-
sqlite3`.
|
|
102
|
+
from it. On macOS the python.org `sqlite3` module cannot load extensions; use Homebrew or
|
|
103
|
+
conda Python, or `pip install sqlean.py` and `import sqlean as sqlite3`.
|
|
99
104
|
|
|
100
105
|
## Quickstart
|
|
101
106
|
|
|
@@ -106,87 +111,63 @@ db = sqlite3.connect("notes.db")
|
|
|
106
111
|
sqlite_sparse.load(db) # loads the sparse0 extension
|
|
107
112
|
sqlite_sparse.register(db, "mini") # downloads the model on first use
|
|
108
113
|
db.execute("CREATE VIRTUAL TABLE notes USING sparse0(model='mini')")
|
|
109
|
-
db.execute("INSERT INTO notes(rowid, text) VALUES (1, 'Aspirin lowers heart attack risk')")
|
|
114
|
+
db.execute("INSERT INTO notes(rowid, text) VALUES (1, 'Aspirin lowers heart attack risk')") # the model runs here
|
|
110
115
|
db.commit()
|
|
111
|
-
db.execute("SELECT rowid, score FROM notes WHERE notes MATCH ? LIMIT 5",
|
|
116
|
+
db.execute("SELECT rowid, score FROM notes WHERE notes MATCH ? LIMIT 5", # and never here
|
|
112
117
|
("what prevents cardiac arrest",)).fetchall()
|
|
113
118
|
```
|
|
114
119
|
|
|
115
|
-
The model runs at INSERT only; MATCH never loads it. Searching an existing index needs
|
|
116
|
-
no model at all, on any machine.
|
|
117
|
-
|
|
118
120
|
```python
|
|
121
|
+
# Another machine, no model downloaded: MATCH only reads the file.
|
|
119
122
|
db = sqlite3.connect("notes.db")
|
|
120
123
|
sqlite_sparse.load(db)
|
|
121
|
-
db.execute("CREATE VIRTUAL TABLE temp.notes USING sparse0()") # adopts the file
|
|
124
|
+
db.execute("CREATE VIRTUAL TABLE temp.notes USING sparse0()") # adopts the index in the file
|
|
122
125
|
db.execute("SELECT rowid, score FROM temp.notes WHERE notes MATCH 'heart medication' LIMIT 5")
|
|
123
126
|
```
|
|
124
127
|
|
|
125
|
-
A database file holds one sparse index; another `sparse0` table in the same file
|
|
126
|
-
attaches to the same index rather than creating a second one.
|
|
127
|
-
|
|
128
|
-
Because results are rows, semantic search composes with plain SQL. Ask for extra
|
|
129
|
-
candidates with `k`, then filter and join like any other table.
|
|
130
|
-
|
|
131
128
|
```sql
|
|
129
|
+
-- Results are rows: ask for k candidates, then filter and join like any other table.
|
|
132
130
|
SELECT n.rowid, n.score, d.title
|
|
133
131
|
FROM notes n JOIN documents d ON d.id = n.rowid
|
|
134
132
|
WHERE n.text MATCH 'heart medication' AND k = 50 AND d.folder = 'work'
|
|
135
133
|
ORDER BY n.score DESC LIMIT 10;
|
|
136
134
|
```
|
|
137
135
|
|
|
138
|
-
Indexing is the expensive half
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
`LIMIT n` and `AND k = n` both work, and `ORDER BY score DESC` is honoured without a sort
|
|
143
|
-
step. `DELETE FROM notes WHERE rowid = ?` marks a document deleted; run
|
|
144
|
-
`SELECT sparse_compact()` now and then on an index with heavy churn to reclaim its
|
|
145
|
-
postings. Documents longer than `max_seq` tokens (default 512) are truncated at insert, and
|
|
146
|
-
each row in the `docs` table records `ntokens` and `truncated`. The full surface, including
|
|
147
|
-
the Python helpers, is in [docs/api.md](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/docs/api.md).
|
|
136
|
+
Indexing is the expensive half. Build a large corpus once on a GPU machine with
|
|
137
|
+
`sqlite-sparse build` and ship the `.db` to wherever the reads happen. Deletes,
|
|
138
|
+
compaction, truncation and the rest of the surface are in [docs/api.md](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/docs/api.md).
|
|
148
139
|
|
|
149
140
|
## How it works
|
|
150
141
|
|
|
151
142
|

|
|
152
143
|
|
|
153
|
-
**INSERT.** The
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
query weight × stored weight into the running total of every document listed; that
|
|
167
|
-
accumulation is the scatter-add. `prevents` contributes 6.72 × 0.18 and `cardiac`
|
|
168
|
-
6.53 × 0.42 to document 1, total 3.95. The documents touched are sorted and the top k
|
|
169
|
-
returned. Scoring is exact over the stored weights, with no candidate stage and no
|
|
170
|
-
approximate index, and ties break on the lower rowid. Nothing from the GGUF or the
|
|
171
|
-
sidecar is read at query time.
|
|
144
|
+
**INSERT.** The text is tokenized, the encoder runs through llama.cpp, and the head scores
|
|
145
|
+
every vocabulary word against the token vectors. For the aspirin sentence that leaves 157
|
|
146
|
+
weighted terms (`heart` 0.95, `stroke` 0.92, `risk` 0.78, `reduce` 0.70, `cardiac` 0.42,
|
|
147
|
+
`prevents` 0.18, and so on). Each term is appended to that word's posting list, a row in
|
|
148
|
+
the file listing the documents it scored and the weight as one byte.
|
|
149
|
+
|
|
150
|
+
**MATCH.** The query is tokenized the same way and each token gets its weight from the
|
|
151
|
+
table stored in the file (`what` 2.77, `prevents` 6.72, `cardiac` 6.53, `arrest` 6.87).
|
|
152
|
+
For each query word the extension walks that word's posting list and adds query weight ×
|
|
153
|
+
stored weight into every listed document's total, the scatter-add: `prevents` contributes
|
|
154
|
+
6.72 × 0.18 and `cardiac` 6.53 × 0.42 to document 1, total 3.95. Scoring is exact over the
|
|
155
|
+
stored weights, with no candidate stage or approximate index, and nothing from the GGUF or
|
|
156
|
+
the sidecar is read.
|
|
172
157
|
|
|
173
158
|
The file layout is in [FORMAT.md](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/FORMAT.md). The format and the sidecar header carry
|
|
174
159
|
version 1, and files written by any 1.x release stay readable by later 1.x releases.
|
|
175
160
|
|
|
176
161
|
## Benchmarks
|
|
177
162
|
|
|
178
|
-
Three ways to search inside a SQLite file, each in its shipped form, on the same machine
|
|
179
|
-
FTS5
|
|
180
|
-
[sqlite-vec](https://github.com/asg017/sqlite-vec) int8
|
|
181
|
-
[mdbr-leaf-ir](https://huggingface.co/MongoDB/mdbr-leaf-ir) (23M) encoding queries on
|
|
182
|
-
CPU
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
because that is its real query path. This compares the brute-force vector path inside
|
|
187
|
-
SQLite, not an approximate nearest-neighbour index. The FTS5 query is the disjunction of
|
|
188
|
-
the query's tokens ranked by `bm25()`; a conjunction is faster but misses documents that
|
|
189
|
-
match only some of the terms.
|
|
163
|
+
Three ways to search inside a SQLite file, each in its shipped form, on the same machine:
|
|
164
|
+
FTS5, SQLite's built-in keyword search ranked by BM25; dense brute-force with
|
|
165
|
+
[sqlite-vec](https://github.com/asg017/sqlite-vec) int8 and
|
|
166
|
+
[mdbr-leaf-ir](https://huggingface.co/MongoDB/mdbr-leaf-ir) (23M) encoding queries on
|
|
167
|
+
torch CPU; and `sparse0` with `mini` (23M, Q8_0 encoder, u8 postings). Latency is end to
|
|
168
|
+
end, so dense includes encoding the query, because that is its real query path. FTS5 runs
|
|
169
|
+
the OR of the query's tokens ranked by `bm25()` (an AND is faster but misses partial
|
|
170
|
+
matches), and the dense lane is the brute-force scan, not an approximate index.
|
|
190
171
|
|
|
191
172
|
| msmarco, 1M documents | FTS5 BM25 | dense brute-force | sqlite-sparse |
|
|
192
173
|
|---|---|---|---|
|
|
@@ -199,7 +180,9 @@ match only some of the terms.
|
|
|
199
180
|
| model at query time | none | 23M transformer | none |
|
|
200
181
|
| retrieval | lexical | semantic | semantic |
|
|
201
182
|
|
|
202
|
-
At 100K documents the p50s are 54 ms, 82 ms and 0.26 ms respectively.
|
|
183
|
+
At 100K documents the p50s are 54 ms, 82 ms and 0.26 ms respectively. Measured on a GCE
|
|
184
|
+
`c3-standard-8` (8 vCPU, 4 physical cores); the scripts and raw results are attached to
|
|
185
|
+
each release.
|
|
203
186
|
|
|
204
187
|
### The extension does not lose the model's quality
|
|
205
188
|
|
|
@@ -218,14 +201,9 @@ the same documents and queries. The gain ranges from small (SciFact) to large (F
|
|
|
218
201
|
|
|
219
202
|
The same model run in torch at fp32 agrees with the extension on 96 to 98 percent of
|
|
220
203
|
top-10 results on every dataset, and storing weights as one byte instead of fp32 changed
|
|
221
|
-
nDCG@10 by less than 0.001.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
with each lane in its own fresh process. 4,000 samples × 5 repetitions per lane (900 × 3
|
|
225
|
-
for dense and 1,000 × 3 for FTS5 at 1M), median of repetition medians. Cold start is the
|
|
226
|
-
second of three fresh-process runs. RAM is peak RSS after 50 warm queries. The corpus is
|
|
227
|
-
the first 100K and 1M passages of MS MARCO with its dev queries. Benchmark scripts and raw
|
|
228
|
-
results are attached to each release.
|
|
204
|
+
nDCG@10 by less than 0.001. For context against dense models of the same size,
|
|
205
|
+
mdbr-leaf-ir (23M) reports 0.5355 BEIR average to `mini`'s 0.497; the two do very
|
|
206
|
+
different amounts of work at query time, so that is context, not a controlled comparison.
|
|
229
207
|
|
|
230
208
|
## Models
|
|
231
209
|
|
|
@@ -235,17 +213,16 @@ results are attached to each release.
|
|
|
235
213
|
| `base` | [doc-v3-distill](https://huggingface.co/opensearch-project/opensearch-neural-sparse-encoding-doc-v3-distill) | 67M | 0.517 | [arbazsiddiqui/opensearch-neural-sparse-doc-v3-distill-GGUF](https://huggingface.co/arbazsiddiqui/opensearch-neural-sparse-doc-v3-distill-GGUF) |
|
|
236
214
|
| `multilingual` | [multilingual-v1](https://huggingface.co/opensearch-project/opensearch-neural-sparse-encoding-multilingual-v1) | 168M | multilingual | [arbazsiddiqui/opensearch-neural-sparse-multilingual-v1-GGUF](https://huggingface.co/arbazsiddiqui/opensearch-neural-sparse-multilingual-v1-GGUF) |
|
|
237
215
|
|
|
238
|
-
All three are in the [sqlite-sparse
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
216
|
+
All three are in the [sqlite-sparse
|
|
217
|
+
models](https://huggingface.co/collections/arbazsiddiqui/sqlite-sparse-models-6a929c8e0cb15b0e8ed47d43)
|
|
218
|
+
collection, and `sqlite_sparse.register(db, alias)` fetches one into
|
|
219
|
+
`~/.cache/sqlite-sparse`. Weights are unmodified from the Apache-2.0 originals by the
|
|
220
|
+
OpenSearch project.
|
|
243
221
|
|
|
244
222
|
### Bring your own model
|
|
245
223
|
|
|
246
|
-
Any inference-free OpenSearch-style sparse encoder on Hugging Face works
|
|
247
|
-
|
|
248
|
-
vocabulary.
|
|
224
|
+
Any inference-free OpenSearch-style sparse encoder on Hugging Face works: the encoder as
|
|
225
|
+
GGUF plus a `.sprs` sidecar holding the head and the query weight table.
|
|
249
226
|
|
|
250
227
|
```
|
|
251
228
|
git clone --depth 1 https://github.com/ggml-org/llama.cpp
|
|
@@ -260,9 +237,29 @@ SELECT sparse_register('mine', 'model_q8.gguf', 'model.sprs');
|
|
|
260
237
|
CREATE VIRTUAL TABLE notes USING sparse0(model='mine');
|
|
261
238
|
```
|
|
262
239
|
|
|
263
|
-
The
|
|
264
|
-
|
|
265
|
-
|
|
240
|
+
The checkpoint must be a BERT-family encoder with a masked-LM head and a static query
|
|
241
|
+
weight table, and llama.cpp must support the architecture (it does not support GTE,
|
|
242
|
+
`doc-v3-gte`).
|
|
243
|
+
|
|
244
|
+
### Bring your own vectors
|
|
245
|
+
|
|
246
|
+
Any sparse model works if you run it yourself, including SPLADE models that encode the
|
|
247
|
+
query too, and models llama.cpp cannot run. Create the index from the model's vocabulary
|
|
248
|
+
and hand it `{"token": weight}` objects for documents and for queries. Nothing is
|
|
249
|
+
converted; the extension stores and scores, and the file is the same format.
|
|
250
|
+
|
|
251
|
+
```sql
|
|
252
|
+
CREATE VIRTUAL TABLE notes USING sparse0(vocab='vocab.txt'); -- one token per line, no model
|
|
253
|
+
INSERT INTO notes(rowid, terms) VALUES (1, '{"heart": 0.95, "cardiac": 0.42, "stroke": 0.92}');
|
|
254
|
+
SELECT rowid, score FROM notes WHERE notes.terms MATCH '{"cardiac": 6.53, "arrest": 6.87}' LIMIT 5;
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Tokens must be in the vocabulary (an unknown token is an error on insert and ignored in a
|
|
258
|
+
query), weights must be positive, and weights above 6.375 saturate the one-byte storage.
|
|
259
|
+
`terms MATCH` also works on an index one of the shipped models built, so a query encoded
|
|
260
|
+
by your own model can search it. Text queries on a vocabulary-only index are an error,
|
|
261
|
+
since there is no query weight table. In Python: `SparseIndex.create_external(path,
|
|
262
|
+
vocab)`, `add_terms(id, terms)`, `search_terms(terms)`.
|
|
266
263
|
|
|
267
264
|
## Development
|
|
268
265
|
|
|
@@ -275,7 +272,10 @@ make test # installs the Python binding and runs the suite
|
|
|
275
272
|
`src/` is the extension (`sparse0.c` virtual table, `wordpiece.c` tokenizer on utf8proc, `head.c`
|
|
276
273
|
scoring head on ggml, `scorer.c` scatter-add, `encoder.c` llama.cpp wrapper).
|
|
277
274
|
`bindings/python` is the reference implementation of the file format and the test oracle.
|
|
278
|
-
|
|
275
|
+
Each shipped conversion is validated against the original SentenceTransformers
|
|
276
|
+
implementation (encoder hidden states at cosine 0.9997 or better, term weights within
|
|
277
|
+
1.3e-3), and component agreement is logged in
|
|
278
|
+
[`tests/test_differential.md`](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/tests/test_differential.md).
|
|
279
279
|
|
|
280
280
|
## License
|
|
281
281
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|