Skip to main content

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

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

litesearch-0.1.30.tar.gz (96.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

litesearch-0.1.30-py3-none-any.whl (100.7 kB view details)

Uploaded Python 3

File details

Details for the file litesearch-0.1.30.tar.gz.

File metadata

  • Download URL: litesearch-0.1.30.tar.gz
  • Upload date:
  • Size: 96.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.1

File hashes

Hashes for litesearch-0.1.30.tar.gz
Algorithm Hash digest
SHA256 532c513af9a86323368cb4fd72448aac46e63e1f982a1fb784c41d6fb4eb6723
MD5 e4801c9fefd1d0f24251f9dc046e2b5f
BLAKE2b-256 df057464e063891fe17d30f9d6fe514ac818a36d11fe5068c7897e92e2c93d66

See more details on using hashes here.

File details

Details for the file litesearch-0.1.30-py3-none-any.whl.

File metadata

  • Download URL: litesearch-0.1.30-py3-none-any.whl
  • Upload date:
  • Size: 100.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.1

File hashes

Hashes for litesearch-0.1.30-py3-none-any.whl
Algorithm Hash digest
SHA256 c3a84cf7dc3be3f7774e57e719c7e8606090b5eb2696e8188f1045bff2598d82
MD5 0f11d447b4fd12536ce0a19d20141715
BLAKE2b-256 d97ab111565e68bd40cd793d455b6de59df2438db135d8e1560cb38d294223c8

See more details on using hashes here.

Release history Release notifications | RSS feed

0.1.34

2 files

0.1.33

2 files

0.1.32

2 files

0.1.31

2 files

This release

0.1.30 This release

2 files

0.1.29

2 files

0.1.28

2 files

0.1.27

2 files

0.1.26

2 files

0.1.24

2 files

0.1.23

2 files

0.1.22

2 files

0.1.21

2 files

0.1.20

2 files

0.1.18

2 files

0.1.16

2 files

0.1.15

2 files

0.1.14

2 files

0.1.13

2 files

0.1.12

2 files

0.1.10

2 files

0.1.9

2 files

0.1.7

2 files

0.1.6

2 files

0.1.5

2 files

0.1.4

2 files

0.1.3

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

0.0.37

2 files

0.0.36

2 files

0.0.35

2 files

0.0.34

2 files

0.0.33

2 files

0.0.32

2 files

0.0.31

2 files

0.0.30

2 files

0.0.29

2 files

0.0.28

2 files

0.0.27

2 files

0.0.26

2 files

0.0.25

2 files

0.0.24

2 files

0.0.23

2 files

0.0.22

2 files

0.0.21

2 files

0.0.20

2 files

0.0.19

2 files

0.0.18

2 files

0.0.17

2 files

0.0.16

2 files

0.0.15

2 files

0.0.14

2 files

0.0.13

2 files

0.0.12

2 files

0.0.11

2 files

0.0.10

2 files

0.0.9

2 files

0.0.8

2 files

0.0.7

2 files

0.0.6

2 files

0.0.5

2 files

0.0.4

2 files

0.0.3

2 files

0.0.2

2 files

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