Tennis

How it works

Chunking, BM25, cosine, and reciprocal rank fusion — and the reasoning behind each choice.

your text
   ├── chunked (~1000 chars, 100 overlap, split on paragraph/sentence boundaries)
   ├── FTS5 index ────────► BM25 ranking ──┐
   └── static embedder ───► cosine ranking ─┴──► reciprocal rank fusion ──► results
                                                 stored in one SQLite file

Why fuse by rank

BM25 scores and cosine similarities are on incomparable scales. Normalizing them into a weighted sum requires inventing an opinion about how a 0.7 cosine compares to a BM25 of −1.3, and that opinion is wrong for some corpus.

Positions in a ranking are directly comparable and need no calibration, which is why reciprocal rank fusion is the default everywhere it is used. A document's score is the sum of 1/(k + rank) over the rankers that found it, with k = 60 from the original paper.

That arithmetic is why the numbers in the output behave the way they do. A document ranked first by both rankers scores 1/61 + 1/61 = 0.0328. A document ranked first by only one scores 1/61 = 0.0164 — exactly half. The [kw#1 sem#1] tags are not decoration; they are the derivation of the score.

There is no approximate index. At 512 dimensions a single core compares a few million vectors per second, so exact search stays comfortably fast well past the point where you would move to a server anyway.

Exactness means no recall cliff, no index to tune, and no parameters that quietly degrade results as the corpus grows.

Why chunk at all

The built-in model has no context limit, so chunking is not working around a truncation bug. It is working around averaging.

A vector is the average of its tokens, so the longer the text the more it converges on the average of the language rather than the meaning of the passage. A ten-page document embeds to roughly nothing in particular. Chunking keeps each vector about one idea.

Chunks are ~1000 characters with 100 characters of overlap, split on paragraph and then sentence boundaries, so a match spanning a boundary is still found whole. --chunk sets the width for a new namespace.

Why the FTS triggers matter

SQLite's external-content FTS5 tables do not follow their source table automatically. Without triggers the index silently drifts on every update and delete — deleted documents keep matching, stale text keeps being returned, and nothing errors.

Tennis creates those triggers in the first migration, and there is a regression test that updates and deletes a document and asserts the old terms stop matching.

Compared to sqlite-vec

sqlite-vec answers "where do my vectors live" — and deliberately leaves everything else to you. Tennis makes the same core commitment (your index is one SQLite file you can open with sqlite3, copy, back up, or delete) and builds in the layers you would otherwise assemble around it:

sqlite-vecTennis
Vector storage, exact KNNyesyes
Embeddingsbring your ownmodel ships in the binary, runs offline
Keyword searchseparate FTS5 setupBM25 built in
Hybrid rankinghand-written fusion SQLreciprocal rank fusion by default
Loading itcompiled C extension (in Go: cgo)pure Go, CGO_ENABLED=0, one static binary
Chunking, incremental indexingyours to writebuilt in
Embedder/index mismatchsilently wrong resultsrefused at open, by design

If you want raw SQL over vectors inside a database you already have, use sqlite-vec — it is very good at exactly that. Tennis is for when you want the whole retrieval loop — embed, chunk, index, fuse, filter — working in one command.

What it isn't

  • Not an approximate-nearest-neighbor engine. Exact scan only. If you have millions of vectors and need sub-millisecond search, use a vector database.
  • Not a server. No auth, no multi-tenancy, no replication. It's a file.
  • Not a reranker. Results come from BM25 and cosine fused; there is no cross-encoder pass.
  • Not a relevance threshold. Semantic search ranks everything, so a query always returns up to TopK results even when nothing is a good match. Use the score and the kw#/sem# tags to judge; a result found by only one ranker with a low score is usually noise.
  • Not multimodal. Text only.
  • Not good at CJK keyword search. The FTS5 tokenizer does not segment Chinese, Japanese, or Korean, so the keyword ranker finds nothing for CJK queries; semantic search still works. Fixing this properly (a trigram tokenizer) changes the index schema, so it is tracked as an issue rather than patched quietly.

The file

Everything lives in ~/.tennis/db.sqlite (or $TENNIS_DB, or --db). It is an ordinary SQLite database:

sqlite3 ~/.tennis/db.sqlite '.tables'

Copy it, back it up, or delete it. There is no other state except the model cache in ~/.cache/tennis.