Hybrid Search
Combine keyword search with vector similarity in a single request. Pass a pre-computed embedding vector alongside your text query, and the engine blends both signals. The request is the same on Meilisearch, Typesense, and Elasticsearch, but each index needs a vector field first: an embedders setting on Meilisearch, a float[] field with num_dim on Typesense, a dense_vector field on Elasticsearch. A Typesense index made through the API or the dashboard has an automatic schema with no vector field, and Typesense takes no schema change after that, so only a Sync definition can give it one. Visor has no vector search yet: on a Visor index the request runs keyword-only and the response carries fallback: true.
/v1/hybrid-searchUnified keyword + vector search. Falls back to keyword-only when no vector is provided.
{
"index": "products",
"q": "wireless headphones",
"vector": [0.12, -0.34, 0.56, ...],
"semanticRatio": 0.7,
"limit": 20,
"filter": "price < 200"
}{
"hits": [
{
"id": "42",
"title": "Wireless headphones",
"_scoring": { "keyword": 11.2, "semantic": 0.91, "combined": 0.0328 }
}
],
"query": "wireless headphones",
"processingTimeMs": 18,
"estimatedTotalHits": 156,
"fallback": false
}| Parameter | Type | Default | Description |
|---|---|---|---|
index | string | required | Index UID to search |
q | string | "" | Keyword query string |
vector | number[] | — | Pre-computed embedding vector. Omit for keyword-only search |
semanticRatio | number | — | Balance between keyword (0) and vector (1) results. Must be 0-1 |
limit | number | 20 | Maximum hits to return |
filter | string | — | Filter expression in engine syntax |
Keyword-only fallback: when the index has no vector field, the search runs keyword-only and still answers 200, with "fallback": true in the response. On Typesense and Elasticsearch it does the same when you send no vector. If hybrid search seems to change nothing, check fallback before tuning semanticRatio.
What _scoring means
Each hit carries _scoring with three numbers. combined is the score the hits are ranked by. keyword and semantic are the two parts, but only where the engine reports them. A part the engine does not report is 0, so a 0 does not mean the hit failed to match.
- Elasticsearch, with a vector field:
keywordis the raw BM25 score,semanticthe kNN score, andcombinedthe reciprocal rank fusion score Headband computes from the two lists. - Typesense, with a vector field:
keywordistext_match,semanticis 1 minus the vector distance, andcombinedthe fusion score when Typesense reports one. - Meilisearch, with an embedder: only
combinedis set, to Meilisearch's ranking score (0 to 1).keywordandsemanticare always0, because Meilisearch does not report the two parts. - Keyword-only (
fallback: true):semanticis0andkeywordequalscombined.
The scales differ by engine, so compare scores within one index only, never across engines.
Elasticsearch: add the dense_vector field yourself
Headband creates an Elasticsearch index with dynamic mapping, and its settings API has no mapping field. A float array sent in a document then becomes a plain float field, not a vector, and hybrid search keeps answering fallback: true. Add the dense_vector field on the engine itself, through its dedicated engine endpoint, before any document carries that field:
# 1. The engine's endpoint, and the index's name on it (engineIndexUid)
headband instances list
headband indexes list
# 2. Map the vector field. dims is your embedding model's size.
curl -X PUT "https://<engine id>.ewr1.search.headband.dev/<engineIndexUid>/_mapping" -H "Authorization: ApiKey $HEADBAND_API_KEY" -H "Content-Type: application/json" -d '{"properties":{"embedding":{"type":"dense_vector","dims":1536,"index":true,"similarity":"cosine"}}}'
# 3. Add documents with an "embedding" array, then search with a vector
curl -X POST "https://headband.dev/v1/hybrid-search" -H "Authorization: Bearer $HEADBAND_API_KEY" -H "Content-Type: application/json" -d '{"index":"products","q":"wireless headphones","vector":[0.12,-0.34,...],"semanticRatio":0.5}'- Use an admin key on the engine endpoint: an organization-wide one, or a project key of the index's branch. A search key can only search there, so it cannot change a mapping.
- The index's name on Elasticsearch is lower-cased, such as
hbp_<project id>_products. Copy it fromengineIndexUidrather than building it from the uid. - Map the field first. Once a document has put a float array under that name, the field is a number field and Elasticsearch refuses to change it (
mapper [embedding] cannot be changed from type [float]). Then use a new field name, or delete and re-create the index. - With the field mapped, the response carries
fallback: falseand each hit carries its keyword and semantic scores. headband indexes listshowsinstanceIdandengineIndexUidfor an index on a dedicated engine. An index on your own Elasticsearch cluster (a custom engine config) takes the same mapping on that cluster directly.
Note: Headband does not generate embeddings. You compute the vector using your own model (OpenAI, Cohere, etc.) and pass it in. This keeps the architecture simple and avoids vendor lock-in on embedding models.