sqlite-sparse 0.1.0.post1__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.
Files changed (21) hide show
  1. {sqlite_sparse-0.1.0.post1/sqlite_sparse.egg-info → sqlite_sparse-1.1.0}/PKG-INFO +106 -109
  2. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0}/README.md +101 -101
  3. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0}/pyproject.toml +2 -3
  4. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0}/sqlite_sparse/__init__.py +1 -1
  5. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0}/sqlite_sparse/api.py +31 -0
  6. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0}/sqlite_sparse/search.py +20 -1
  7. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0/sqlite_sparse.egg-info}/PKG-INFO +106 -109
  8. sqlite_sparse-1.1.0/sqlite_sparse.egg-info/requires.txt +9 -0
  9. sqlite_sparse-0.1.0.post1/sqlite_sparse.egg-info/requires.txt +0 -13
  10. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0}/LICENSE +0 -0
  11. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0}/setup.cfg +0 -0
  12. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0}/sqlite_sparse/cli.py +0 -0
  13. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0}/sqlite_sparse/convert.py +0 -0
  14. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0}/sqlite_sparse/encoder.py +0 -0
  15. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0}/sqlite_sparse/loadable.py +0 -0
  16. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0}/sqlite_sparse/models.py +0 -0
  17. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0}/sqlite_sparse/store.py +0 -0
  18. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0}/sqlite_sparse.egg-info/SOURCES.txt +0 -0
  19. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0}/sqlite_sparse.egg-info/dependency_links.txt +0 -0
  20. {sqlite_sparse-0.1.0.post1 → sqlite_sparse-1.1.0}/sqlite_sparse.egg-info/entry_points.txt +0 -0
  21. {sqlite_sparse-0.1.0.post1 → 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: 0.1.0.post1
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
@@ -18,13 +18,10 @@ Requires-Python: >=3.9
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
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"
21
+ Provides-Extra: convert
22
+ Requires-Dist: sentence-transformers>=5.0; extra == "convert"
23
+ Requires-Dist: torch; extra == "convert"
24
+ Requires-Dist: transformers>=4.40; extra == "convert"
28
25
  Provides-Extra: dev
29
26
  Requires-Dist: pytest>=7; extra == "dev"
30
27
  Dynamic: license-file
@@ -58,27 +55,33 @@ but the keywords were chosen by a transformer. OpenSearch's inference-free varia
58
55
  the encoder only on documents; each query token gets one learned weight from a lookup
59
56
  table, and retrieval is an exact dot product.
60
57
 
61
- Dense retrieval (vector search) instead runs an embedding model on every query. A learned
62
- sparse index moves all model work to write time, and the representation is legible, since
63
- you can see which terms matched and with what weight. The cost is quality against good
64
- dense models of the same size and much slower indexing. `mini` (23M) averages 0.497
65
- nDCG@10 on BEIR and mdbr-leaf-ir (23M dense) reports 0.5355 in its symmetric
66
- configuration. The two do very different amounts of work at query time, so this is
67
- context, not a controlled comparison. For read-heavy workloads where indexing is
68
- amortized over many searches, the trade can be favourable.
69
-
70
- This library ships OpenSearch's inference-free sparse models, which until now have lived
71
- inside search clusters (OpenSearch, Elasticsearch, Vespa).
72
- [sqlite-vec](https://github.com/asg017/sqlite-vec) provides a way to use embeddings in
73
- SQLite; this does the same for learned sparse, whose posting lists are plain rows in the
74
- database file. What this project contributes is the embedded
75
- implementation, the file format with a reference implementation to test it against, and
76
- the measurements.
77
-
78
- The searchable index and the query weights live in the SQLite database. The encoder is
79
- needed only when inserting documents. So the `.db` can be copied, shipped inside an app,
80
- opened on any machine with no GPU, and queried with plain SQL from any language that
81
- has SQLite. Prebuilt binaries cover Linux x86-64 and macOS arm64.
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.
82
85
 
83
86
  ## Install
84
87
 
@@ -90,15 +93,14 @@ Or take the binary from the [releases page](https://github.com/arbazsiddiqui/sql
90
93
  and use it from any language.
91
94
 
92
95
  ```
93
- tar xzf sparse0-0.1.0-loadable-linux-x86_64.tar.gz # or -macos-arm64
96
+ tar xzf sparse0-1.1.0-loadable-linux-x86_64.tar.gz # or -macos-arm64
94
97
  sqlite3 notes.db
95
98
  sqlite> .load ./sparse0
96
99
  ```
97
100
 
98
101
  Keep the filename `sparse0.so` / `sparse0.dylib`, since SQLite derives the entry point
99
- from it. On macOS, the python.org installer's `sqlite3` module cannot load extensions.
100
- Use Python from Homebrew or conda, or `pip install sqlean.py` and `import sqlean as
101
- 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`.
102
104
 
103
105
  ## Quickstart
104
106
 
@@ -109,87 +111,63 @@ db = sqlite3.connect("notes.db")
109
111
  sqlite_sparse.load(db) # loads the sparse0 extension
110
112
  sqlite_sparse.register(db, "mini") # downloads the model on first use
111
113
  db.execute("CREATE VIRTUAL TABLE notes USING sparse0(model='mini')")
112
- 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
113
115
  db.commit()
114
- 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
115
117
  ("what prevents cardiac arrest",)).fetchall()
116
118
  ```
117
119
 
118
- The model runs at INSERT only; MATCH never loads it. Searching an existing index needs
119
- no model at all, on any machine.
120
-
121
120
  ```python
121
+ # Another machine, no model downloaded: MATCH only reads the file.
122
122
  db = sqlite3.connect("notes.db")
123
123
  sqlite_sparse.load(db)
124
- 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
125
125
  db.execute("SELECT rowid, score FROM temp.notes WHERE notes MATCH 'heart medication' LIMIT 5")
126
126
  ```
127
127
 
128
- A database file holds one sparse index; another `sparse0` table in the same file
129
- attaches to the same index rather than creating a second one.
130
-
131
- Because results are rows, semantic search composes with plain SQL. Ask for extra
132
- candidates with `k`, then filter and join like any other table.
133
-
134
128
  ```sql
129
+ -- Results are rows: ask for k candidates, then filter and join like any other table.
135
130
  SELECT n.rowid, n.score, d.title
136
131
  FROM notes n JOIN documents d ON d.id = n.rowid
137
132
  WHERE n.text MATCH 'heart medication' AND k = 50 AND d.folder = 'work'
138
133
  ORDER BY n.score DESC LIMIT 10;
139
134
  ```
140
135
 
141
- Indexing is the expensive half, so large corpora are best built once on a GPU machine
142
- with the Python package (`sqlite-sparse build`, which writes the same file format) and
143
- the `.db` then shipped to wherever the reads happen.
144
-
145
- `LIMIT n` and `AND k = n` both work, and `ORDER BY score DESC` is honoured without a sort
146
- step. `DELETE FROM notes WHERE rowid = ?` marks a document deleted; run
147
- `SELECT sparse_compact()` now and then on an index with heavy churn to reclaim its
148
- postings. Documents longer than `max_seq` tokens (default 512) are truncated at insert, and
149
- each row in the `docs` table records `ntokens` and `truncated`. The full surface, including
150
- 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).
151
139
 
152
140
  ## How it works
153
141
 
154
142
  ![How sqlite-sparse indexes and searches](https://raw.githubusercontent.com/arbazsiddiqui/sqlite-sparse/master/docs/how-it-works.svg)
155
143
 
156
- **INSERT.** The extension tokenizes the text with the registered model's tokenizer and
157
- runs the encoder from the GGUF file through llama.cpp, one vector per token. It then
158
- applies the scoring head from the `.sprs` sidecar, which scores every word in the model's
159
- vocabulary against those vectors; positive scores are kept, compressed with a logarithm,
160
- and become the document's weighted terms. For the aspirin sentence that is 157 terms
161
- (`heart` 0.95, `stroke` 0.92, `risk` 0.78, `reduce` 0.70, `cardiac` 0.42, `prevents` 0.18,
162
- and so on). Each term is appended to that word's posting list, a row in the file listing
163
- the documents it scored and the weight, stored as one byte. The document's token count
164
- and whether it was truncated go into the `docs` table.
165
-
166
- **MATCH.** The extension tokenizes the query the same way and reads one number per token
167
- from the query weight table stored in the file (`what` 2.77, `prevents` 6.72, `cardiac`
168
- 6.53, `arrest` 6.87). For each query word it walks that word's posting list and adds
169
- query weight × stored weight into the running total of every document listed; that
170
- accumulation is the scatter-add. `prevents` contributes 6.72 × 0.18 and `cardiac`
171
- 6.53 × 0.42 to document 1, total 3.95. The documents touched are sorted and the top k
172
- returned. Scoring is exact over the stored weights, with no candidate stage and no
173
- approximate index, and ties break on the lower rowid. Nothing from the GGUF or the
174
- 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.
175
157
 
176
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
177
- version 1; format stability is not promised before 1.0.
159
+ version 1, and files written by any 1.x release stay readable by later 1.x releases.
178
160
 
179
161
  ## Benchmarks
180
162
 
181
- Three ways to search inside a SQLite file, each in its shipped form, on the same machine.
182
- FTS5 is SQLite's built-in keyword search ranked by BM25. Dense brute-force is
183
- [sqlite-vec](https://github.com/asg017/sqlite-vec) int8, which scans every vector, with
184
- [mdbr-leaf-ir](https://huggingface.co/MongoDB/mdbr-leaf-ir) (23M) encoding queries on torch
185
- CPU with 8 threads. Sparse is the `sparse0` extension with `mini` (23M, Q8_0 encoder, u8
186
- postings).
187
-
188
- Every number is end-to-end query latency, which for dense includes encoding the query,
189
- because that is its real query path. This compares the brute-force vector path inside
190
- SQLite, not an approximate nearest-neighbour index. The FTS5 query is the disjunction of
191
- the query's tokens ranked by `bm25()`; a conjunction is faster but misses documents that
192
- 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.
193
171
 
194
172
  | msmarco, 1M documents | FTS5 BM25 | dense brute-force | sqlite-sparse |
195
173
  |---|---|---|---|
@@ -202,7 +180,9 @@ match only some of the terms.
202
180
  | model at query time | none | 23M transformer | none |
203
181
  | retrieval | lexical | semantic | semantic |
204
182
 
205
- 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.
206
186
 
207
187
  ### The extension does not lose the model's quality
208
188
 
@@ -221,14 +201,9 @@ the same documents and queries. The gain ranges from small (SciFact) to large (F
221
201
 
222
202
  The same model run in torch at fp32 agrees with the extension on 96 to 98 percent of
223
203
  top-10 results on every dataset, and storing weights as one byte instead of fp32 changed
224
- nDCG@10 by less than 0.001.
225
-
226
- Venue was a dedicated GCE `c3-standard-8` (8 vCPU, 4 physical cores) running Debian 12,
227
- with each lane in its own fresh process. 4,000 samples × 5 repetitions per lane (900 × 3
228
- for dense and 1,000 × 3 for FTS5 at 1M), median of repetition medians. Cold start is the
229
- second of three fresh-process runs. RAM is peak RSS after 50 warm queries. The corpus is
230
- the first 100K and 1M passages of MS MARCO with its dev queries. Benchmark scripts and raw
231
- 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.
232
207
 
233
208
  ## Models
234
209
 
@@ -238,23 +213,22 @@ results are attached to each release.
238
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) |
239
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) |
240
215
 
241
- All three are in the [sqlite-sparse models](https://huggingface.co/collections/arbazsiddiqui/sqlite-sparse-models-6a929c8e0cb15b0e8ed47d43)
242
- collection, and `sqlite_sparse.register(db, alias)` fetches one into `~/.cache/sqlite-sparse`.
243
- Each conversion is validated against the original SentenceTransformers implementation
244
- (encoder hidden states at cosine 0.9997 or better, term weights within 1.3e-3). Weights
245
- are unmodified from the Apache-2.0 originals by the OpenSearch project.
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.
246
221
 
247
222
  ### Bring your own model
248
223
 
249
- Any inference-free OpenSearch-style sparse encoder on Hugging Face works. Two files are
250
- needed, the encoder as GGUF and a sidecar with the scoring head, query weight table and
251
- 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.
252
226
 
253
227
  ```
254
228
  git clone --depth 1 https://github.com/ggml-org/llama.cpp
255
229
  python llama.cpp/convert_hf_to_gguf.py <hf-model-id> --outfile model_f16.gguf --outtype f16
256
230
  llama.cpp/build/bin/llama-quantize model_f16.gguf model_q8.gguf q8_0 # optional
257
- pip install "sqlite-sparse[build-torch]"
231
+ pip install "sqlite-sparse[convert]" # torch and sentence-transformers, only for this step
258
232
  sqlite-sparse convert <hf-model-id> model.sprs # --double-log for v3 models
259
233
  ```
260
234
 
@@ -263,9 +237,29 @@ SELECT sparse_register('mine', 'model_q8.gguf', 'model.sprs');
263
237
  CREATE VIRTUAL TABLE notes USING sparse0(model='mine');
264
238
  ```
265
239
 
266
- The converter requires the checkpoint to be a BERT-family encoder with a masked-LM head
267
- and a static query weight table. llama.cpp must support the encoder architecture; it does
268
- not support GTE (`doc-v3-gte`).
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)`.
269
263
 
270
264
  ## Development
271
265
 
@@ -278,7 +272,10 @@ make test # installs the Python binding and runs the suite
278
272
  `src/` is the extension (`sparse0.c` virtual table, `wordpiece.c` tokenizer on utf8proc, `head.c`
279
273
  scoring head on ggml, `scorer.c` scatter-add, `encoder.c` llama.cpp wrapper).
280
274
  `bindings/python` is the reference implementation of the file format and the test oracle.
281
- Component agreement is logged in [`tests/test_differential.md`](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/tests/test_differential.md).
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).
282
279
 
283
280
  ## License
284
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) instead runs an embedding model on every query. A learned
31
- sparse index moves all model work to write time, and the representation is legible, since
32
- you can see which terms matched and with what weight. The cost is quality against good
33
- dense models of the same size and much slower indexing. `mini` (23M) averages 0.497
34
- nDCG@10 on BEIR and mdbr-leaf-ir (23M dense) reports 0.5355 in its symmetric
35
- configuration. The two do very different amounts of work at query time, so this is
36
- context, not a controlled comparison. For read-heavy workloads where indexing is
37
- amortized over many searches, the trade can be favourable.
38
-
39
- This library ships OpenSearch's inference-free sparse models, which until now have lived
40
- inside search clusters (OpenSearch, Elasticsearch, Vespa).
41
- [sqlite-vec](https://github.com/asg017/sqlite-vec) provides a way to use embeddings in
42
- SQLite; this does the same for learned sparse, whose posting lists are plain rows in the
43
- database file. What this project contributes is the embedded
44
- implementation, the file format with a reference implementation to test it against, and
45
- the measurements.
46
-
47
- The searchable index and the query weights live in the SQLite database. The encoder is
48
- needed only when inserting documents. So the `.db` can be copied, shipped inside an app,
49
- opened on any machine with no GPU, and queried with plain SQL from any language that
50
- has SQLite. Prebuilt binaries cover Linux x86-64 and macOS arm64.
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-0.1.0-loadable-linux-x86_64.tar.gz # or -macos-arm64
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, the python.org installer's `sqlite3` module cannot load extensions.
69
- Use Python from Homebrew or conda, or `pip install sqlean.py` and `import sqlean as
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, so large corpora are best built once on a GPU machine
111
- with the Python package (`sqlite-sparse build`, which writes the same file format) and
112
- the `.db` then shipped to wherever the reads happen.
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
  ![How sqlite-sparse indexes and searches](https://raw.githubusercontent.com/arbazsiddiqui/sqlite-sparse/master/docs/how-it-works.svg)
124
115
 
125
- **INSERT.** The extension tokenizes the text with the registered model's tokenizer and
126
- runs the encoder from the GGUF file through llama.cpp, one vector per token. It then
127
- applies the scoring head from the `.sprs` sidecar, which scores every word in the model's
128
- vocabulary against those vectors; positive scores are kept, compressed with a logarithm,
129
- and become the document's weighted terms. For the aspirin sentence that is 157 terms
130
- (`heart` 0.95, `stroke` 0.92, `risk` 0.78, `reduce` 0.70, `cardiac` 0.42, `prevents` 0.18,
131
- and so on). Each term is appended to that word's posting list, a row in the file listing
132
- the documents it scored and the weight, stored as one byte. The document's token count
133
- and whether it was truncated go into the `docs` table.
134
-
135
- **MATCH.** The extension tokenizes the query the same way and reads one number per token
136
- from the query weight table stored in the file (`what` 2.77, `prevents` 6.72, `cardiac`
137
- 6.53, `arrest` 6.87). For each query word it walks that word's posting list and adds
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
- version 1; format stability is not promised before 1.0.
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 is SQLite's built-in keyword search ranked by BM25. Dense brute-force is
152
- [sqlite-vec](https://github.com/asg017/sqlite-vec) int8, which scans every vector, with
153
- [mdbr-leaf-ir](https://huggingface.co/MongoDB/mdbr-leaf-ir) (23M) encoding queries on torch
154
- CPU with 8 threads. Sparse is the `sparse0` extension with `mini` (23M, Q8_0 encoder, u8
155
- postings).
156
-
157
- Every number is end-to-end query latency, which for dense includes encoding the query,
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
- Venue was a dedicated GCE `c3-standard-8` (8 vCPU, 4 physical cores) running Debian 12,
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,23 +185,22 @@ 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 models](https://huggingface.co/collections/arbazsiddiqui/sqlite-sparse-models-6a929c8e0cb15b0e8ed47d43)
211
- collection, and `sqlite_sparse.register(db, alias)` fetches one into `~/.cache/sqlite-sparse`.
212
- Each conversion is validated against the original SentenceTransformers implementation
213
- (encoder hidden states at cosine 0.9997 or better, term weights within 1.3e-3). Weights
214
- are unmodified from the Apache-2.0 originals by the OpenSearch project.
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. Two files are
219
- needed, the encoder as GGUF and a sidecar with the scoring head, query weight table and
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
224
201
  python llama.cpp/convert_hf_to_gguf.py <hf-model-id> --outfile model_f16.gguf --outtype f16
225
202
  llama.cpp/build/bin/llama-quantize model_f16.gguf model_q8.gguf q8_0 # optional
226
- pip install "sqlite-sparse[build-torch]"
203
+ pip install "sqlite-sparse[convert]" # torch and sentence-transformers, only for this step
227
204
  sqlite-sparse convert <hf-model-id> model.sprs # --double-log for v3 models
228
205
  ```
229
206
 
@@ -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 converter requires the checkpoint to be a BERT-family encoder with a masked-LM head
236
- and a static query weight table. llama.cpp must support the encoder architecture; it does
237
- not support GTE (`doc-v3-gte`).
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
- Component agreement is logged in [`tests/test_differential.md`](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/tests/test_differential.md).
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlite-sparse"
3
- version = "0.1.0.post1"
3
+ version = "1.1.0"
4
4
  description = "Semantic search in one SQLite file. No model, no server at query time."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -24,8 +24,7 @@ Repository = "https://github.com/arbazsiddiqui/sqlite-sparse"
24
24
  Issues = "https://github.com/arbazsiddiqui/sqlite-sparse/issues"
25
25
 
26
26
  [project.optional-dependencies]
27
- build = ["onnxruntime>=1.17", "tokenizers>=0.15", "huggingface_hub>=0.20"]
28
- build-torch = ["sentence-transformers>=5.0", "torch"]
27
+ convert = ["sentence-transformers>=5.0", "torch", "transformers>=4.40"]
29
28
  dev = ["pytest>=7"]
30
29
 
31
30
  [project.scripts]
@@ -3,4 +3,4 @@ from .loadable import load, loadable_path
3
3
  from .models import MODELS, fetch, register
4
4
 
5
5
  __all__ = ["SparseIndex", "load", "loadable_path", "MODELS", "fetch", "register"]
6
- __version__ = "0.1.0.post1"
6
+ __version__ = "1.1.0"
@@ -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
- qw = self.encode_query(text)
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: 0.1.0.post1
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
@@ -18,13 +18,10 @@ Requires-Python: >=3.9
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
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"
21
+ Provides-Extra: convert
22
+ Requires-Dist: sentence-transformers>=5.0; extra == "convert"
23
+ Requires-Dist: torch; extra == "convert"
24
+ Requires-Dist: transformers>=4.40; extra == "convert"
28
25
  Provides-Extra: dev
29
26
  Requires-Dist: pytest>=7; extra == "dev"
30
27
  Dynamic: license-file
@@ -58,27 +55,33 @@ but the keywords were chosen by a transformer. OpenSearch's inference-free varia
58
55
  the encoder only on documents; each query token gets one learned weight from a lookup
59
56
  table, and retrieval is an exact dot product.
60
57
 
61
- Dense retrieval (vector search) instead runs an embedding model on every query. A learned
62
- sparse index moves all model work to write time, and the representation is legible, since
63
- you can see which terms matched and with what weight. The cost is quality against good
64
- dense models of the same size and much slower indexing. `mini` (23M) averages 0.497
65
- nDCG@10 on BEIR and mdbr-leaf-ir (23M dense) reports 0.5355 in its symmetric
66
- configuration. The two do very different amounts of work at query time, so this is
67
- context, not a controlled comparison. For read-heavy workloads where indexing is
68
- amortized over many searches, the trade can be favourable.
69
-
70
- This library ships OpenSearch's inference-free sparse models, which until now have lived
71
- inside search clusters (OpenSearch, Elasticsearch, Vespa).
72
- [sqlite-vec](https://github.com/asg017/sqlite-vec) provides a way to use embeddings in
73
- SQLite; this does the same for learned sparse, whose posting lists are plain rows in the
74
- database file. What this project contributes is the embedded
75
- implementation, the file format with a reference implementation to test it against, and
76
- the measurements.
77
-
78
- The searchable index and the query weights live in the SQLite database. The encoder is
79
- needed only when inserting documents. So the `.db` can be copied, shipped inside an app,
80
- opened on any machine with no GPU, and queried with plain SQL from any language that
81
- has SQLite. Prebuilt binaries cover Linux x86-64 and macOS arm64.
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.
82
85
 
83
86
  ## Install
84
87
 
@@ -90,15 +93,14 @@ Or take the binary from the [releases page](https://github.com/arbazsiddiqui/sql
90
93
  and use it from any language.
91
94
 
92
95
  ```
93
- tar xzf sparse0-0.1.0-loadable-linux-x86_64.tar.gz # or -macos-arm64
96
+ tar xzf sparse0-1.1.0-loadable-linux-x86_64.tar.gz # or -macos-arm64
94
97
  sqlite3 notes.db
95
98
  sqlite> .load ./sparse0
96
99
  ```
97
100
 
98
101
  Keep the filename `sparse0.so` / `sparse0.dylib`, since SQLite derives the entry point
99
- from it. On macOS, the python.org installer's `sqlite3` module cannot load extensions.
100
- Use Python from Homebrew or conda, or `pip install sqlean.py` and `import sqlean as
101
- 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`.
102
104
 
103
105
  ## Quickstart
104
106
 
@@ -109,87 +111,63 @@ db = sqlite3.connect("notes.db")
109
111
  sqlite_sparse.load(db) # loads the sparse0 extension
110
112
  sqlite_sparse.register(db, "mini") # downloads the model on first use
111
113
  db.execute("CREATE VIRTUAL TABLE notes USING sparse0(model='mini')")
112
- 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
113
115
  db.commit()
114
- 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
115
117
  ("what prevents cardiac arrest",)).fetchall()
116
118
  ```
117
119
 
118
- The model runs at INSERT only; MATCH never loads it. Searching an existing index needs
119
- no model at all, on any machine.
120
-
121
120
  ```python
121
+ # Another machine, no model downloaded: MATCH only reads the file.
122
122
  db = sqlite3.connect("notes.db")
123
123
  sqlite_sparse.load(db)
124
- 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
125
125
  db.execute("SELECT rowid, score FROM temp.notes WHERE notes MATCH 'heart medication' LIMIT 5")
126
126
  ```
127
127
 
128
- A database file holds one sparse index; another `sparse0` table in the same file
129
- attaches to the same index rather than creating a second one.
130
-
131
- Because results are rows, semantic search composes with plain SQL. Ask for extra
132
- candidates with `k`, then filter and join like any other table.
133
-
134
128
  ```sql
129
+ -- Results are rows: ask for k candidates, then filter and join like any other table.
135
130
  SELECT n.rowid, n.score, d.title
136
131
  FROM notes n JOIN documents d ON d.id = n.rowid
137
132
  WHERE n.text MATCH 'heart medication' AND k = 50 AND d.folder = 'work'
138
133
  ORDER BY n.score DESC LIMIT 10;
139
134
  ```
140
135
 
141
- Indexing is the expensive half, so large corpora are best built once on a GPU machine
142
- with the Python package (`sqlite-sparse build`, which writes the same file format) and
143
- the `.db` then shipped to wherever the reads happen.
144
-
145
- `LIMIT n` and `AND k = n` both work, and `ORDER BY score DESC` is honoured without a sort
146
- step. `DELETE FROM notes WHERE rowid = ?` marks a document deleted; run
147
- `SELECT sparse_compact()` now and then on an index with heavy churn to reclaim its
148
- postings. Documents longer than `max_seq` tokens (default 512) are truncated at insert, and
149
- each row in the `docs` table records `ntokens` and `truncated`. The full surface, including
150
- 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).
151
139
 
152
140
  ## How it works
153
141
 
154
142
  ![How sqlite-sparse indexes and searches](https://raw.githubusercontent.com/arbazsiddiqui/sqlite-sparse/master/docs/how-it-works.svg)
155
143
 
156
- **INSERT.** The extension tokenizes the text with the registered model's tokenizer and
157
- runs the encoder from the GGUF file through llama.cpp, one vector per token. It then
158
- applies the scoring head from the `.sprs` sidecar, which scores every word in the model's
159
- vocabulary against those vectors; positive scores are kept, compressed with a logarithm,
160
- and become the document's weighted terms. For the aspirin sentence that is 157 terms
161
- (`heart` 0.95, `stroke` 0.92, `risk` 0.78, `reduce` 0.70, `cardiac` 0.42, `prevents` 0.18,
162
- and so on). Each term is appended to that word's posting list, a row in the file listing
163
- the documents it scored and the weight, stored as one byte. The document's token count
164
- and whether it was truncated go into the `docs` table.
165
-
166
- **MATCH.** The extension tokenizes the query the same way and reads one number per token
167
- from the query weight table stored in the file (`what` 2.77, `prevents` 6.72, `cardiac`
168
- 6.53, `arrest` 6.87). For each query word it walks that word's posting list and adds
169
- query weight × stored weight into the running total of every document listed; that
170
- accumulation is the scatter-add. `prevents` contributes 6.72 × 0.18 and `cardiac`
171
- 6.53 × 0.42 to document 1, total 3.95. The documents touched are sorted and the top k
172
- returned. Scoring is exact over the stored weights, with no candidate stage and no
173
- approximate index, and ties break on the lower rowid. Nothing from the GGUF or the
174
- 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.
175
157
 
176
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
177
- version 1; format stability is not promised before 1.0.
159
+ version 1, and files written by any 1.x release stay readable by later 1.x releases.
178
160
 
179
161
  ## Benchmarks
180
162
 
181
- Three ways to search inside a SQLite file, each in its shipped form, on the same machine.
182
- FTS5 is SQLite's built-in keyword search ranked by BM25. Dense brute-force is
183
- [sqlite-vec](https://github.com/asg017/sqlite-vec) int8, which scans every vector, with
184
- [mdbr-leaf-ir](https://huggingface.co/MongoDB/mdbr-leaf-ir) (23M) encoding queries on torch
185
- CPU with 8 threads. Sparse is the `sparse0` extension with `mini` (23M, Q8_0 encoder, u8
186
- postings).
187
-
188
- Every number is end-to-end query latency, which for dense includes encoding the query,
189
- because that is its real query path. This compares the brute-force vector path inside
190
- SQLite, not an approximate nearest-neighbour index. The FTS5 query is the disjunction of
191
- the query's tokens ranked by `bm25()`; a conjunction is faster but misses documents that
192
- 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.
193
171
 
194
172
  | msmarco, 1M documents | FTS5 BM25 | dense brute-force | sqlite-sparse |
195
173
  |---|---|---|---|
@@ -202,7 +180,9 @@ match only some of the terms.
202
180
  | model at query time | none | 23M transformer | none |
203
181
  | retrieval | lexical | semantic | semantic |
204
182
 
205
- 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.
206
186
 
207
187
  ### The extension does not lose the model's quality
208
188
 
@@ -221,14 +201,9 @@ the same documents and queries. The gain ranges from small (SciFact) to large (F
221
201
 
222
202
  The same model run in torch at fp32 agrees with the extension on 96 to 98 percent of
223
203
  top-10 results on every dataset, and storing weights as one byte instead of fp32 changed
224
- nDCG@10 by less than 0.001.
225
-
226
- Venue was a dedicated GCE `c3-standard-8` (8 vCPU, 4 physical cores) running Debian 12,
227
- with each lane in its own fresh process. 4,000 samples × 5 repetitions per lane (900 × 3
228
- for dense and 1,000 × 3 for FTS5 at 1M), median of repetition medians. Cold start is the
229
- second of three fresh-process runs. RAM is peak RSS after 50 warm queries. The corpus is
230
- the first 100K and 1M passages of MS MARCO with its dev queries. Benchmark scripts and raw
231
- 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.
232
207
 
233
208
  ## Models
234
209
 
@@ -238,23 +213,22 @@ results are attached to each release.
238
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) |
239
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) |
240
215
 
241
- All three are in the [sqlite-sparse models](https://huggingface.co/collections/arbazsiddiqui/sqlite-sparse-models-6a929c8e0cb15b0e8ed47d43)
242
- collection, and `sqlite_sparse.register(db, alias)` fetches one into `~/.cache/sqlite-sparse`.
243
- Each conversion is validated against the original SentenceTransformers implementation
244
- (encoder hidden states at cosine 0.9997 or better, term weights within 1.3e-3). Weights
245
- are unmodified from the Apache-2.0 originals by the OpenSearch project.
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.
246
221
 
247
222
  ### Bring your own model
248
223
 
249
- Any inference-free OpenSearch-style sparse encoder on Hugging Face works. Two files are
250
- needed, the encoder as GGUF and a sidecar with the scoring head, query weight table and
251
- 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.
252
226
 
253
227
  ```
254
228
  git clone --depth 1 https://github.com/ggml-org/llama.cpp
255
229
  python llama.cpp/convert_hf_to_gguf.py <hf-model-id> --outfile model_f16.gguf --outtype f16
256
230
  llama.cpp/build/bin/llama-quantize model_f16.gguf model_q8.gguf q8_0 # optional
257
- pip install "sqlite-sparse[build-torch]"
231
+ pip install "sqlite-sparse[convert]" # torch and sentence-transformers, only for this step
258
232
  sqlite-sparse convert <hf-model-id> model.sprs # --double-log for v3 models
259
233
  ```
260
234
 
@@ -263,9 +237,29 @@ SELECT sparse_register('mine', 'model_q8.gguf', 'model.sprs');
263
237
  CREATE VIRTUAL TABLE notes USING sparse0(model='mine');
264
238
  ```
265
239
 
266
- The converter requires the checkpoint to be a BERT-family encoder with a masked-LM head
267
- and a static query weight table. llama.cpp must support the encoder architecture; it does
268
- not support GTE (`doc-v3-gte`).
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)`.
269
263
 
270
264
  ## Development
271
265
 
@@ -278,7 +272,10 @@ make test # installs the Python binding and runs the suite
278
272
  `src/` is the extension (`sparse0.c` virtual table, `wordpiece.c` tokenizer on utf8proc, `head.c`
279
273
  scoring head on ggml, `scorer.c` scatter-add, `encoder.c` llama.cpp wrapper).
280
274
  `bindings/python` is the reference implementation of the file format and the test oracle.
281
- Component agreement is logged in [`tests/test_differential.md`](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/tests/test_differential.md).
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).
282
279
 
283
280
  ## License
284
281
 
@@ -0,0 +1,9 @@
1
+ numpy>=1.24
2
+
3
+ [convert]
4
+ sentence-transformers>=5.0
5
+ torch
6
+ transformers>=4.40
7
+
8
+ [dev]
9
+ pytest>=7
@@ -1,13 +0,0 @@
1
- numpy>=1.24
2
-
3
- [build]
4
- onnxruntime>=1.17
5
- tokenizers>=0.15
6
- huggingface_hub>=0.20
7
-
8
- [build-torch]
9
- sentence-transformers>=5.0
10
- torch
11
-
12
- [dev]
13
- pytest>=7