cd /news/artificial-intelligence/build-a-hybrid-keyword-vector-search… Β· home β€Ί topics β€Ί artificial-intelligence β€Ί article
[ARTICLE Β· art-70025] src=sourcefeed.dev β†— pub= topic=artificial-intelligence verified=true sentiment=Β· neutral

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.

read6 min views1 publishedJul 23, 2026
Build a Hybrid Keyword + Vector Search Engine with Typesense
Image: Sourcefeed (auto-discovered)

Fuse BM25 keyword ranks with local embedding search in one Typesense API call for better relevance.

Rachel Goldstein

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.

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.

── more in #artificial-intelligence 4 stories Β· sorted by recency
── more on @typesense 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/build-a-hybrid-keywo…] indexed:0 read:6min 2026-07-23 Β· β€”