Build a Hybrid Keyword + Vector Search Engine with Typesense
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 image
typesense/typesense:30.2) - Docker 24+ (install) — works on Apple Silicon and x86_64
- curl and jq for sending requests and reading JSON
- ~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
vector_distance— even documents that shared no keywords with the query — proving the semantic leg ran. (Exactrank_fusion_scoreand distance numbers will vary; the presence of 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 with
alpha: 0.1and 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 names declared in the same schema.
Import hangs or first request returns 503 Service Unavailable — the model is still downloading. 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: add
filter_by(e.g.filter_by:"year:>2010") to constrain either leg before scoring. - Distance cutoffs: use
distance_thresholdinsidevector_queryto drop weak semantic matches entirely. - Bring your own embeddings: swap
model_configfor an OpenAI/Cohere/GCP model, or index vectors you computed elsewhere and pass them viavector_query. - Read the full Vector Search reference and the Semantic Search guide for 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 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.