Build a Hybrid Keyword + Vector Search Engine with Typesense Typesense 30.2 can now fuse BM25 keyword ranks with local embedding search in a single API call, eliminating the need for a separate embedding service. A tutorial by Rachel Goldstein demonstrates building a hybrid search engine that indexes a movie catalog with auto-generated embeddings and runs hybrid queries that surface results keyword-only search misses. 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. Rachel Goldstein https://sourcefeed.dev/u/rachel goldstein What you'll build You'll stand up a local Typesense https://typesense.org 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 https://docs.docker.com/get-docker/ — works on Apple Silicon and x86 64 curl andfor sending requests and reading JSON jq https://jqlang.github.io/jq/ - ~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 https://jsonlines.org/ 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. Exact vector distance rank fusion score and 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.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 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 threshold inside vector query to drop weak semantic matches entirely. Bring your own embeddings: swap model config for an OpenAI/Cohere/GCP model, or index vectors you computed elsewhere and pass them via vector query .- Read the full Vector Search reference https://typesense.org/docs/30.2/api/vector-search.html and the Semantic Search guide https://typesense.org/docs/guide/semantic-search.html for reranking, curation, and production sizing. Sources & further reading - Vector Search https://typesense.org/docs/30.2/api/vector-search.html — typesense.org - Semantic Search https://typesense.org/docs/guide/semantic-search.html — typesense.org - Install Typesense https://typesense.org/docs/guide/install-typesense.html — typesense.org - typesense/typesense Docker Image https://hub.docker.com/r/typesense/typesense/ — hub.docker.com Rachel Goldstein https://sourcefeed.dev/u/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.