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.
Files changed (20) hide show
  1. {sqlite_sparse-1.0.0/sqlite_sparse.egg-info → sqlite_sparse-1.1.0}/PKG-INFO +100 -100
  2. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/README.md +99 -99
  3. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/pyproject.toml +1 -1
  4. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/__init__.py +1 -1
  5. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/api.py +31 -0
  6. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/search.py +20 -1
  7. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0/sqlite_sparse.egg-info}/PKG-INFO +100 -100
  8. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/LICENSE +0 -0
  9. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/setup.cfg +0 -0
  10. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/cli.py +0 -0
  11. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/convert.py +0 -0
  12. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/encoder.py +0 -0
  13. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/loadable.py +0 -0
  14. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/models.py +0 -0
  15. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse/store.py +0 -0
  16. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse.egg-info/SOURCES.txt +0 -0
  17. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse.egg-info/dependency_links.txt +0 -0
  18. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse.egg-info/entry_points.txt +0 -0
  19. {sqlite_sparse-1.0.0 → sqlite_sparse-1.1.0}/sqlite_sparse.egg-info/requires.txt +0 -0
  20. {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.0.0
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) instead runs an embedding model on every query. A learned
59
- sparse index moves all model work to write time, and the representation is legible, since
60
- you can see which terms matched and with what weight. The cost is quality against good
61
- dense models of the same size and much slower indexing. `mini` (23M) averages 0.497
62
- nDCG@10 on BEIR and mdbr-leaf-ir (23M dense) reports 0.5355 in its symmetric
63
- configuration. The two do very different amounts of work at query time, so this is
64
- context, not a controlled comparison. For read-heavy workloads where indexing is
65
- amortized over many searches, the trade can be favourable.
66
-
67
- This library ships OpenSearch's inference-free sparse models, which until now have lived
68
- inside search clusters (OpenSearch, Elasticsearch, Vespa).
69
- [sqlite-vec](https://github.com/asg017/sqlite-vec) provides a way to use embeddings in
70
- SQLite; this does the same for learned sparse, whose posting lists are plain rows in the
71
- database file. What this project contributes is the embedded
72
- implementation, the file format with a reference implementation to test it against, and
73
- the measurements.
74
-
75
- The searchable index and the query weights live in the SQLite database. The encoder is
76
- needed only when inserting documents. So the `.db` can be copied, shipped inside an app,
77
- opened on any machine with no GPU, and queried with plain SQL from any language that
78
- 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.
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.0.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
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, the python.org installer's `sqlite3` module cannot load extensions.
97
- Use Python from Homebrew or conda, or `pip install sqlean.py` and `import sqlean as
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, so large corpora are best built once on a GPU machine
139
- with the Python package (`sqlite-sparse build`, which writes the same file format) and
140
- the `.db` then shipped to wherever the reads happen.
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
  ![How sqlite-sparse indexes and searches](https://raw.githubusercontent.com/arbazsiddiqui/sqlite-sparse/master/docs/how-it-works.svg)
152
143
 
153
- **INSERT.** The extension tokenizes the text with the registered model's tokenizer and
154
- runs the encoder from the GGUF file through llama.cpp, one vector per token. It then
155
- applies the scoring head from the `.sprs` sidecar, which scores every word in the model's
156
- vocabulary against those vectors; positive scores are kept, compressed with a logarithm,
157
- and become the document's weighted terms. For the aspirin sentence that is 157 terms
158
- (`heart` 0.95, `stroke` 0.92, `risk` 0.78, `reduce` 0.70, `cardiac` 0.42, `prevents` 0.18,
159
- and so on). Each term is appended to that word's posting list, a row in the file listing
160
- the documents it scored and the weight, stored as one byte. The document's token count
161
- and whether it was truncated go into the `docs` table.
162
-
163
- **MATCH.** The extension tokenizes the query the same way and reads one number per token
164
- from the query weight table stored in the file (`what` 2.77, `prevents` 6.72, `cardiac`
165
- 6.53, `arrest` 6.87). For each query word it walks that word's posting list and adds
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 is SQLite's built-in keyword search ranked by BM25. Dense brute-force is
180
- [sqlite-vec](https://github.com/asg017/sqlite-vec) int8, which scans every vector, with
181
- [mdbr-leaf-ir](https://huggingface.co/MongoDB/mdbr-leaf-ir) (23M) encoding queries on torch
182
- CPU with 8 threads. Sparse is the `sparse0` extension with `mini` (23M, Q8_0 encoder, u8
183
- postings).
184
-
185
- Every number is end-to-end query latency, which for dense includes encoding the query,
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
- Venue was a dedicated GCE `c3-standard-8` (8 vCPU, 4 physical cores) running Debian 12,
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 models](https://huggingface.co/collections/arbazsiddiqui/sqlite-sparse-models-6a929c8e0cb15b0e8ed47d43)
239
- collection, and `sqlite_sparse.register(db, alias)` fetches one into `~/.cache/sqlite-sparse`.
240
- Each conversion is validated against the original SentenceTransformers implementation
241
- (encoder hidden states at cosine 0.9997 or better, term weights within 1.3e-3). Weights
242
- 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.
243
221
 
244
222
  ### Bring your own model
245
223
 
246
- Any inference-free OpenSearch-style sparse encoder on Hugging Face works. Two files are
247
- needed, the encoder as GGUF and a sidecar with the scoring head, query weight table and
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 converter requires the checkpoint to be a BERT-family encoder with a masked-LM head
264
- and a static query weight table. llama.cpp must support the encoder architecture; it does
265
- 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)`.
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
- 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).
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) 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-1.0.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
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,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 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
@@ -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 = "1.0.0"
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"
@@ -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__ = "1.0.0"
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: 1.0.0
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) instead runs an embedding model on every query. A learned
59
- sparse index moves all model work to write time, and the representation is legible, since
60
- you can see which terms matched and with what weight. The cost is quality against good
61
- dense models of the same size and much slower indexing. `mini` (23M) averages 0.497
62
- nDCG@10 on BEIR and mdbr-leaf-ir (23M dense) reports 0.5355 in its symmetric
63
- configuration. The two do very different amounts of work at query time, so this is
64
- context, not a controlled comparison. For read-heavy workloads where indexing is
65
- amortized over many searches, the trade can be favourable.
66
-
67
- This library ships OpenSearch's inference-free sparse models, which until now have lived
68
- inside search clusters (OpenSearch, Elasticsearch, Vespa).
69
- [sqlite-vec](https://github.com/asg017/sqlite-vec) provides a way to use embeddings in
70
- SQLite; this does the same for learned sparse, whose posting lists are plain rows in the
71
- database file. What this project contributes is the embedded
72
- implementation, the file format with a reference implementation to test it against, and
73
- the measurements.
74
-
75
- The searchable index and the query weights live in the SQLite database. The encoder is
76
- needed only when inserting documents. So the `.db` can be copied, shipped inside an app,
77
- opened on any machine with no GPU, and queried with plain SQL from any language that
78
- 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.
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.0.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
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, the python.org installer's `sqlite3` module cannot load extensions.
97
- Use Python from Homebrew or conda, or `pip install sqlean.py` and `import sqlean as
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, so large corpora are best built once on a GPU machine
139
- with the Python package (`sqlite-sparse build`, which writes the same file format) and
140
- the `.db` then shipped to wherever the reads happen.
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
  ![How sqlite-sparse indexes and searches](https://raw.githubusercontent.com/arbazsiddiqui/sqlite-sparse/master/docs/how-it-works.svg)
152
143
 
153
- **INSERT.** The extension tokenizes the text with the registered model's tokenizer and
154
- runs the encoder from the GGUF file through llama.cpp, one vector per token. It then
155
- applies the scoring head from the `.sprs` sidecar, which scores every word in the model's
156
- vocabulary against those vectors; positive scores are kept, compressed with a logarithm,
157
- and become the document's weighted terms. For the aspirin sentence that is 157 terms
158
- (`heart` 0.95, `stroke` 0.92, `risk` 0.78, `reduce` 0.70, `cardiac` 0.42, `prevents` 0.18,
159
- and so on). Each term is appended to that word's posting list, a row in the file listing
160
- the documents it scored and the weight, stored as one byte. The document's token count
161
- and whether it was truncated go into the `docs` table.
162
-
163
- **MATCH.** The extension tokenizes the query the same way and reads one number per token
164
- from the query weight table stored in the file (`what` 2.77, `prevents` 6.72, `cardiac`
165
- 6.53, `arrest` 6.87). For each query word it walks that word's posting list and adds
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 is SQLite's built-in keyword search ranked by BM25. Dense brute-force is
180
- [sqlite-vec](https://github.com/asg017/sqlite-vec) int8, which scans every vector, with
181
- [mdbr-leaf-ir](https://huggingface.co/MongoDB/mdbr-leaf-ir) (23M) encoding queries on torch
182
- CPU with 8 threads. Sparse is the `sparse0` extension with `mini` (23M, Q8_0 encoder, u8
183
- postings).
184
-
185
- Every number is end-to-end query latency, which for dense includes encoding the query,
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
- Venue was a dedicated GCE `c3-standard-8` (8 vCPU, 4 physical cores) running Debian 12,
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 models](https://huggingface.co/collections/arbazsiddiqui/sqlite-sparse-models-6a929c8e0cb15b0e8ed47d43)
239
- collection, and `sqlite_sparse.register(db, alias)` fetches one into `~/.cache/sqlite-sparse`.
240
- Each conversion is validated against the original SentenceTransformers implementation
241
- (encoder hidden states at cosine 0.9997 or better, term weights within 1.3e-3). Weights
242
- 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.
243
221
 
244
222
  ### Bring your own model
245
223
 
246
- Any inference-free OpenSearch-style sparse encoder on Hugging Face works. Two files are
247
- needed, the encoder as GGUF and a sidecar with the scoring head, query weight table and
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 converter requires the checkpoint to be a BERT-family encoder with a masked-LM head
264
- and a static query weight table. llama.cpp must support the encoder architecture; it does
265
- 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)`.
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
- 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).
279
279
 
280
280
  ## License
281
281
 
File without changes
File without changes