# Build a Hybrid Keyword + Vector Search Engine with Typesense

> Source: <https://sourcefeed.dev/a/build-a-hybrid-keyword-vector-search-engine-with-typesense>
> Published: 2026-07-23 11:45:14+00:00

# 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.
