sqlite-sparse 0.1.0__tar.gz → 1.0.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 (24) hide show
  1. sqlite_sparse-1.0.0/PKG-INFO +284 -0
  2. sqlite_sparse-1.0.0/README.md +256 -0
  3. {sqlite_sparse-0.1.0 → sqlite_sparse-1.0.0}/pyproject.toml +2 -3
  4. {sqlite_sparse-0.1.0 → sqlite_sparse-1.0.0}/sqlite_sparse/__init__.py +1 -1
  5. sqlite_sparse-1.0.0/sqlite_sparse.egg-info/PKG-INFO +284 -0
  6. sqlite_sparse-1.0.0/sqlite_sparse.egg-info/requires.txt +9 -0
  7. sqlite_sparse-0.1.0/PKG-INFO +0 -46
  8. sqlite_sparse-0.1.0/README.md +0 -15
  9. sqlite_sparse-0.1.0/sqlite_sparse.egg-info/PKG-INFO +0 -46
  10. sqlite_sparse-0.1.0/sqlite_sparse.egg-info/requires.txt +0 -13
  11. {sqlite_sparse-0.1.0 → sqlite_sparse-1.0.0}/LICENSE +0 -0
  12. {sqlite_sparse-0.1.0 → sqlite_sparse-1.0.0}/setup.cfg +0 -0
  13. {sqlite_sparse-0.1.0 → sqlite_sparse-1.0.0}/sqlite_sparse/api.py +0 -0
  14. {sqlite_sparse-0.1.0 → sqlite_sparse-1.0.0}/sqlite_sparse/cli.py +0 -0
  15. {sqlite_sparse-0.1.0 → sqlite_sparse-1.0.0}/sqlite_sparse/convert.py +0 -0
  16. {sqlite_sparse-0.1.0 → sqlite_sparse-1.0.0}/sqlite_sparse/encoder.py +0 -0
  17. {sqlite_sparse-0.1.0 → sqlite_sparse-1.0.0}/sqlite_sparse/loadable.py +0 -0
  18. {sqlite_sparse-0.1.0 → sqlite_sparse-1.0.0}/sqlite_sparse/models.py +0 -0
  19. {sqlite_sparse-0.1.0 → sqlite_sparse-1.0.0}/sqlite_sparse/search.py +0 -0
  20. {sqlite_sparse-0.1.0 → sqlite_sparse-1.0.0}/sqlite_sparse/store.py +0 -0
  21. {sqlite_sparse-0.1.0 → sqlite_sparse-1.0.0}/sqlite_sparse.egg-info/SOURCES.txt +0 -0
  22. {sqlite_sparse-0.1.0 → sqlite_sparse-1.0.0}/sqlite_sparse.egg-info/dependency_links.txt +0 -0
  23. {sqlite_sparse-0.1.0 → sqlite_sparse-1.0.0}/sqlite_sparse.egg-info/entry_points.txt +0 -0
  24. {sqlite_sparse-0.1.0 → sqlite_sparse-1.0.0}/sqlite_sparse.egg-info/top_level.txt +0 -0
@@ -0,0 +1,284 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlite-sparse
3
+ Version: 1.0.0
4
+ Summary: Semantic search in one SQLite file. No model, no server at query time.
5
+ Author-email: Arbaz Siddiqui <arbaz00@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/arbazsiddiqui/sqlite-sparse
8
+ Project-URL: Repository, https://github.com/arbazsiddiqui/sqlite-sparse
9
+ Project-URL: Issues, https://github.com/arbazsiddiqui/sqlite-sparse/issues
10
+ Keywords: sqlite,search,semantic-search,sparse,retrieval,splade,embedded
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Database
16
+ Classifier: Topic :: Text Processing :: Indexing
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: numpy>=1.24
21
+ Provides-Extra: convert
22
+ Requires-Dist: sentence-transformers>=5.0; extra == "convert"
23
+ Requires-Dist: torch; extra == "convert"
24
+ Requires-Dist: transformers>=4.40; extra == "convert"
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # sqlite-sparse
30
+
31
+ Run a sparse retrieval model on SQLite. No model required at query time.
32
+
33
+ *For small, read-heavy systems that need semantic search. Documents are encoded once at
34
+ insert and queries use only tokenization and a static weight table stored in the file,
35
+ scored by an exact inverted-index scatter-add. No vector database, no ANN index, no
36
+ query-time model.*
37
+
38
+ [![CI](https://github.com/arbazsiddiqui/sqlite-sparse/actions/workflows/ci.yml/badge.svg)](https://github.com/arbazsiddiqui/sqlite-sparse/actions/workflows/ci.yml)
39
+ [![PyPI](https://img.shields.io/pypi/v/sqlite-sparse)](https://pypi.org/project/sqlite-sparse/)
40
+ [![License](https://img.shields.io/badge/license-MIT-blue)](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/LICENSE)
41
+
42
+ ```sql
43
+ .load ./sparse0
44
+ CREATE VIRTUAL TABLE notes USING sparse0(model='mini');
45
+ INSERT INTO notes(rowid, text) VALUES (1, 'Aspirin lowers the risk of heart attack and stroke.');
46
+ SELECT rowid, score FROM notes WHERE notes MATCH 'what prevents cardiac arrest' LIMIT 5;
47
+ ```
48
+
49
+ ## Background
50
+
51
+ A learned sparse encoder (SPLADE, "sparse lexical and expansion") turns text into a
52
+ weighted bag of vocabulary terms. For the sentence above that is about 160 terms, among
53
+ them `prevents` and `cardiac`. The output can be stored and searched like a keyword index,
54
+ but the keywords were chosen by a transformer. OpenSearch's inference-free variants run
55
+ the encoder only on documents; each query token gets one learned weight from a lookup
56
+ table, and retrieval is an exact dot product.
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.
79
+
80
+ ## Install
81
+
82
+ ```
83
+ pip install sqlite-sparse
84
+ ```
85
+
86
+ Or take the binary from the [releases page](https://github.com/arbazsiddiqui/sqlite-sparse/releases)
87
+ and use it from any language.
88
+
89
+ ```
90
+ tar xzf sparse0-1.0.0-loadable-linux-x86_64.tar.gz # or -macos-arm64
91
+ sqlite3 notes.db
92
+ sqlite> .load ./sparse0
93
+ ```
94
+
95
+ 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`.
99
+
100
+ ## Quickstart
101
+
102
+ ```python
103
+ import sqlite3, sqlite_sparse
104
+
105
+ db = sqlite3.connect("notes.db")
106
+ sqlite_sparse.load(db) # loads the sparse0 extension
107
+ sqlite_sparse.register(db, "mini") # downloads the model on first use
108
+ 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')")
110
+ db.commit()
111
+ db.execute("SELECT rowid, score FROM notes WHERE notes MATCH ? LIMIT 5",
112
+ ("what prevents cardiac arrest",)).fetchall()
113
+ ```
114
+
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
+ ```python
119
+ db = sqlite3.connect("notes.db")
120
+ sqlite_sparse.load(db)
121
+ db.execute("CREATE VIRTUAL TABLE temp.notes USING sparse0()") # adopts the file
122
+ db.execute("SELECT rowid, score FROM temp.notes WHERE notes MATCH 'heart medication' LIMIT 5")
123
+ ```
124
+
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
+ ```sql
132
+ SELECT n.rowid, n.score, d.title
133
+ FROM notes n JOIN documents d ON d.id = n.rowid
134
+ WHERE n.text MATCH 'heart medication' AND k = 50 AND d.folder = 'work'
135
+ ORDER BY n.score DESC LIMIT 10;
136
+ ```
137
+
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).
148
+
149
+ ## How it works
150
+
151
+ ![How sqlite-sparse indexes and searches](https://raw.githubusercontent.com/arbazsiddiqui/sqlite-sparse/master/docs/how-it-works.svg)
152
+
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.
172
+
173
+ 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
+ version 1, and files written by any 1.x release stay readable by later 1.x releases.
175
+
176
+ ## Benchmarks
177
+
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.
190
+
191
+ | msmarco, 1M documents | FTS5 BM25 | dense brute-force | sqlite-sparse |
192
+ |---|---|---|---|
193
+ | query p50, warm | 582 ms | 735 ms | **3.1 ms** |
194
+ | query p99, warm | 1,305 ms | 739 ms | **7.2 ms** |
195
+ | cold process to first result | 88 ms | 6,989 ms | **62 ms** |
196
+ | peak RAM on the query path | 37 MB | 527 MB | **28 MB** |
197
+ | index size, bytes per document | **540** | 807 | 1,092 |
198
+ | indexing, documents per second (CPU) | **~43,000** | 167 | 20.5 |
199
+ | model at query time | none | 23M transformer | none |
200
+ | retrieval | lexical | semantic | semantic |
201
+
202
+ At 100K documents the p50s are 54 ms, 82 ms and 0.26 ms respectively.
203
+
204
+ ### The extension does not lose the model's quality
205
+
206
+ The reference for quality is the model card. The compiled extension (Q8_0 encoder, u8
207
+ storage, 512-token truncation) reproduces it.
208
+
209
+ | nDCG@10 | OpenSearch doc-v2-mini, 23M (card) | same model through sqlite-sparse | FTS5 BM25, same corpora |
210
+ |---|---|---|---|
211
+ | SciFact | 0.699 | 0.6985 | 0.668 |
212
+ | NFCorpus | 0.336 | 0.3371 | 0.308 |
213
+ | SCIDOCS | 0.164 | 0.1633 | 0.151 |
214
+ | FiQA | 0.338 | 0.3387 | 0.234 |
215
+
216
+ The last column is what the semantic index buys over SQLite's built-in keyword search on
217
+ the same documents and queries. The gain ranges from small (SciFact) to large (FiQA).
218
+
219
+ The same model run in torch at fp32 agrees with the extension on 96 to 98 percent of
220
+ 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.
229
+
230
+ ## Models
231
+
232
+ | alias | model | params | BEIR avg (card) | GGUF + sidecar |
233
+ |---|---|---|---|---|
234
+ | `mini` (default) | [doc-v2-mini](https://huggingface.co/opensearch-project/opensearch-neural-sparse-encoding-doc-v2-mini) | 23M | 0.497 | [arbazsiddiqui/opensearch-neural-sparse-doc-v2-mini-GGUF](https://huggingface.co/arbazsiddiqui/opensearch-neural-sparse-doc-v2-mini-GGUF) |
235
+ | `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
+ | `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
+
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.
243
+
244
+ ### Bring your own model
245
+
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.
249
+
250
+ ```
251
+ git clone --depth 1 https://github.com/ggml-org/llama.cpp
252
+ python llama.cpp/convert_hf_to_gguf.py <hf-model-id> --outfile model_f16.gguf --outtype f16
253
+ llama.cpp/build/bin/llama-quantize model_f16.gguf model_q8.gguf q8_0 # optional
254
+ pip install "sqlite-sparse[convert]" # torch and sentence-transformers, only for this step
255
+ sqlite-sparse convert <hf-model-id> model.sprs # --double-log for v3 models
256
+ ```
257
+
258
+ ```sql
259
+ SELECT sparse_register('mine', 'model_q8.gguf', 'model.sprs');
260
+ CREATE VIRTUAL TABLE notes USING sparse0(model='mine');
261
+ ```
262
+
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`).
266
+
267
+ ## Development
268
+
269
+ ```
270
+ git clone https://github.com/arbazsiddiqui/sqlite-sparse
271
+ make # cmake fetches a pinned llama.cpp and builds build/sparse0.{so,dylib}
272
+ make test # installs the Python binding and runs the suite
273
+ ```
274
+
275
+ `src/` is the extension (`sparse0.c` virtual table, `wordpiece.c` tokenizer on utf8proc, `head.c`
276
+ scoring head on ggml, `scorer.c` scatter-add, `encoder.c` llama.cpp wrapper).
277
+ `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).
279
+
280
+ ## License
281
+
282
+ MIT. The model weights are unmodified Apache-2.0 work by the OpenSearch project and
283
+ llama.cpp is MIT; [NOTICE](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/NOTICE) lists every third-party artifact, its license and
284
+ its provenance.
@@ -0,0 +1,256 @@
1
+ # sqlite-sparse
2
+
3
+ Run a sparse retrieval model on SQLite. No model required at query time.
4
+
5
+ *For small, read-heavy systems that need semantic search. Documents are encoded once at
6
+ insert and queries use only tokenization and a static weight table stored in the file,
7
+ scored by an exact inverted-index scatter-add. No vector database, no ANN index, no
8
+ query-time model.*
9
+
10
+ [![CI](https://github.com/arbazsiddiqui/sqlite-sparse/actions/workflows/ci.yml/badge.svg)](https://github.com/arbazsiddiqui/sqlite-sparse/actions/workflows/ci.yml)
11
+ [![PyPI](https://img.shields.io/pypi/v/sqlite-sparse)](https://pypi.org/project/sqlite-sparse/)
12
+ [![License](https://img.shields.io/badge/license-MIT-blue)](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/LICENSE)
13
+
14
+ ```sql
15
+ .load ./sparse0
16
+ CREATE VIRTUAL TABLE notes USING sparse0(model='mini');
17
+ INSERT INTO notes(rowid, text) VALUES (1, 'Aspirin lowers the risk of heart attack and stroke.');
18
+ SELECT rowid, score FROM notes WHERE notes MATCH 'what prevents cardiac arrest' LIMIT 5;
19
+ ```
20
+
21
+ ## Background
22
+
23
+ A learned sparse encoder (SPLADE, "sparse lexical and expansion") turns text into a
24
+ weighted bag of vocabulary terms. For the sentence above that is about 160 terms, among
25
+ them `prevents` and `cardiac`. The output can be stored and searched like a keyword index,
26
+ but the keywords were chosen by a transformer. OpenSearch's inference-free variants run
27
+ the encoder only on documents; each query token gets one learned weight from a lookup
28
+ table, and retrieval is an exact dot product.
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.
51
+
52
+ ## Install
53
+
54
+ ```
55
+ pip install sqlite-sparse
56
+ ```
57
+
58
+ Or take the binary from the [releases page](https://github.com/arbazsiddiqui/sqlite-sparse/releases)
59
+ and use it from any language.
60
+
61
+ ```
62
+ tar xzf sparse0-1.0.0-loadable-linux-x86_64.tar.gz # or -macos-arm64
63
+ sqlite3 notes.db
64
+ sqlite> .load ./sparse0
65
+ ```
66
+
67
+ 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`.
71
+
72
+ ## Quickstart
73
+
74
+ ```python
75
+ import sqlite3, sqlite_sparse
76
+
77
+ db = sqlite3.connect("notes.db")
78
+ sqlite_sparse.load(db) # loads the sparse0 extension
79
+ sqlite_sparse.register(db, "mini") # downloads the model on first use
80
+ 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')")
82
+ db.commit()
83
+ db.execute("SELECT rowid, score FROM notes WHERE notes MATCH ? LIMIT 5",
84
+ ("what prevents cardiac arrest",)).fetchall()
85
+ ```
86
+
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
+ ```python
91
+ db = sqlite3.connect("notes.db")
92
+ sqlite_sparse.load(db)
93
+ db.execute("CREATE VIRTUAL TABLE temp.notes USING sparse0()") # adopts the file
94
+ db.execute("SELECT rowid, score FROM temp.notes WHERE notes MATCH 'heart medication' LIMIT 5")
95
+ ```
96
+
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
+ ```sql
104
+ SELECT n.rowid, n.score, d.title
105
+ FROM notes n JOIN documents d ON d.id = n.rowid
106
+ WHERE n.text MATCH 'heart medication' AND k = 50 AND d.folder = 'work'
107
+ ORDER BY n.score DESC LIMIT 10;
108
+ ```
109
+
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).
120
+
121
+ ## How it works
122
+
123
+ ![How sqlite-sparse indexes and searches](https://raw.githubusercontent.com/arbazsiddiqui/sqlite-sparse/master/docs/how-it-works.svg)
124
+
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.
144
+
145
+ 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, and files written by any 1.x release stay readable by later 1.x releases.
147
+
148
+ ## Benchmarks
149
+
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.
162
+
163
+ | msmarco, 1M documents | FTS5 BM25 | dense brute-force | sqlite-sparse |
164
+ |---|---|---|---|
165
+ | query p50, warm | 582 ms | 735 ms | **3.1 ms** |
166
+ | query p99, warm | 1,305 ms | 739 ms | **7.2 ms** |
167
+ | cold process to first result | 88 ms | 6,989 ms | **62 ms** |
168
+ | peak RAM on the query path | 37 MB | 527 MB | **28 MB** |
169
+ | index size, bytes per document | **540** | 807 | 1,092 |
170
+ | indexing, documents per second (CPU) | **~43,000** | 167 | 20.5 |
171
+ | model at query time | none | 23M transformer | none |
172
+ | retrieval | lexical | semantic | semantic |
173
+
174
+ At 100K documents the p50s are 54 ms, 82 ms and 0.26 ms respectively.
175
+
176
+ ### The extension does not lose the model's quality
177
+
178
+ The reference for quality is the model card. The compiled extension (Q8_0 encoder, u8
179
+ storage, 512-token truncation) reproduces it.
180
+
181
+ | nDCG@10 | OpenSearch doc-v2-mini, 23M (card) | same model through sqlite-sparse | FTS5 BM25, same corpora |
182
+ |---|---|---|---|
183
+ | SciFact | 0.699 | 0.6985 | 0.668 |
184
+ | NFCorpus | 0.336 | 0.3371 | 0.308 |
185
+ | SCIDOCS | 0.164 | 0.1633 | 0.151 |
186
+ | FiQA | 0.338 | 0.3387 | 0.234 |
187
+
188
+ The last column is what the semantic index buys over SQLite's built-in keyword search on
189
+ the same documents and queries. The gain ranges from small (SciFact) to large (FiQA).
190
+
191
+ The same model run in torch at fp32 agrees with the extension on 96 to 98 percent of
192
+ 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.
201
+
202
+ ## Models
203
+
204
+ | alias | model | params | BEIR avg (card) | GGUF + sidecar |
205
+ |---|---|---|---|---|
206
+ | `mini` (default) | [doc-v2-mini](https://huggingface.co/opensearch-project/opensearch-neural-sparse-encoding-doc-v2-mini) | 23M | 0.497 | [arbazsiddiqui/opensearch-neural-sparse-doc-v2-mini-GGUF](https://huggingface.co/arbazsiddiqui/opensearch-neural-sparse-doc-v2-mini-GGUF) |
207
+ | `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
+ | `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
+
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.
215
+
216
+ ### Bring your own model
217
+
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.
221
+
222
+ ```
223
+ git clone --depth 1 https://github.com/ggml-org/llama.cpp
224
+ python llama.cpp/convert_hf_to_gguf.py <hf-model-id> --outfile model_f16.gguf --outtype f16
225
+ llama.cpp/build/bin/llama-quantize model_f16.gguf model_q8.gguf q8_0 # optional
226
+ pip install "sqlite-sparse[convert]" # torch and sentence-transformers, only for this step
227
+ sqlite-sparse convert <hf-model-id> model.sprs # --double-log for v3 models
228
+ ```
229
+
230
+ ```sql
231
+ SELECT sparse_register('mine', 'model_q8.gguf', 'model.sprs');
232
+ CREATE VIRTUAL TABLE notes USING sparse0(model='mine');
233
+ ```
234
+
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`).
238
+
239
+ ## Development
240
+
241
+ ```
242
+ git clone https://github.com/arbazsiddiqui/sqlite-sparse
243
+ make # cmake fetches a pinned llama.cpp and builds build/sparse0.{so,dylib}
244
+ make test # installs the Python binding and runs the suite
245
+ ```
246
+
247
+ `src/` is the extension (`sparse0.c` virtual table, `wordpiece.c` tokenizer on utf8proc, `head.c`
248
+ scoring head on ggml, `scorer.c` scatter-add, `encoder.c` llama.cpp wrapper).
249
+ `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).
251
+
252
+ ## License
253
+
254
+ MIT. The model weights are unmodified Apache-2.0 work by the OpenSearch project and
255
+ llama.cpp is MIT; [NOTICE](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/NOTICE) lists every third-party artifact, its license and
256
+ its provenance.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlite-sparse"
3
- version = "0.1.0"
3
+ version = "1.0.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"
6
+ __version__ = "1.0.0"
@@ -0,0 +1,284 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlite-sparse
3
+ Version: 1.0.0
4
+ Summary: Semantic search in one SQLite file. No model, no server at query time.
5
+ Author-email: Arbaz Siddiqui <arbaz00@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/arbazsiddiqui/sqlite-sparse
8
+ Project-URL: Repository, https://github.com/arbazsiddiqui/sqlite-sparse
9
+ Project-URL: Issues, https://github.com/arbazsiddiqui/sqlite-sparse/issues
10
+ Keywords: sqlite,search,semantic-search,sparse,retrieval,splade,embedded
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Database
16
+ Classifier: Topic :: Text Processing :: Indexing
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: numpy>=1.24
21
+ Provides-Extra: convert
22
+ Requires-Dist: sentence-transformers>=5.0; extra == "convert"
23
+ Requires-Dist: torch; extra == "convert"
24
+ Requires-Dist: transformers>=4.40; extra == "convert"
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # sqlite-sparse
30
+
31
+ Run a sparse retrieval model on SQLite. No model required at query time.
32
+
33
+ *For small, read-heavy systems that need semantic search. Documents are encoded once at
34
+ insert and queries use only tokenization and a static weight table stored in the file,
35
+ scored by an exact inverted-index scatter-add. No vector database, no ANN index, no
36
+ query-time model.*
37
+
38
+ [![CI](https://github.com/arbazsiddiqui/sqlite-sparse/actions/workflows/ci.yml/badge.svg)](https://github.com/arbazsiddiqui/sqlite-sparse/actions/workflows/ci.yml)
39
+ [![PyPI](https://img.shields.io/pypi/v/sqlite-sparse)](https://pypi.org/project/sqlite-sparse/)
40
+ [![License](https://img.shields.io/badge/license-MIT-blue)](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/LICENSE)
41
+
42
+ ```sql
43
+ .load ./sparse0
44
+ CREATE VIRTUAL TABLE notes USING sparse0(model='mini');
45
+ INSERT INTO notes(rowid, text) VALUES (1, 'Aspirin lowers the risk of heart attack and stroke.');
46
+ SELECT rowid, score FROM notes WHERE notes MATCH 'what prevents cardiac arrest' LIMIT 5;
47
+ ```
48
+
49
+ ## Background
50
+
51
+ A learned sparse encoder (SPLADE, "sparse lexical and expansion") turns text into a
52
+ weighted bag of vocabulary terms. For the sentence above that is about 160 terms, among
53
+ them `prevents` and `cardiac`. The output can be stored and searched like a keyword index,
54
+ but the keywords were chosen by a transformer. OpenSearch's inference-free variants run
55
+ the encoder only on documents; each query token gets one learned weight from a lookup
56
+ table, and retrieval is an exact dot product.
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.
79
+
80
+ ## Install
81
+
82
+ ```
83
+ pip install sqlite-sparse
84
+ ```
85
+
86
+ Or take the binary from the [releases page](https://github.com/arbazsiddiqui/sqlite-sparse/releases)
87
+ and use it from any language.
88
+
89
+ ```
90
+ tar xzf sparse0-1.0.0-loadable-linux-x86_64.tar.gz # or -macos-arm64
91
+ sqlite3 notes.db
92
+ sqlite> .load ./sparse0
93
+ ```
94
+
95
+ 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`.
99
+
100
+ ## Quickstart
101
+
102
+ ```python
103
+ import sqlite3, sqlite_sparse
104
+
105
+ db = sqlite3.connect("notes.db")
106
+ sqlite_sparse.load(db) # loads the sparse0 extension
107
+ sqlite_sparse.register(db, "mini") # downloads the model on first use
108
+ 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')")
110
+ db.commit()
111
+ db.execute("SELECT rowid, score FROM notes WHERE notes MATCH ? LIMIT 5",
112
+ ("what prevents cardiac arrest",)).fetchall()
113
+ ```
114
+
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
+ ```python
119
+ db = sqlite3.connect("notes.db")
120
+ sqlite_sparse.load(db)
121
+ db.execute("CREATE VIRTUAL TABLE temp.notes USING sparse0()") # adopts the file
122
+ db.execute("SELECT rowid, score FROM temp.notes WHERE notes MATCH 'heart medication' LIMIT 5")
123
+ ```
124
+
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
+ ```sql
132
+ SELECT n.rowid, n.score, d.title
133
+ FROM notes n JOIN documents d ON d.id = n.rowid
134
+ WHERE n.text MATCH 'heart medication' AND k = 50 AND d.folder = 'work'
135
+ ORDER BY n.score DESC LIMIT 10;
136
+ ```
137
+
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).
148
+
149
+ ## How it works
150
+
151
+ ![How sqlite-sparse indexes and searches](https://raw.githubusercontent.com/arbazsiddiqui/sqlite-sparse/master/docs/how-it-works.svg)
152
+
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.
172
+
173
+ 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
+ version 1, and files written by any 1.x release stay readable by later 1.x releases.
175
+
176
+ ## Benchmarks
177
+
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.
190
+
191
+ | msmarco, 1M documents | FTS5 BM25 | dense brute-force | sqlite-sparse |
192
+ |---|---|---|---|
193
+ | query p50, warm | 582 ms | 735 ms | **3.1 ms** |
194
+ | query p99, warm | 1,305 ms | 739 ms | **7.2 ms** |
195
+ | cold process to first result | 88 ms | 6,989 ms | **62 ms** |
196
+ | peak RAM on the query path | 37 MB | 527 MB | **28 MB** |
197
+ | index size, bytes per document | **540** | 807 | 1,092 |
198
+ | indexing, documents per second (CPU) | **~43,000** | 167 | 20.5 |
199
+ | model at query time | none | 23M transformer | none |
200
+ | retrieval | lexical | semantic | semantic |
201
+
202
+ At 100K documents the p50s are 54 ms, 82 ms and 0.26 ms respectively.
203
+
204
+ ### The extension does not lose the model's quality
205
+
206
+ The reference for quality is the model card. The compiled extension (Q8_0 encoder, u8
207
+ storage, 512-token truncation) reproduces it.
208
+
209
+ | nDCG@10 | OpenSearch doc-v2-mini, 23M (card) | same model through sqlite-sparse | FTS5 BM25, same corpora |
210
+ |---|---|---|---|
211
+ | SciFact | 0.699 | 0.6985 | 0.668 |
212
+ | NFCorpus | 0.336 | 0.3371 | 0.308 |
213
+ | SCIDOCS | 0.164 | 0.1633 | 0.151 |
214
+ | FiQA | 0.338 | 0.3387 | 0.234 |
215
+
216
+ The last column is what the semantic index buys over SQLite's built-in keyword search on
217
+ the same documents and queries. The gain ranges from small (SciFact) to large (FiQA).
218
+
219
+ The same model run in torch at fp32 agrees with the extension on 96 to 98 percent of
220
+ 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.
229
+
230
+ ## Models
231
+
232
+ | alias | model | params | BEIR avg (card) | GGUF + sidecar |
233
+ |---|---|---|---|---|
234
+ | `mini` (default) | [doc-v2-mini](https://huggingface.co/opensearch-project/opensearch-neural-sparse-encoding-doc-v2-mini) | 23M | 0.497 | [arbazsiddiqui/opensearch-neural-sparse-doc-v2-mini-GGUF](https://huggingface.co/arbazsiddiqui/opensearch-neural-sparse-doc-v2-mini-GGUF) |
235
+ | `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
+ | `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
+
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.
243
+
244
+ ### Bring your own model
245
+
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.
249
+
250
+ ```
251
+ git clone --depth 1 https://github.com/ggml-org/llama.cpp
252
+ python llama.cpp/convert_hf_to_gguf.py <hf-model-id> --outfile model_f16.gguf --outtype f16
253
+ llama.cpp/build/bin/llama-quantize model_f16.gguf model_q8.gguf q8_0 # optional
254
+ pip install "sqlite-sparse[convert]" # torch and sentence-transformers, only for this step
255
+ sqlite-sparse convert <hf-model-id> model.sprs # --double-log for v3 models
256
+ ```
257
+
258
+ ```sql
259
+ SELECT sparse_register('mine', 'model_q8.gguf', 'model.sprs');
260
+ CREATE VIRTUAL TABLE notes USING sparse0(model='mine');
261
+ ```
262
+
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`).
266
+
267
+ ## Development
268
+
269
+ ```
270
+ git clone https://github.com/arbazsiddiqui/sqlite-sparse
271
+ make # cmake fetches a pinned llama.cpp and builds build/sparse0.{so,dylib}
272
+ make test # installs the Python binding and runs the suite
273
+ ```
274
+
275
+ `src/` is the extension (`sparse0.c` virtual table, `wordpiece.c` tokenizer on utf8proc, `head.c`
276
+ scoring head on ggml, `scorer.c` scatter-add, `encoder.c` llama.cpp wrapper).
277
+ `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).
279
+
280
+ ## License
281
+
282
+ MIT. The model weights are unmodified Apache-2.0 work by the OpenSearch project and
283
+ llama.cpp is MIT; [NOTICE](https://github.com/arbazsiddiqui/sqlite-sparse/blob/master/NOTICE) lists every third-party artifact, its license and
284
+ its provenance.
@@ -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,46 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: sqlite-sparse
3
- Version: 0.1.0
4
- Summary: Semantic search in one SQLite file. No model, no server at query time.
5
- Author-email: Arbaz Siddiqui <arbaz00@gmail.com>
6
- License: MIT
7
- Project-URL: Homepage, https://github.com/arbazsiddiqui/sqlite-sparse
8
- Project-URL: Repository, https://github.com/arbazsiddiqui/sqlite-sparse
9
- Project-URL: Issues, https://github.com/arbazsiddiqui/sqlite-sparse/issues
10
- Keywords: sqlite,search,semantic-search,sparse,retrieval,splade,embedded
11
- Classifier: Development Status :: 4 - Beta
12
- Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: MIT License
14
- Classifier: Programming Language :: Python :: 3
15
- Classifier: Topic :: Database
16
- Classifier: Topic :: Text Processing :: Indexing
17
- Requires-Python: >=3.9
18
- Description-Content-Type: text/markdown
19
- License-File: LICENSE
20
- Requires-Dist: numpy>=1.24
21
- Provides-Extra: build
22
- Requires-Dist: onnxruntime>=1.17; extra == "build"
23
- Requires-Dist: tokenizers>=0.15; extra == "build"
24
- Requires-Dist: huggingface_hub>=0.20; extra == "build"
25
- Provides-Extra: build-torch
26
- Requires-Dist: sentence-transformers>=5.0; extra == "build-torch"
27
- Requires-Dist: torch; extra == "build-torch"
28
- Provides-Extra: dev
29
- Requires-Dist: pytest>=7; extra == "dev"
30
- Dynamic: license-file
31
-
32
- # sqlite-sparse (Python)
33
-
34
- Python binding for [sqlite-sparse](https://github.com/arbazsiddiqui/sqlite-sparse),
35
- semantic search in one SQLite file with no model at query time.
36
-
37
- ```python
38
- import sqlite3, sqlite_sparse
39
- db = sqlite3.connect("notes.db")
40
- sqlite_sparse.load(db) # loads the sparse0 extension
41
- sqlite_sparse.register(db, "mini") # downloads the model on first use
42
- db.execute("CREATE VIRTUAL TABLE notes USING sparse0(model='mini')")
43
- ```
44
-
45
- Also contains the pure-Python reference implementation of the file format
46
- (`SparseIndex`) and the sidecar converter. Full documentation in the repository README.
@@ -1,15 +0,0 @@
1
- # sqlite-sparse (Python)
2
-
3
- Python binding for [sqlite-sparse](https://github.com/arbazsiddiqui/sqlite-sparse),
4
- semantic search in one SQLite file with no model at query time.
5
-
6
- ```python
7
- import sqlite3, sqlite_sparse
8
- db = sqlite3.connect("notes.db")
9
- sqlite_sparse.load(db) # loads the sparse0 extension
10
- sqlite_sparse.register(db, "mini") # downloads the model on first use
11
- db.execute("CREATE VIRTUAL TABLE notes USING sparse0(model='mini')")
12
- ```
13
-
14
- Also contains the pure-Python reference implementation of the file format
15
- (`SparseIndex`) and the sidecar converter. Full documentation in the repository README.
@@ -1,46 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: sqlite-sparse
3
- Version: 0.1.0
4
- Summary: Semantic search in one SQLite file. No model, no server at query time.
5
- Author-email: Arbaz Siddiqui <arbaz00@gmail.com>
6
- License: MIT
7
- Project-URL: Homepage, https://github.com/arbazsiddiqui/sqlite-sparse
8
- Project-URL: Repository, https://github.com/arbazsiddiqui/sqlite-sparse
9
- Project-URL: Issues, https://github.com/arbazsiddiqui/sqlite-sparse/issues
10
- Keywords: sqlite,search,semantic-search,sparse,retrieval,splade,embedded
11
- Classifier: Development Status :: 4 - Beta
12
- Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: MIT License
14
- Classifier: Programming Language :: Python :: 3
15
- Classifier: Topic :: Database
16
- Classifier: Topic :: Text Processing :: Indexing
17
- Requires-Python: >=3.9
18
- Description-Content-Type: text/markdown
19
- License-File: LICENSE
20
- Requires-Dist: numpy>=1.24
21
- Provides-Extra: build
22
- Requires-Dist: onnxruntime>=1.17; extra == "build"
23
- Requires-Dist: tokenizers>=0.15; extra == "build"
24
- Requires-Dist: huggingface_hub>=0.20; extra == "build"
25
- Provides-Extra: build-torch
26
- Requires-Dist: sentence-transformers>=5.0; extra == "build-torch"
27
- Requires-Dist: torch; extra == "build-torch"
28
- Provides-Extra: dev
29
- Requires-Dist: pytest>=7; extra == "dev"
30
- Dynamic: license-file
31
-
32
- # sqlite-sparse (Python)
33
-
34
- Python binding for [sqlite-sparse](https://github.com/arbazsiddiqui/sqlite-sparse),
35
- semantic search in one SQLite file with no model at query time.
36
-
37
- ```python
38
- import sqlite3, sqlite_sparse
39
- db = sqlite3.connect("notes.db")
40
- sqlite_sparse.load(db) # loads the sparse0 extension
41
- sqlite_sparse.register(db, "mini") # downloads the model on first use
42
- db.execute("CREATE VIRTUAL TABLE notes USING sparse0(model='mini')")
43
- ```
44
-
45
- Also contains the pure-Python reference implementation of the file format
46
- (`SparseIndex`) and the sidecar converter. Full documentation in the repository README.
@@ -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
File without changes
File without changes