Skip to content
AI Advanced Tutorial

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
Rachel Goldstein
Dev Tools Editor · Jul 23, 2026 · 5 min read
Build a Hybrid Keyword + Vector Search Engine with Typesense

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:

  1. Every hit has a vector_distance — even documents that shared no keywords with the query — proving the semantic leg ran. (Exact rank_fusion_score and distance numbers will vary; the presence of both fields and the reordering is what matters.)
  2. 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 and the Semantic Search guide for reranking, curation, and production sizing.

Sources & further reading

  1. Vector Search — typesense.org
  2. Semantic Search — typesense.org
  3. Install Typesense — typesense.org
  4. typesense/typesense Docker Image — hub.docker.com
Rachel Goldstein
Written by
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

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading