Fuse BM25 keyword ranks with local embedding search in one Typesense API call for better relevance.
What you'll build #
You'll stand up a local Typesense search engine, index a small movie catalog with auto-generated embeddings, and run a single query two ways: pure BM25 keyword search, then a hybrid search that fuses keyword ranks with semantic vector ranks. By the end you'll see the hybrid query surface results that keyword-only search misses β and you'll know how to tune the keyword/vector balance with one parameter.
Typesense is unusual here: it generates query and document embeddings inside the server (built-in ONNX runtime, CPU only), so you never run a separate embedding service. Keyword search, vector search, and rank fusion all happen in one API call.
Prerequisites #
Verified against these versions on 2026-07-23:
Typesense Server 30.2(Docker imagetypesense/typesense:30.2
)Docker 24+ (install) β works on Apple Silicon and x86_64curl andfor sending requests and reading JSONjq- ~2 GB free RAM and an internet connection on first index (Typesense downloads the embedding model once)
No GPU, no Python, no OpenAI key required β the embeddings are computed locally by Typesense.
1. Start Typesense #
export TYPESENSE_API_KEY=xyz
mkdir -p "$(pwd)/typesense-data"
docker run -d --name typesense -p 8108:8108 \
-v "$(pwd)/typesense-data:/data" \
typesense/typesense:30.2 \
--data-dir /data --api-key="$TYPESENSE_API_KEY" --enable-cors
Confirm it's healthy:
curl -s http://localhost:8108/health
You should get {"ok":true}
.
2. Create a collection with an auto-embedding field #
The embed
block tells Typesense to build a vector for each document from the title
and overview
fields. ts/all-MiniLM-L12-v2
is a built-in model (384 dimensions); the ts/
prefix means Typesense fetches and runs it for you β no num_dim
needed when you use auto-embedding.
curl -s "http://localhost:8108/collections" \
-H "X-TYPESENSE-API-KEY: $TYPESENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "movies",
"fields": [
{"name": "title", "type": "string"},
{"name": "overview", "type": "string"},
{"name": "embedding","type": "float[]",
"embed": {
"from": ["title", "overview"],
"model_config": {"model_name": "ts/all-MiniLM-L12-v2"}
}
}
]
}'
3. Index the documents #
Typesense imports JSONL (one document per line). The first import triggers the model download (~120 MB), so this call can take 30β60 seconds; later imports are instant.
curl -s "http://localhost:8108/collections/movies/documents/import?action=create" \
-H "X-TYPESENSE-API-KEY: $TYPESENSE_API_KEY" \
-H "Content-Type: text/plain" \
--data-binary $'{"id":"1","title":"Interstellar","overview":"A team of explorers travels through a wormhole in space in search of a new home for humanity."}\n{"id":"2","title":"The Martian","overview":"An astronaut is stranded on Mars and must survive alone until a rescue mission arrives."}\n{"id":"3","title":"Gravity","overview":"Two astronauts fight to survive after debris leaves them adrift in orbit above Earth."}\n{"id":"4","title":"Jurassic Park","overview":"Scientists clone dinosaurs to populate an island theme park that quickly goes wrong."}\n{"id":"5","title":"Finding Nemo","overview":"A timid clownfish searches the ocean to find his abducted son."}'
Each line comes back with {"success":true}
.
4. Run the keyword-only baseline #
Search only the text fields β this is standard BM25.
curl -s "http://localhost:8108/multi_search" \
-H "X-TYPESENSE-API-KEY: $TYPESENSE_API_KEY" \
-d '{"searches":[{
"collection":"movies",
"q":"surviving alone in outer space",
"query_by":"title,overview"
}]}' | jq '.results[0].hits[].document.title'
You'll get only documents that literally contain query words β "Interstellar" (has "space"), "The Martian" (has "survive alone"), and "Gravity" (has "survive"). A film about being adrift with no shared keywords would be ranked purely on those word overlaps, and near-synonyms are invisible.
5. Run the hybrid search #
Add the embedding
field to query_by
. Typesense now embeds your query string automatically, runs a nearest-neighbor search alongside the keyword search, and fuses the two result lists. The vector_query
lets you set alpha
β the weight given to the vector rank in the fusion (default 0.3
; keyword gets 1 β alpha
). Here we lean semantic at 0.7
.
curl -s "http://localhost:8108/multi_search" \
-H "X-TYPESENSE-API-KEY: $TYPESENSE_API_KEY" \
-d '{"searches":[{
"collection":"movies",
"q":"surviving alone in outer space",
"query_by":"title,overview,embedding",
"vector_query":"embedding:([], alpha: 0.7)",
"exclude_fields":"embedding"
}]}' | jq '.results[0].hits[] | {title: .document.title, rrf: .hybrid_search_info.rank_fusion_score, dist: .vector_distance}'
The empty []
in embedding:([], alpha: 0.7)
tells Typesense to use the vector it auto-generated from q
rather than a vector you supply. exclude_fields:"embedding"
keeps the 384-float array out of the response.
Verify it works #
The hybrid response reorders and expands the result set. Each hit now carries a fused score plus a vector distance, similar to:
{"title": "Gravity", "rrf": 0.85, "dist": 0.42}
{"title": "The Martian", "rrf": 0.72, "dist": 0.39}
{"title": "Interstellar", "rrf": 0.66, "dist": 0.31}
{"title": "Finding Nemo", "rrf": 0.30, "dist": 0.61}
Two things confirm success:
Every hit has aβ even documents that shared no keywords with the query β proving the semantic leg ran. (Exactvector_distance
rank_fusion_score
and distance numbers will vary; thepresenceof both fields and the reordering is what matters.)The ranking shifts versus Step 4. Semantically strong matches like "Gravity" climb, while a keyword coincidence sinks. Re-run Step 5 withalpha: 0.1
and the order snaps back toward the keyword baseline β that single knob is the whole point of rank fusion.
Typesense builds the fused score from each document's rank in the two lists: rank_fusion_score = (1 β alpha) Β· K + alpha Β· S
, where K
and S
are the keyword and semantic ranks. Set rerank_hybrid_matches: true
in the search params to force both a text_match
and a vector_distance
onto every hit for finer control.
Troubleshooting #
** Property embed.model_config.model_name is not a valid string. on collection creation** β the
embed
block is malformed or nested under the wrong field. It must sit inside the float[]
field definition, with from
as an array of existing string field namesdeclared in the same schema.
Import hangs or first request returns 503 Service Unavailable β the model is still down.
ts/all-MiniLM-L12-v2
is fetched on the first index; watch progress with docker logs -f typesense
. If it never completes, the container likely has no outbound network β the model download needs internet even though inference is local.** {"code":404,"error":"Could not find a field named embedding in the schema."} on search** β you added
embedding
to query_by
on a collection created without the embed
field. Drop and recreate the collection (curl -X DELETE .../collections/movies
) with the Step 2 schema.** vector_query returns Field embedding must have a vector query value.** β the
([], ...)
brackets are missing or you passed a non-empty vector while relying on auto-embedding. For an auto-embedded query, keep the brackets empty: embedding:([], alpha: 0.7)
.## Next steps
Filter + fusion: addfilter_by
(e.g.filter_by:"year:>2010"
) to constrain either leg before scoring.Distance cutoffs: usedistance_threshold
insidevector_query
to drop weak semantic matches entirely.Bring your own embeddings: swapmodel_config
for an OpenAI/Cohere/GCP model, or index vectors you computed elsewhere and pass them viavector_query
.- Read the full Vector Search referenceand theSemantic Search guidefor reranking, curation, and production sizing.
Sources & further reading #
Vector Searchβ typesense.org - Semantic Searchβ typesense.org - Install Typesenseβ typesense.org - typesense/typesense Docker Imageβ hub.docker.com
Rachel GoldsteinΒ· Dev Tools Editor
Rachel has been embedded in the developer tooling ecosystem for nearly eight years, covering everything from IDE wars and package-manager drama to the quiet rise of AI-assisted coding. She has a soft spot for open-source maintainers and an unhealthy number of terminal emulators installed on a single laptop.
Discussion 0 #
No comments yet
Be the first to weigh in.