Skip to main content

litesearch

Reading this on GitHub? The formatted documentation is easier to read.

litesearch adds search to an application without a search server. Your documents, their indexes and their vectors live in one SQLite file. Each query runs a keyword search and a vector search, and litesearch merges the two result lists.

search how it finds rows good at weak at
keyword SQLite’s FTS5 extension keeps a full-text index: for each word, the rows that hold it. bm25 ranks the rows. A row scores higher when it holds the query’s words often and when those words are rare. names, error codes, identifiers the same idea in other words
vector An encoder (an embedding model) turns text into an embedding, a list of a few hundred numbers. Texts with similar meanings get embeddings that are close together. “ending a membership” for “cancel my subscription” exact names

Reciprocal Rank Fusion (RRF) merges the two lists. A row gets 1/(60 + its position) from each list that holds it, and the amounts add up. RRF uses positions only. It never compares a bm25 score with a vector distance, and it has no setting to tune.

There are two ways to use the library.

route use it when what you decide
[Index](https://Karthik777.github.io/litesearch/api.html#index) you want to search a folder of documents, a set of strings, or a code base only whether to rerank. The encoder, vector type, chunk size, search strategy and document tree come from measurements in evals/
[database()](https://Karthik777.github.io/litesearch/core.html#database) you need your own columns, your own encoder, your own SQL, or float32 vectors everything, including the vector dtype. A wrong dtype gives a wrong ranking and only a warning

Start with Index. Index.db is the database under it. Use it when you need SQL against the same data.

Install

# usearch's SQLite extension is fetched, probed and configured on first use (see litesearch.postfix)
!uv add litesearch

litesearch has no optional extras. rerank=True needs flashrank. The ONNX encoder FastEncode needs onnxruntime. litesearch imports each one only when you use it, and the error names the package to install.

Route 1: Index

An Index is a searchable collection. add puts files, folders or text into it. Index cuts each document into chunks of about 512 bytes and stores each chunk with its embedding. search returns the chunks that best match a query.

Other methods work on whole documents and sections. sections ranks sections, read returns the full text of one section, toc lists the index as a table of contents, and context collects the best sections for a query, for a language model.

ix = Index()                                        # pass a path to keep it on disk
ix.add('pdfs/attention_is_all_you_need.pdf')
hits = ix.search('how does multi-head attention work', limit=3)
[(h['heading'], h['page']) for h in hits]

Every hit has a heading and a node_id. During add, Index builds a document tree from the headings. The document is the root, its chapters and sections are the nodes, and each chunk records its node. heading is the path of headings above the chunk, such as attention is all you need › Abstract. node_id is the document id, #, and the section number. A hit tells you which section matched, not only which 512 bytes.

sec = ix.sections('how does multi-head attention work', limit=2)   # ranked *sections*, not chunks
[(s['node_id'], (s['snippets'] or [''])[0][:60]) for s in sec]
ix.read(sec[0]['node_id'])['text'][:300]            # one whole section, reassembled
ix.toc(summaries=False)                             # the corpus — no embeddings computed at all

You decide one setting. rerank=True gives the top 30 candidates to a cross-encoder from flashrank. A cross-encoder is a small model that reads the query and each candidate together and scores the match. In the evaluation it improved weighted MRR by 0.026 to 0.077, in all twelve measured cells. It makes a query about 10 times slower and downloads a 4 MB model on first use.

ix.search('how does multi-head attention work', rerank=True)

For source code, use add_code. It cuts Python files at top-level functions and classes with Python’s own parser. Code chunks get no document tree, so toc, sections and read do not cover them:

ix.add_code('litesearch')      # a folder, or the name of an installed package

Route 2: database()

database() opens a SQLite database and returns a fastlite Database. fastlite is a thin layer over SQLite that gives each table a Python object. The first vector search loads usearch’s SQLite extension into the connection, which adds vector distance functions you can call from SQL. db.usearch_sql() loads it before that, for SQL of your own. Pass a file path to keep the database on disk, or nothing for one held in memory.

The cell below does by hand what Index does. It loads an encoder and creates a store: a table with a text column, an embedding column and a full-text index. Then it embeds five sentences and searches them.

Tell the search the number type of the stored vectors. An embedding is stored as raw bytes. litesearch assumes float16, which is half the size of float32. The static encoder returns float32, so the code converts to float16 before it stores. If you store float32 and search as float16, every distance comes back empty, litesearch prints a warning, and the vector search returns rows in table order.

db    = database()
enc   = static_embedder()             # model2vec: no GPU, no ONNX runtime
store = db.get_store(hash=True, ann=True)
emb   = lambda xs: np.asarray(enc.encode(list(xs)), dtype=np.float16)   # a store holds float16

texts = ['attention mechanisms in neural networks', 'transformer architecture for sequences',
         'stochastic gradient descent and learning rate schedules',
         'positional encoding and token embeddings', 'dropout reduces overfitting']
store.insert_all([dict(content=t, embedding=e.tobytes()) for t, e in zip(texts, emb(texts))],
                 upsert=True, hash_id='id', hash_id_columns=['content'])
store.rebuild_index()

q = 'self-attention mechanism'
db.search(q, emb([q])[0].tobytes(), columns=['content'], limit=2)

What is under database()

database() returns a fastlite Database. Four packages sit under it, and each one has one job.

package its job in litesearch
apsw the SQLite driver. It holds the connection, registers the Python FTS5 tokenizers and loads extensions.
apswutils the table layer over apsw. It creates tables, adds columns, inserts and upserts rows, turns on FTS5 with its triggers, and writes the SQL of a full-text search.
fastlite the Database object you use: db.t.name for a table, db.q(sql) for a query
adhara a SQLite extension in Rust: a native tokenizer, and an exact vector search on all cores

litesearch adds the stores, the tokenizers, the search functions and the thread safety. Your code calls litesearch and fastlite. It does not call apsw, apswutils or adhara directly.

adhara

database() loads adhara into each connection when adhara is installed. adhara is a dependency of litesearch on Linux and macOS. It has no Windows wheel, so on Windows litesearch works as before. If adhara does not load, database() gives a warning and the search runs in Python.

No function signature changes. A search returns the same rows in the same order with or without adhara. These behaviours are new:

  • vec_search, the exact vector search, asks adhara for the rowids of the nearest rows. adhara compares the query with every vector in memory, on all cores. vec_search then reads only those rows in SQL and computes _dist as before. Database.search calls vec_search when ann=False or when where is set.
  • vec_search does not use adhara for a search with offset, where_args, an embedding column other than embedding, the divergence metric or float64 vectors.
  • Rows at the same distance come in rowid order. Before, their order depended on the SQLite query plan.
  • SANSKRIT_TOKENIZE is 'adhara', the native form of the cross-script tokenizer. Use it for a new FTS table. A table made with 'sanskrit' keeps working.
  • Each connection has the SQL table function adhara_hybrid. It runs a keyword search, a vector search and RRF in one call. The adhara README documents it.

The HNSW search (ann=True with no where) and the keyword search do not use adhara. New stores keep the tokenizer chain porter simplify casefold 1 sanskrit unicodewords. A Swift app can read these stores with adhara’s Swift package.

The table shows Vault.search from vishalakshi, which is built on Index. The vault holds 181,290 chunks, on a 14-core arm64 Mac. Each time is for 10 searches. The hits were the same with and without adhara.

search without adhara with adhara
no where: HNSW 51 ms 52 ms
a where that most rows pass (3 document kinds) 518 ms 118 ms
a where that few rows pass (1 document kind) 102 ms 130 ms

A where that few rows pass is slower with adhara. SQLite reads only the matching rows through an index, and adhara compares the query with every vector.

Threads: share one connection, or give each thread its own

A SQLite connection used from several threads needs care. database(shared=True) wraps the connection so that threads can share it. Each statement runs alone, and a thread that opens a transaction keeps the connection until it commits. Without shared=True, give each thread its own database() on the same file. SQLite’s WAL mode, which litesearch turns on, lets those connections read while another writes.

python -m evals.shared_conn measures both on a store of 20,000 rows with an HNSW index, on a 14-core arm64 Mac:

shared one each one each, memstatus off
FTS queries/s, 8 threads 608 441 6,820
brute-force vector scans/s, 8 threads 70 176 168
HNSW queries/s, 8 threads 4,705 2,748 2,587
FTS p50 / p99, 4 readers beside a writer 106 / 193 ms 13.4 / 33.0 ms 1.6 / 3.4 ms
resident memory, HNSW index loaded 17 MB 17 MB per thread 17 MB per thread

By default SQLite counts every memory allocation under one lock for the whole process. Threads on separate connections then wait for each other. Run apsw.shutdown(); apsw.config(apsw.SQLITE_CONFIG_MEMSTATUS, 0); apsw.initialize() before the first connection opens to turn the counting off. After that, apsw.memory_used() returns 0. litesearch does not do this, because it changes the whole process.

Share one connection when your threads share one database object and writes are short. For parallel reads, or a steady writer, give each thread its own connection and turn the counting off. evals/RESULTS.md has the method and the results for every thread count.

What the evaluation says

The defaults come from evals/. It runs 120 known-item queries for each of three corpora: EU legislation, arXiv papers and a 19th-century astrology treatise. A known-item query is written to find one passage. The score is MRR (mean reciprocal rank): 1 if the right section is first, 1/2 if second, and so on. Three quarters of the weight is on queries that do not copy the words of the answer. python -m evals.decide gives every number.

The changes above the blank row are on by default. The changes below it are off.

change change in weighted MRR decision
pre() on the keyword search: drop stopwords, match word prefixes, join the words with OR +0.016 to +0.093 on since 0.1.6
512-byte chunks instead of whole pages +0.06 to +0.12 the Index default
cross-encoder rerank +0.026 to +0.077 rerank=True
HNSW index for the vector search -0.005 on by default, for its speed
document tree, used for ranking -0.052 to +0.011 off. The tree is for toc, read and sections
heading text added to each chunk about ±0.02, sign depends on the corpus off
more candidates without a reranker -0.014 to -0.068 off; it helps only with a reranker
late chunking -0.033 to -0.053 removed; the code is in evals/latechunk.py
entity graph -0.070 to -0.160 removed from context; it is in vruksha

The choice of encoder matters little: across four encoders the spread is 0.018 to 0.046. The default, potion-multilingual-128M, is a static model. It looks up one vector for each token, and it indexes about 1,700 times more cheaply than a neural encoder.

The document tree does not improve ranking. It does help a language model: on a Sanskrit corpus, context() raises verse-level recall from 0.190 to 0.340.

On this benchmark, keyword search alone beats hybrid search, because each query rewords words from its target. On evals/multihop.py, where the answer shares no word with the question, keyword search finds nothing, and vector search puts the target first in 53% to 84% of queries.

The other modules

module what you get
litesearch.tree the document tree directly: add_dir, doc_search, context, custom chunkers
litesearch.data file_parse for any file, pyparse for Python code, and the helpers that turn a typed query into an FTS5 query
litesearch.utils encoders: static, ONNX FastEncode, image and multimodal
litesearch.topics groups of similar chunks and labels for them, read off the HNSW index
litesearch.sanskrit the FTS5 tokenizer that matches words across scripts
litesearch.quality which documents in a store only add noise to search results

Three related jobs are in other packages. pdflite reads PDFs. ganapati handles Sanskrit metre, verse chunking and lemmas. vruksha builds an entity graph.

Every store matches words across scripts. A tokenizer is the part of FTS5 that splits text into the words it indexes. litesearch’s tokenizer adds a copy of each word in plain ASCII letters, so श्रीमाता, śrīmātā and srimata reach the same row. It also adds a looser copy that follows English spelling, so Bheeshma reaches भीष्म and Bhīṣma. English words keep their own entries. The looser copy makes the index 14.7% larger. With it, a Devanagari query found the right verse every time (recall 1.000) with every encoder tested. A connection that has not registered the tokenizer cannot search the store. That includes the sqlite3 command-line tool. Open the store with database().

Next steps

Acknowledgements

Thank you to @yfedoseev for pdf-oxide, which litesearch uses to extract text from PDFs.

Metadata

Release files for litesearch 0.1.61

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for litesearch 0.1.61
File Size Uploaded Provenance
litesearch-0.1.61.tar.gz 91.4 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for litesearch 0.1.61
File Interpreter ABI Platform
litesearch-0.1.61-py3-none-any.whl Python 3 none any Details

Total release size: 188.7 kB

Release files / litesearch-0.1.61.tar.gz

Download URL litesearch-0.1.61.tar.gz
Size 91.4 kB
Tags Source
SHA-256 checksum
How to use checksums
4cec536dd2ab46c6b87ad59bff9267b3e8da0d40777dc4f31be0d2266f21270a
BLAKE2b-256 checksum
How to use checksums
a8b336ad0ddc931231d0c290770a2fa4fadc5a56c88159d2854e14ca9f5220a7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release files / litesearch-0.1.61-py3-none-any.whl

Download URL litesearch-0.1.61-py3-none-any.whl
Size 97.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d3144d40b16fd53602c93dc0fe8c2b1bb457ee4548b904764da4b2387b42d3d8
BLAKE2b-256 checksum
How to use checksums
2a57422dcbc04542b8d38c1e84fe53a0dcc6fdab0b99e59e2302a84bc81c9122
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Security for litesearch 0.1.61

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.61 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page