litesearch
NB Reading this on GitHub? The formatted documentation is nicer.
litesearch stores and searches documents in a single SQLite database. It combines FTS5 keyword search with SIMD vector similarity (via usearch), then merges the ranked lists using Reciprocal Rank Fusion. No server, no new infrastructure.
There are two ways in, and picking between them takes one question: do you want the defaults decided for you?
| route | use it when | what it costs you |
|---|---|---|
[Index](https://Karthik777.github.io/litesearch/api.html#index) |
you want to search a folder of documents or code | nothing you asked about — encoder, dtype, chunk size, retrieval strategy and tree are all chosen from evals/ |
[database()](https://Karthik777.github.io/litesearch/core.html#database) |
you need your own columns, your own encoder, SQL, or float32 vectors | you now own six decisions, and one of them fails silently |
Start at Index. Drop to database() when it stops fitting — it is the same object underneath,
reachable as Index.db, so there is no migration and no wrapper to escape from.
Install
# usearch SQLite extensions are configured automatically on first import
# (macOS needs one extra step — see litesearch.postfix)
!uv add litesearch
Route 1 — Index
Three lines. add takes a directory, a file, a string, a list of strings, or {title: text}.
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 carries a heading breadcrumb and a node_id, because Index builds a document tree at
ingest. That tree is what turns “which 512 characters” into “which section”:
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
One knob is left to you. rerank=True runs a flashrank cross-encoder over the top 30 candidates
and is worth +0.026 to +0.077 weighted MRR — positive in all twelve paired cells measured — at
roughly 10x the query latency and a 4 MB model download on first use.
ix.search('how does multi-head attention work', rerank=True)
For code, add_code uses the AST path instead of headings — its tree is module › class › function:
ix.add_code('litesearch') # a directory, or an installed package name
That is the whole surface. Index has six methods; everything below this line is the layer it sits
on, which you do not need until you do.
Route 2 — database()
database() returns a fastlite Database patched with usearch’s
SIMD distance functions. Pass a file path for persistence; omit it for an in-memory store. Reach
for this when you want columns, filters and joins of your own.
db = database()
vecs = dict(v1=np.ones((100,), dtype=np.float32).tobytes(),
v2=np.zeros((100,), dtype=np.float32).tobytes())
{m: db.q(f'select distance_{m}_f32(:v1,:v2) as d', vecs)[0]['d']
for m in ['sqeuclidean', 'divergence', 'inner', 'cosine']}
Four metrics — cosine, sqeuclidean, inner, divergence — each with f32, f16, f64 and
i8 variants, running inside SQL.
The hand-rolled version of route 1 is eight lines, and one of them is a trap:
enc = static_retrieval_embedder() # 512-dim static model — no GPU, no ONNX runtime
store = db.get_store(hash=True, ann=True)
# float16, because that is what a store holds by default. Handing it float32 is the one mistake
# that fails *quietly*: every distance comes back 0 and the ranking is silently keyword-only.
emb = lambda xs: np.asarray(enc.encode(list(xs)), dtype=np.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)
Index exists because those eight lines have to be right every time, and the dtype line has no
error message when it is wrong.
What the evaluation says
evals/ runs 120 known-item queries per genre over three corpora (EU legislation, arXiv papers, a
19th-century astrology treatise) in five query flavours, and scores section-level MRR weighted so
that three quarters of the mass sits on flavours where the query is not a copy of the answer.
python -m evals.decide reproduces every number below.
Read it as a ladder. Everything above the line is already on by default; everything below it is off, and stays off:
| change | Δ weighted MRR | verdict |
|---|---|---|
pre() on the FTS leg |
+0.016 → +0.093 | on by default since 0.1.6 |
| 512-char chunks over page-sized | +0.06 → +0.12 | Index default |
| cross-encoder rerank | +0.026 → +0.077 | rerank=True — the one lever worth a decision |
| HNSW ANN vector leg | −0.005 | on by default; buy the speed |
| — | ||
| document tree, for ranking | −0.052 → +0.011 | a wash. Built anyway, for toc/read/sections |
| heading prefix on the chunk | ±0.02, sign flips by genre | a wash |
| deeper candidate fanout alone | −0.014 → −0.068 | fanout pays only with a reranker |
| late chunking | −0.033 → −0.053 | exporti: in the module, out of __all__ |
| entity graph leg | −0.070 → −0.160 | opt-in by name only; see below |
Three findings worth more than their line in the table:
The encoder is not the lever. Across potion-32M (static, no GPU), bge-small, jina-v2-sm
and egemma-300m the spread is 0.018–0.046, and the static model wins one genre outright. It
indexes ~1,700x cheaper. That is why the default is a static model — specifically
potion-multilingual-128M, the multilingual member of that family, so that Devanagari and other
non-Latin scripts are covered without choosing an encoder.
The tree does not improve ranking, and is still worth building. Section ranking is a wash.
Section assembly is not: on the Sanskrit corpus context() roughly doubles verse-level recall
over plain chunk search (0.190 → 0.340), the largest single effect measured anywhere in evals/.
FTS alone looks unbeatable on this benchmark, and that is the benchmark’s fault. Keyword-only
retrieval with pre() beats hybrid in all 24 paired cells — because every query in the main set is
a lexical transformation of the sentence it targets, so surface overlap always suffices.
evals/multihop.py builds the corrective: a bridge set where the answer shares no token with the
question. There FTS cannot score at all and the vector leg reaches the target 53–84% of the time at
rank 1. Vectors earn their place on bridging, not on known-item lookup.
Beyond the two routes
Each of these has its own page. None of them is a decision you have to make to get started.
| module | what you get |
|---|---|
litesearch.tree |
the document tree directly — add_dir, doc_search, context, custom chunkers and summarizers |
litesearch.data |
PDF extraction, file_parse for any file type, pyparse/pkg2chunks for code, FTS query preprocessing |
litesearch.utils |
ONNX encoders — FastEncode, FastEncodeImage, FastEncodeMultimodal for cross-modal image+text search |
litesearch.sanskrit |
verse readers, VerseChunker, metre detection, sandhi-splitting lemmas, Monier-Williams glosses |
litesearch.graph |
entity graph and graph_search, with no LLM anywhere |
Cross-script search is on for every store, not only Sanskrit ones: the sanskrit FTS5
tokenizer emits an ASCII fold of each token as a colocated token, so श्रीमाता, śrīmātā and
srimata all reach the same row. It is purely additive — ordinary English tokenises identically —
and it is the single largest measured retrieval win in the repository: 1.000 Devanagari→verse
recall for every encoder tested, because the tokenizer does the work no embedding had to. One
real cost: a store built with this chain cannot be opened by a connection that has not registered
the tokenizer, plain sqlite3 included.
The graph leg is opt-in by name. Index does not expose it and db.context() defaults to
graph=False. On ordinary queries it is negative in every cell, genre and flavour, monotonically
worse as its weight rises. On the bridge set built to favour it, it buys roughly +0.04 target MRR
and +0.12 hit@1 on one genre of three, while losing 0.10–0.16 on ordinary questions and running
3–4x slower. Call db.graph_search when you know your traffic looks like that.
Next Steps
- examples/01_simple_rag.ipynb — ingest a folder of PDFs, chunk with chonkie, rerank with FlashRank
- examples/02_tool_use.ipynb — wire litesearch into an LLM tool-use loop
- api docs —
Index, and what each default is worth - core docs —
database,get_store,search,rrf_merge,vec_search - tree docs —
add_dir,toc,read,sections,context - vishalakshi — a litesearch-backed vault, and the first caller nominated to port onto
Index; see the api page for what that port should test
Acknowledgements
A big thank you to @yfedoseev for pdf-oxide, which powers the PDF extraction functionality in litesearch.data.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file litesearch-0.1.24.tar.gz.
File metadata
- Download URL: litesearch-0.1.24.tar.gz
- Upload date:
- Size: 95.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8705a5f994841e6cc266db55c28a3b149ec9b5fac3cd606eb48ddda4f80cbb48
|
|
| MD5 |
a88e8a2953f9fb5732c9d94232cd2a96
|
|
| BLAKE2b-256 |
e3c1fa00d2360ccd999e3e849212e2b1da19cdb9838420782aba1ceb8cb1cfbf
|
File details
Details for the file litesearch-0.1.24-py3-none-any.whl.
File metadata
- Download URL: litesearch-0.1.24-py3-none-any.whl
- Upload date:
- Size: 100.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1aa5cf9e3f2bf54eac97ce3d7a25e21a4e8d44c36d0b06aa0280f7faf6c81e30
|
|
| MD5 |
b8341389ff69ffb01a00e94f2cebf25b
|
|
| BLAKE2b-256 |
4f669ec2823059cee992c61b65fb7c665009594171bba9ec9f22d006e55511ac
|