We often have to go through a whole bunch of hoops to get documents processed and ready for searching through them. litesearch plans to make this as easy as possible by providing simple building blocks to set up a database with FTS5 and vector search capabilities.
porter unicode61, the FTS5 default, splits on _ and stems the pieces, so fts_search indexes as ft+search. apsw’s UAX#29 tokenizer joins on _, so identifiers survive and porter still stems prose. sanskrit only adds colocated tokens, so the chain is additive and is the default: a store’s tokenizer is fixed at table creation, and the first document should not decide it.
sanskrit sits inside porter. Outside it, the fold is computed on porter’s output: Devanagari passes porter untouched, so धर्मक्षेत्रे indexes as dharmaksetre while the ASCII query stems to dharmaksetr and misses. Measured: 3 of 14 ordinary Devanagari words unreachable by their own ASCII spelling.
_tokenizers_ok probes the whole chain, not its names one at a time. simplify wraps another tokenizer and raises when asked for alone, so name-by-name probing fails on a working chain.
busy_window widens apsw’s busy timeout for a block and restores it. Refcounted, so overlapping windows cannot strand the widened value.
def process_content( store, # target Table (hash-id store) content, # iterable of chunk dicts embed:bool=True, # embed content before upsert emb_fn:NoneType=None, # embedder, required when embed=True hash_id_columns:tuple=('content',), # columns the row id is hashed over; add 'node_id' to keep identical text in different sections distinct parallel:bool=False, # widen the busy timeout so concurrent writers wait rather than fail chunk:int=5000, # rows per transaction**kw):
Embed and upsert chunks in chunk-sized transactions.parallel=True gives concurrent writers a 30-second busy timeout.
def sql_in( col, vals, # SQL IN clause; callers guard against empty vals):
Call self as a function.
get_store and get_tree run idempotent DDL. Read paths call them per query. ensured caches their table handles per connection, so later reads run no DDL.
def get_store( name:str='store', # table namehash:bool=False, # whether to create hash index on content ann:bool=False, # also register a usearch HNSW index (sidecar) for this store ndim:int=None, # embedding dims for the ANN index (inferred on first sync if None) metric:str='cosine', # ANN distance metric (cosine,inner,sqeuclidean,divergence) dtype:type=float16, # ANN vector dtype connectivity:int=None, # HNSW connectivity (usearch default if None) expansion_add:int=None, # HNSW add expansion expansion_search:int=None, # HNSW search expansion index_path:str=None, # sidecar path (defaults to <db>.<name>.usearch; None db -> in-memory) tokenize:str=None, # FTS5 tokenizer (default: apsw unicodewords chain, else 'porter')**kw):
Create an FTS5 store with optional content hashes and a usearch HNSW index.
def upsert_all( tbl, # target Table, with `pk` declared as its primary key rows, # dicts of scalars, upserted on `pk` pk, # primary-key column name or tuple of them chunk:int=2000, # rows per `executemany` batch):
Upsert rows with one prepared statement instead of two per row.
Per-connection cache of loaded usearch Index objects, keyed by store name.
_dtype_check warns when dtype cannot be what these vectors were stored as.
The failure is silent and total. model2vec returns float32 and FastEncode float16. Store one and search as the other, and distance_cosine_f16 reads f32 bytes as twice as many f16 values, whose inf/nan patterns return SQL NULL for every row. The vector leg comes back in rowid order and a hybrid search degrades to FTS.
def fts_search( q:str, # query string columns:list|None=None, # columns to return order_by:str|None=None, # order by clause limit:int|None=None, # limit on number of results offset:int|None=None, # offset for results where:str|None=None, # additional where clause where_args:dict|None=None, # args for where clause quote:bool=False, # quote FTS query to disable special chars include_rank:bool=True, # include rank column)->list:
def sync( content, # iterable of chunk dicts with 'content' key_col:str=None, # scope existing rows by this column's values (e.g. 'path','package'); None = whole table emb_fn:NoneType=None, # embedder for new/changed chunks embed:bool=True, # embed before upsert force:bool=False, # treat all content as new (skip diff) parallel:bool=False, # chunked BEGIN IMMEDIATE writes; needs database(busy_timeout=...) chunk:int=5000, # rows per transaction when parallel=True):
Smart hash-diff update: delete stale rows, embed+upsert changed, mirror into the ANN index. Returns {changed,same,removed}.
FTS5 triggers index each row as it is written. bulk_load removes them and rebuilds FTS once after the load. Use it only for offline loads: FTS stays stale until the rebuild, and rebuilding a small update costs more than trigger maintenance.
bulk_load
def bulk_load():
Suspend this table’s FTS5 triggers for a bulk insert, then rebuild the index once.
Neighbours of an indexed row
ann_neighbors answers “what is near this row” using the vector usearch already holds, rather than re-embedding its text. Cheap enough to fire from a cursor move.
ann_neighbors
Rows nearest an already-indexed row, “what else looks like this”.
Reuses the stored vector rather than re-embedding the row, so no model is loaded and no text is tokenised: the whole call is one HNSW probe plus one rowid IN (...) fetch.
def ann_neighbors( key:int, # usearch key (rowid) to search from limit:int=15, # neighbours to return columns:list=None, # columns to return (rowid always included) where:str=None, # additional where clause where_args:dict=None, # args for where clause include_self:bool=False, # keep the anchor row in the results dtype:type=float16, # embedding dtype):
Rows nearest an already-indexed row — “what else looks like this”.
def rrf_all( lists, # ranked result lists to fuse k:int=60, # rank at which a hit is worth half the top spot limit:int=50, # results returned id_key:str='rowid', # the key rows are joined on weights:NoneType=None, # one weight per list; None means all equal)->list:
Reciprocal Rank Fusion over any number of ranked lists. A row in two lists outranks a row in one.
from litesearch.core import rrf_all, rrf_mergefts = [dict(rowid=1, t='a'), dict(rowid=2, t='b'), dict(rowid=3, t='c')]vec = [dict(rowid=3, t='c'), dict(rowid=4, t='d')]# rowid 3 is 3rd on one list and 1st on the other, which beats anything ranked oncefused = rrf_all([fts, vec])assert [r['rowid'] for r in fused][:2] == [3, 1]assert {r['rowid'] for r in fused} == {1, 2, 3, 4}assert rrf_merge(fts, vec) == fused# a weight is a thumb on the scale: doubling the vector leg lifts its top hit past the FTS top hitassert [r['rowid'] for r in rrf_all([fts, vec], weights=[1.0, 2.0])][:2] == [3, 4]
register_tokenizers returns None on success, so _fts_tokenizers = register(...) was always falsy and get_store fell back to bare porter. The UAX#29 chain had never reached a store.
Registered on every connection, because FTS5 resolves a tokenizer by name at query time.
def database( pth_or_uri:str=':memory:', # the database name or URL wal:bool=True, # use WAL mode sem_search:bool=True, # enable usearch extensions busy_timeout:int=None, # ms to wait on a locked db; None = apsw default. Set for parallel ingestion**kw)->Database: # additional args to pass to apswutils database
Set up a database connection and load usearch extensions.
Parallel ingestion. apswutils runs PRAGMA optimize inside apsw’s open hook with a 100 ms busy timeout, so concurrent writers raised BusyError and dropped whole batches silently.
import threading, tempfile, os, apsw, apsw.bestpractice_orig = apsw.bestpractice.connection_busy_timeout.__defaults__# write_txn commits, rolls back on error, and nests without opening a second transaction_wdb = database(sem_search=False); _wt = _wdb.get_store(name='w', hash=True)with write_txn(_wdb): _wt.insert({'content':'a'})try:with write_txn(_wdb): _wt.insert({'content':'b'});raiseValueError('boom')exceptValueError: passassertlen(_wt()) ==1, 'a failed write_txn must roll back'with write_txn(_wdb):with write_txn(_wdb): _wt.insert({'content':'c'}) # nested is a no-opassertlen(_wt()) ==2andnot _wdb.conn.in_transaction
busy_window widens inside, and only the outermost exit restores
with busy_window(30_000):assert apsw.bestpractice.connection_busy_timeout.__defaults__ == (30_000,)with busy_window(30_000): passassert apsw.bestpractice.connection_busy_timeout.__defaults__ == (30_000,), 'inner exit must not restore'assert apsw.bestpractice.connection_busy_timeout.__defaults__ == _orig
8 threads into one database needs both halves: busy_timeout= on database(), which opens inside a busy_window, and parallel=True on process_content, which chunks writes into BEGIN IMMEDIATE transactions. The open dominates: with the stock 100 ms timeout this load passed 9 of 15 runs.
_cp = os.path.join(tempfile.mkdtemp(), 'parallel.db')_cmain = database(_cp, sem_search=False, busy_timeout=30_000); _cmain.get_store(name='store', hash=True)_cerrs, _cconns, _cesc = [], [], []_prev_hook = threading.excepthookthreading.excepthook =lambda a: _cesc.append(type(a.exc_value).__name__) # thread errors never reach us otherwisedef _cingest(tid):try: _d = database(_cp, sem_search=False, busy_timeout=30_000); _cconns.append(_d) process_content(_d.t['store'], [{'content':f't{tid}-r{i}', 'metadata':'{}'} for i inrange(200)], embed=False, parallel=True, chunk=50)exceptExceptionas e: _cerrs.append((tid, type(e).__name__))_cts = [threading.Thread(target=_cingest, args=(i,)) for i inrange(8)]for _t in _cts: _t.start()for _t in _cts: _t.join()threading.excepthook = _prev_hookassertnot _cerrs andnot _cesc, (_cerrs, _cesc)assertlist(_cmain.conn.execute('SELECT count(*) FROM store'))[0][0] ==8*200, 'rows lost under contention'assert apsw.bestpractice.connection_busy_timeout.__defaults__ == _orig, 'busy_window leaked'
# `db_lock` is what makes the read-modify-write paths safe when threads share *one* connection.# The busy_window test above does not reach it: there every thread opens its own connection, so# they contend in SQLite rather than in python. Here the hash-id upserts interleave on the same# handle, which is the case the lock exists for._sp = os.path.join(tempfile.mkdtemp(), 'shared.db')_smain = database(_sp, sem_search=False); _smain.get_store(name='store', hash=True)_sst = _smain.t['store']assert db_lock(_smain) is db_lock(_smain), 'one lock per connection, not one per call'_serrs, _sesc = [], []_prev_hook = threading.excepthookthreading.excepthook =lambda a: _sesc.append(type(a.exc_value).__name__)def _singest(tid):try: process_content(_sst, [{'content':f's{tid}-r{i}', 'metadata':'{}'} for i inrange(100)], embed=False, chunk=25)exceptExceptionas e: _serrs.append((tid, type(e).__name__))_sts = [threading.Thread(target=_singest, args=(i,)) for i inrange(8)]for _t in _sts: _t.start()for _t in _sts: _t.join()threading.excepthook = _prev_hookassertnot _serrs andnot _sesc, (_serrs, _sesc)assertlist(_smain.conn.execute('SELECT count(*) FROM store'))[0][0] ==8*100, 'rows lost on a shared connection'assert apsw.bestpractice.connection_busy_timeout.__defaults__ == _orig, 'busy_window leaked'
# Table.sync forwards parallel/chunk down to process_content, so hash-diff syncs (what kosha's# package ingestion uses) get the same chunked BEGIN IMMEDIATE writes as a direct process_content._sdb = database(sem_search=False, busy_timeout=30_000); _sst = _sdb.get_store(name='sy', hash=True)_srows = [{'content': f'row {i}', 'metadata': '{}'} for i inrange(120)]assert _sst.sync(_srows, embed=False, parallel=True, chunk=25) == {'changed':120,'same':0,'removed':0}assertlen(_sst()) ==120assert _sst.sync(_srows, embed=False, parallel=True, chunk=25) == {'changed':0,'same':120,'removed':0} # idempotentassert _sst.sync(_srows[:60], embed=False, parallel=True, chunk=25) == {'changed':0,'same':60,'removed':60}assertlen(_sst()) ==60
def search( q:str, # query string emb:bytes, # embedding vector columns:list=None, # columns to return where:str=None, # additional where clause where_args:dict=None, # args for where clause limit:int|None=50, # limit on number of results offset:int|None=None, # offset for results (ignored when rrf=True) table_name:str='store', # table name emb_col:str='embedding', # embedding column name emb_metric:str='cosine', # embedding distance metric (cosine,sqeuclidean,inner,divergence) rrf:bool=True, # rerank results with reciprocal rank fusion rrf_k:int=60, # RRF k parameter dtype:type=float16, # embedding dtype id_key:str='rowid', # key to join RRF results on quote:bool=True, # quote FTS query to disable special chars (ignored when fts_pre=True) fts_pre:bool=True, # send the FTS leg through `pre()`: keywords, wildcards, OR ann:bool=False, # use the HNSW ANN index for the vector leg falls back to the exact vec scan when `where` is set parallel:bool=False, # not used. kept for backward compatability. to be removed in later releases reranking:bool=False, # rerank the merged (rrf) hits with a flashrank cross-encoder rerank_model:str=None, # flashrank model name (None -> fast default)):
Search the litesearch store with fts and vector search combined.
Two legs, one connection, two threads is what apsw refuses, and it bought nothing anyway: the work is in consuming the vector leg, not in issuing the two queries, so threading them only added pool overhead. Concurrent now means a connection each, and the default is sequential.
def _v(i, n=8): return (np.full(n, i, dtype=np.float16)/16).tobytes()_db = database(str(Path(mkdtemp())/'p.db'))_st = _db.get_store()_st.insert_all([dict(content=f'rank fusion over leg {i}', embedding=_v(i)) for i inrange(200)])_seq = _db.search('rank fusion', _v(3), ['content'], limit=5)_par = _db.search('rank fusion', _v(3), ['content'], limit=5, parallel=True)test_eq([h['content'] for h in _seq], [h['content'] for h in _par]) # same answer either wayassert _seq, _seq# an in-memory database has no file to reopen, so the concurrent path steps back to sequential_mem = database(':memory:'); _mem.get_store().insert_all([dict(content='rank fusion', embedding=_v(3))])assert _mem.search('rank fusion', _v(3), ['content'], limit=5, parallel=True)
/var/folders/kg/9vdw4mdd1fs58svgh4k1qhr09x7dqh/T/ipykernel_91989/3569258371.py:28: UserWarning: 'store': every distance came back null or 0, which usually means dtype='f16' does not match how the embeddings were stored (e.g. float32 vectors from model2vec searched as float16). The ranking is meaningless.
warnings.warn(f'{tbl.name!r}: every distance came back null or 0, which usually means dtype={want!r} '
The FTS leg goes through pre() by default: stopwords out, wildcard per term, terms OR-ed. Quoting every token makes FTS an implicit AND, so a query with one absent term matched nothing.
_d = database(); _s = _d.get_store('store')_s.insert_all([dict(content="The Pathfinder's transition activities shall help all types of researchers", embedding=np.zeros(4, dtype=np.float16).tobytes()),dict(content='Value added tax shall be chargeable on the supply of goods', embedding=np.ones(4, dtype=np.float16).tobytes())])_qv = np.zeros(4, dtype=np.float16).tobytes()_q ='Pathfinder researchers assistance'# 'assistance' is absent from both rowsassert _d.search(_q, _qv, columns=['content'], rrf=False, fts_pre=False)['fts'] == []_got = _d.search(_q, _qv, columns=['content'], rrf=False, fts_pre=True)['fts']assertlen(_got) ==1and'Pathfinder'in _got[0]['content']# a query of pure stopwords cannot be pre()-processed into anything; fall back rather than crashassert _d.search('of the', _qv, columns=['content'], limit=2) isnotNone
reranking reorders by content relevance (downloads a flashrank model; run manually, not CI)
import numpy as np, json_rdb = database(); _rst = _rdb.get_store('rr', ann=True, ndim=4)_rst.insert_all([{'content':'The Eiffel Tower is in Paris, France.','embedding':np.array([1,0,0,0],dtype=np.float16).tobytes(),'metadata':json.dumps({'doc_id':'a'})}, {'content':'Cats are small mammals kept as pets.','embedding':np.array([0,1,0,0],dtype=np.float16).tobytes(),'metadata':json.dumps({'doc_id':'b'})}])_rst.rebuild_index()_q ='where is the eiffel tower'# query vector deliberately favors 'b'; the cross-encoder should still surface 'a' first_hits = _rdb.search(_q, np.array([0,1,0,0],dtype=np.float16).tobytes(), columns=['metadata'], limit=2, table_name='rr', reranking=True)assert json.loads(_hits[0]['metadata'])['doc_id']=='a'
A float32 store searched as float16: every distance comes back NULL and nothing is raised. The guard turns that into a warning instead of a silently useless vector leg.
# Table names are identifiers, including names such as a routed `client-old` shelf._hdb = database(); _hst = _hdb.get_store('client-old', ann=True, ndim=4)_hv = np.array([1,0,0,0], dtype=np.float16)_hst.insert(dict(content='hyphenated shelf', embedding=_hv.tobytes()))assert _hst.vec_search(_hv.tobytes(), ['content'])[0]['content'] =='hyphenated shelf'assert _hst.rebuild_index() ==1assert _hst.ann_search(_hv.tobytes(), ['content'])[0]['content'] =='hyphenated shelf'
import warnings_rng = np.random.default_rng(0)_nrm =lambda x: x/np.linalg.norm(x)_a, _b = (_nrm(_rng.normal(size=64)).astype(np.float32) for _ inrange(2))_d = database(); _s = _d.get_store('store')_s.insert_all([dict(content='a', embedding=_a.tobytes()), dict(content='b', embedding=_b.tobytes())])with warnings.catch_warnings(record=True) as _w: warnings.simplefilter('always') _s.vec_search(_a.tobytes(), ['content'], dtype=np.float16)assertlen(_w) ==1and'distance came back'instr(_w[0].message), [str(x.message) for x in _w]with warnings.catch_warnings(record=True) as _w: # dtype matched: silent warnings.simplefilter('always')assert _s.vec_search(_a.tobytes(), ['content'], dtype=np.float32)[0]['content'] =='a'assertnot _w, [str(x.message) for x in _w]
an ANN-registered store knows its dtype, so the mismatch is caught exactly rather than guessed
_d2 = database(); _s2 = _d2.get_store('store', ann=True, ndim=64, dtype=np.float32)_s2.insert_all([dict(content='a', embedding=_a.tobytes())])with warnings.catch_warnings(record=True) as _w: warnings.simplefilter('always') _s2.vec_search(_a.tobytes(), ['content'], dtype=np.float16)assertlen(_w) ==1and'registered as'instr(_w[0].message), [str(x.message) for x in _w]
db = database()
The fastlite database is set up with usearch extensions. Let’s run some distance calculations.
embs =dict( v1=np.ones((100,),dtype=np.float32).tobytes(), # vector of ones v2=np.zeros((100,),dtype=np.float32).tobytes(), # vector of zeros v3=np.full((100,),0.25,dtype=np.float32).tobytes() # vector of 0.25s)def dist_q(metric):return db.q(f''' select distance_{metric}_f32(:v1,:v2) as {metric}_v1_v2, distance_{metric}_f32(:v1,:v3) as {metric}_v1_v3, distance_{metric}_f32(:v2,:v3) as {metric}_v2_v3 ''', embs)for fn in ['sqeuclidean', 'divergence', 'inner', 'cosine']: print(dist_q(fn))
db.get_store()if'store'in db.t: print('store is created')print('detected fts table: ',db.t.store.detect_fts())print('Search results:', len(db.search('h',np.zeros((100,)).tobytes()))) # there is no data yet, so should be 0
store is created
detected fts table: store_fts
Search results: 0
We can also create a store with hash index on content. Useful for code search applications
/var/folders/kg/9vdw4mdd1fs58svgh4k1qhr09x7dqh/T/ipykernel_91989/3569258371.py:28: UserWarning: 'my_store': every distance came back null or 0, which usually means dtype='f16' does not match how the embeddings were stored (e.g. float32 vectors from model2vec searched as float16). The ranking is meaningless.
warnings.warn(f'{tbl.name!r}: every distance came back null or 0, which usually means dtype={want!r} '
get_store(ann=True) keeps the SQLite table as the source of truth and maintains a usearch HNSW index as a rebuildable sidecar. Table.sync hash-diffs and mirrors changes into it; db.search(..., ann=True) uses it for the vector leg.
The tests below use a deterministic embedder.
# tiny deterministic embedder: content -> 4-d f16 vector_vecs = {'A':[1,0,0,0], 'B':[0,1,0,0], 'C':[0,0,1,0], 'B2':[0,1,1,0], 'D':[0,0,0,1]}def emb_fn(texts): return [np.array(_vecs.get(t, [0.01,0.01,0.01,0.01]), dtype=np.float16) for t in texts]adb = database()ast_ = adb.get_store('ann_store', hash=True, ann=True, metric='cosine') # ndim inferred on first syncassert'usearch_indices'in adb.t, 'registry table created'assert adb._ann_meta('ann_store')['ndim'] isNone, 'ndim unknown until first sync'stats = ast_.sync([dict(content=c) for c in ['A','B','C']], emb_fn=emb_fn)print('sync stats:', stats)assert stats ==dict(changed=3, same=0, removed=0)assert adb._ann_meta('ann_store')['ndim'] ==4, 'ndim inferred and persisted'assert adb.get_index('ann_store').size ==3, 'index has 3 vectors'
# smart update: A unchanged, B->B2 (changed), C removed, D newstats = ast_.sync([dict(content=c) for c in ['A','B2','D']], emb_fn=emb_fn)print('update stats:', stats)assert stats ==dict(changed=2, same=1, removed=2), stats # add B2,D ; remove old-B,Cassert {r['content'] for r in ast_()} == {'A','B2','D'}assert adb.get_index('ann_store').size ==3# D is now findable, C is goneqD = np.array([0,0,0,1], dtype=np.float16).tobytes()assert ast_.ann_search(qD, columns=['content'], limit=1)[0]['content'] =='D'
# force=True: skip the diff, re-ingest everything (nothing stale/removed, all re-embedded)stats = ast_.sync([dict(content=c) for c in ['A','B2','D']], emb_fn=emb_fn, force=True)assert stats ==dict(changed=3, same=0, removed=0), statsassert adb.get_index('ann_store').size ==3# re-adds overwrite existing keysassert ast_.ann_search(qD, columns=['content'], limit=1)[0]['content'] =='D'
# rebuild the index from the SQLite blobs (recovery path) -- drop cached index, rebuildadb.ann_indices.clear()n = ast_.rebuild_index()assert n ==3, nassert ast_.ann_search(qD, columns=['content'], limit=1)[0]['content'] =='D'
# persistence: on-disk db saves the .usearch sidecar; reopening loads it (no re-embed)import tempfile
p =str(Path(tempfile.mkdtemp())/'ann.db')d1 = database(p)s1 = d1.get_store('ann_store', hash=True, ann=True)s1.sync([dict(content=c) for c in ['A','B','C']], emb_fn=emb_fn)assert Path(d1._ann_meta('ann_store')['path']).exists(), 'sidecar saved'd1.close()d2 = database(p) # fresh connection, index loaded lazily from sidecarassert d2.get_index('ann_store').size ==3, 'index restored from disk'assert d2.t.ann_store.ann_search(qC, columns=['content'], limit=1)[0]['content'] =='C'd2.close()
import numpy as np, json
# ANN on an id-pk (non-hash) store: rebuild_index + ann_search must not KeyError on 'rowid'_annt = database().get_store('annrowid', ann=True, ndim=4, metric='cosine')_vs = np.array([[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,1,0,0]], dtype=np.float16)_annt.insert_all([{'content':f'c{i}','embedding':_vs[i].tobytes(),'metadata':json.dumps({'doc_id':f'd{i}'})} for i inrange(4)])assert _annt.rebuild_index() ==4_h = _annt.ann_search(np.array([1,0,0,0],dtype=np.float16).tobytes(), columns=['metadata'], limit=2)assert _h and json.loads(_h[0]['metadata'])['doc_id']=='d0'assert _h[0]['_dist'] isnotNone_h1= _annt.ann_search(np.array([0,1,0,0],dtype=np.float16).tobytes(), columns=['metadata'], where="json_extract(metadata, '$.doc_id')='d3'",limit=2)assert _h1 and json.loads(_h1[0]['metadata'])['doc_id']=='d3'assert _h1[0]['_dist'] isnotNone
# regression: ann_search must restrict to the ANN candidate keys even when `where` is None.# A precedence slip (`a + b if where else ''`) dropped the rowid filter entirely, turning every# unfiltered ANN query into a full-table scan -- correct top hit, but O(table) rows and latency._big = database().get_store('annbig', ann=True, ndim=4, metric='cosine')_bv = np.random.RandomState(0).randn(50,4).astype(np.float16)_bv[0] = np.array([1,0,0,0], dtype=np.float16)_big.insert_all([{'content':f'b{i}','embedding':_bv[i].tobytes()} for i inrange(50)])assert _big.rebuild_index() ==50_qb = np.array([1,0,0,0], dtype=np.float16).tobytes()_rb = _big.ann_search(_qb, columns=['content'], limit=2) # no where clauseassert _rb[0]['content'] =='b0', _rb[:1]assertlen(_rb) ==2, f'ann_search returned {len(_rb)} rows for limit=2'# where_args must reach db.q as query params (not as **kwargs, which raises TypeError)_rb2 = _big.ann_search(_qb, columns=['content'], limit=5, where='content = :c', where_args={'c':'b0'})assert [r['content'] for r in _rb2] == ['b0'], _rb2# _dist must be a plain python float: numpy scalars are not JSON-serializable, which breaks# any caller that json.dumps() the hits (kosha's --as-json, its daemon and MCP server).import json as _json_json.dumps(_rb[0])
'{"content": "b0", "rowid": 1, "_dist": 0.0}'
# regression: ANN + a selective `where` must not lose recall. usearch post-filters its candidate# window (Index.search has no predicate hook), so a match ranked past that window is invisible.# db.search now routes filtered vector queries to the exact vec_search (WHERE applied before ranking)._fdb = database()_fst = _fdb.get_store('annfilter', ann=True, ndim=4, metric='cosine')import json as _json_near, _far = np.array([1,0,0,0], dtype=np.float16), np.array([0,0,0,1], dtype=np.float16)# 20 rows sit exactly on the query vector; the single row we actually want is far from it._rows = [{'content':f'near{i}','embedding':_near.tobytes(),'metadata':_json.dumps({'tag':'other'})} for i inrange(20)]_rows.append({'content':'wanted','embedding':_far.tobytes(),'metadata':_json.dumps({'tag':'keep'})})_fst.insert_all(_rows)assert _fst.rebuild_index() ==21_qf = _near.tobytes()_wh ="json_extract(metadata,'$.tag')='keep'"# the bug: raw ANN post-filter starves recall -- none of the nearest limit*2 candidates carry tag='keep'assert _fst.ann_search(_qf, columns=['content'], limit=2, where=_wh) == [], 'ANN post-filter should miss the far match'# the fix: db.search(ann=True, where=...) falls back to the exact vec scan and surfaces the filtered match_res = _fdb.search('wanted', _qf, columns=['content','metadata'], where=_wh, table_name='annfilter', ann=True, rrf=False)assert [r['content'] for r in _res['vec']] == ['wanted'], _res['vec']
# ann_neighbors: nearest rows to an already-indexed row, no re-embedding_nb = database().get_store('nbstore', ann=True, ndim=4, metric='cosine')_nv = np.array([[1,0,0,0],[0.99,0.01,0,0],[0.7,0.7,0,0],[0,0,1,0]], dtype=np.float16)_nb.insert_all([{'content':f'n{i}','embedding':_nv[i].tobytes()} for i inrange(4)])assert _nb.rebuild_index() ==4assert _nb.ann_vec(1) isnotNoneand _nb.ann_vec(99) isNone, 'vector by key; None for an unknown key'_nn = _nb.ann_neighbors(1, limit=2, columns=['content'])assert [r['content'] for r in _nn] == ['n1','n2'], _nn # n0 itself excluded, n1 is its near-duplicateassert _nb.ann_neighbors(1, limit=1, columns=['content'], include_self=True)[0]['content'] =='n0'assert _nb.ann_neighbors(99) == [], 'unknown key returns nothing rather than raising'
Stores, tokenisation and search
The layer underneath the tour in the README: how a store is created, how identifiers survive tokenisation, how the hybrid ranker fuses its two legs, and when to reach for the ANN index.
get_store(), FTS5 + Embedding Table
db.get_store() creates (or opens) a table with a content TEXT column, an embedding BLOB column, a JSON metadata column, and an FTS5 full-text index that stays in sync automatically via triggers.
store = db.get_store() # idempotent — safe to call multiple timesstore.schema
'CREATE TABLE [store] (\n [content] TEXT NOT NULL,\n [embedding] BLOB,\n [metadata] TEXT,\n [uploaded_at] FLOAT DEFAULT CURRENT_TIMESTAMP,\n [id] INTEGER PRIMARY KEY\n)'
Pass hash=True to use a content-addressed id (SHA-1 of the content). Useful for code search and deduplication, re-inserting the same content is a no-op:
code_store = db.get_store(name='code', hash=True)code_store.insert_all([{'content':v} for v in ('hello world', 'hi there', 'goodbye now')], upsert=True, hash_id='id')code_store(select='id,content')
database() registers apsw’s FTS5 tokenizers and get_store defaults to the second chain. Override with get_store(tokenize=...); tokenize='porter' restores the old behaviour.
The tokenizer resolves by name at query time, so any connection opening the database must register it. database() does. A plain sqlite3 shell raises error in tokenizer constructor.
db.search(): FTS and vectors, fused
db.search() runs an FTS5 keyword query and a vector search, then fuses the two ranked lists with rrf_all. A row on both lists outranks a row on one. Pass rrf=False to get the two legs back separately instead of a merged list.
/Users/71293/code/litesearch/.venv/lib/python3.13/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
from .autonotebook import tqdm as notebook_tqdm
{'fts': [], 'vec': []}
dtype matters. Pass the same dtype you encoded with. model2vec returns float32, so pass dtype=np.float32. The default is float16.
Custom schemas.get_store() is the convenience. For a schema of your own, call db.t['my_table'].vec_search(emb, ...) and rrf_all([fts, vec]) yourself.
ANN store, for large corpora
The default vector search is a brute-force scan over every row, fine to a few thousand. Past that, ann=True maintains a usearch HNSW index as a sidecar. The SQLite table stays the source of truth; store.rebuild_index() rebuilds the index at any time.
store.sync(content, key_col=..., emb_fn=...) hash-diffs: deletes stale rows, embeds and upserts changed ones, mirrors both into the index.
db.search(..., ann=True) uses the index instead of the scan.
Below: 8,000 code snippets through static_code_embedder, both vector legs compared.
import timefrom litesearch.utils import doc_encoder, static_code_embedder, query_encoderemb = static_code_embedder() # model2vec potion-code-16M — 256-dim float16de, qe = doc_encoder(emb), query_encoder(emb) # -> emb_fn: list[str] -> vectorsadb = database()store = adb.get_store('code', hash=True, ann=True) # dtype defaults to float16, matching the embedder# 8,000 code snippets; sync embeds, upserts, and builds the HNSW index in one calldocs = [dict(content=f'def func_{i}(x, y={i}): "helper {i%97}"; return x*{i} + y - {i%13}') for i inrange(8000)]t = time.perf_counter(); stats = store.sync(docs, emb_fn=de)print(f'sync {stats} in {time.perf_counter()-t:.2f}s — index size {adb.get_index("code").size}')# sync {'changed': 8000, 'same': 0, 'removed': 0} in 0.57s — index size 8000
sync {'changed': 8000, 'same': 0, 'removed': 0} in 0.70s — index size 8000
q ='function that multiplies its input by a constant and adds an offset'qv = qe([q])[0].tobytes()def bench(fn, reps=20): t = time.perf_counter()for _ inrange(reps): fn()return (time.perf_counter()-t)/reps*1000# ms per callbf = bench(lambda: store.vec_search(qv, columns=['content'], limit=10)) # exact brute-force scanan = bench(lambda: store.ann_search(qv, columns=['content'], limit=10)) # HNSWprint(f'brute-force {bf:.3f} ms ann {an:.3f} ms speedup {bf/an:.1f}x')# brute-force 1.787 ms ann 0.100 ms speedup 17.8x# HNSW is approximate but recall is high — top-10 matches the exact scan hereexact = {r['content'] for r in store.vec_search(qv, columns=['content'], limit=10)}approx = {r['content'] for r in store.ann_search(qv, columns=['content'], limit=10)}print(f'recall@10: {len(exact & approx)}/10')# recall@10: 10/10
brute-force 3.406 ms ann 0.114 ms speedup 29.9x
recall@10: 10/10
# use it end-to-end via db.search — ann=True swaps in the HNSW index for the vector legadb.search(q, qv, columns=['content'], table_name='code', ann=True, limit=3)