13. Hybrid top-k - sparse and dense, fused
← Vector exampleswhat this does
POST /v1/tenants/:t/vector/:table/topk_hybrid runs a dense search and a sparse search over the same table and fuses the two rankings server-side, returning one list. Without it you would issue both searches yourself and merge the results in your own code.
The sparse side of the table is written with POST /v1/tenants/:t/vector/:table/put_sparse, which takes a vector in the usual sparse layout - parallel index and value arrays plus the full width.
when to use it
- Retrieval for RAG, where a dense embedding finds the paraphrase and a sparse one finds the exact term - a product code, an error string, a surname.
- Search where a query is sometimes a sentence and sometimes two keywords, and one ranking alone is wrong for half your traffic.
- Anywhere you are already issuing two searches and merging them client-side, paying two round trips for it.
writing the sparse side
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/vector/shop.products/put_sparse" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "sku-9281",
"indices": [17, 402, 5561],
"values": [0.82, 1.40, 0.31],
"dim": 30000
}'# No typed SDK helper for this route yet - call it directly.
import httpx
r = httpx.post(
f"{OC_HOST}/v1/tenants/{OC_TENANT}/vector/shop.products/put_sparse",
headers={"Authorization": f"Bearer {OC_TOKEN}"},
json={
"id": "sku-9281",
"indices": [17, 402, 5561],
"values": [0.82, 1.40, 0.31],
"dim": 30000,
},
)
r.raise_for_status()
# 201 Created, empty body.// No typed SDK helper for this route yet - call it directly.
const res = await fetch(
`${OC_HOST}/v1/tenants/${OC_TENANT}/vector/shop.products/put_sparse`,
{
method: "POST",
headers: {
Authorization: `Bearer ${OC_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
id: "sku-9281",
indices: [17, 402, 5561],
values: [0.82, 1.4, 0.31],
dim: 30000,
}),
},
);// No typed SDK helper for this route yet - call it directly.
payload, _ := json.Marshal(map[string]any{
"id": "sku-9281",
"indices": []uint32{17, 402, 5561},
"values": []float32{0.82, 1.40, 0.31},
"dim": 30000,
})
url := fmt.Sprintf("%s/v1/tenants/%s/vector/shop.products/put_sparse", host, tenant)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)what the write returns
201 Created with an empty body, the same as the dense put. The id is what ties the two sides together: write the sparse vector under the same id as the dense one and the fusion can see both.
the hybrid query
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/vector/shop.products/topk_hybrid" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"dense_query": [0.0124, -0.0883, /* ... 768 floats ... */],
"dense_dim": 768,
"dense_metric": "cosine",
"sparse_query_indices": [17, 402],
"sparse_query_values": [0.9, 1.2],
"sparse_dim": 30000,
"k": 10,
"rrf_k": 60
}'# No typed SDK helper for this route yet - call it directly.
import httpx
r = httpx.post(
f"{OC_HOST}/v1/tenants/{OC_TENANT}/vector/shop.products/topk_hybrid",
headers={"Authorization": f"Bearer {OC_TOKEN}"},
json={
"dense_query": query_768d,
"dense_dim": 768,
"dense_metric": "cosine",
"sparse_query_indices": [17, 402],
"sparse_query_values": [0.9, 1.2],
"sparse_dim": 30000,
"k": 10,
},
)
r.raise_for_status()
for hit in r.json():
print(hit["id"], hit["score"])// No typed SDK helper for this route yet - call it directly.
const res = await fetch(
`${OC_HOST}/v1/tenants/${OC_TENANT}/vector/shop.products/topk_hybrid`,
{
method: "POST",
headers: {
Authorization: `Bearer ${OC_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
dense_query: query768d,
dense_dim: 768,
dense_metric: "cosine",
sparse_query_indices: [17, 402],
sparse_query_values: [0.9, 1.2],
sparse_dim: 30000,
k: 10,
}),
},
);
const hits = await res.json();// No typed SDK helper for this route yet - call it directly.
payload, _ := json.Marshal(map[string]any{
"dense_query": query768d,
"dense_dim": 768,
"dense_metric": "cosine",
"sparse_query_indices": []uint32{17, 402},
"sparse_query_values": []float32{0.9, 1.2},
"sparse_dim": 30000,
"k": 10,
})
url := fmt.Sprintf("%s/v1/tenants/%s/vector/shop.products/topk_hybrid", host, tenant)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)what you get back
[
{ "id": "sku-9281", "score": 0.0325 },
{ "id": "sku-1144", "score": 0.0161 },
{ "id": "sku-5520", "score": 0.0156 }
/* ... up to k entries ... */
]
// A bare array - the same hit shape the single-mode topk routes return. score here is the fused rank score, not a distance or a similarity. It is always positive and it compares two hits within one hybrid query - it does not compare to the cosine or L2 scores of an ordinary top-k.
request fields
| Field | Required | Notes |
|---|---|---|
| dense_query | yes | The dense embedding. Its length must equal dense_dim. |
| dense_dim | yes | Dense vector width. Must match the table's dimension. |
| dense_metric | no | "cosine" (default), "dot", "l2" or "manhattan". |
| dense_mode | no | "fast" or "high_recall", tuning the dense leg only. |
| sparse_query_indices | yes | Non-zero component positions. Need not be sorted - the server sorts, and sums the values of any duplicate index. |
| sparse_query_values | yes | The values at those positions. Same length as the indices. |
| sparse_dim | yes | Full sparse width. Every index must be strictly below it. |
| k | yes | How many fused hits to return. |
| rrf_k | no | Fusion constant, default 60. Raising it flattens the contribution any single list makes; lowering it sharpens it. |
| candidates | no | How many candidates to pull from each list before fusing. Defaults to the larger of 4k and 40, and is clamped to at least k. Larger means better recall and more work per query. |
| filter | no | Metadata equality filter, applied to both legs before fusion. Same shape as the filter on the single-mode routes. |
how it works
Each leg produces a ranked list of candidates hits. Fusion then scores every id by summing 1 / (rrf_k + rank) over the lists it appears in, and returns the top k. Because the sum is over ranks and not over scores, the two legs need no calibration against each other - which is the reason to fuse this way rather than by adding raw similarities.
The consequence worth designing around: a document that places second in both lists beats a document that places first in one and is absent from the other. Appearing in both is the signal.
Sparse vectors go into their own inverted index, which walks only the posting lists for the terms your query actually names. A document with no term in common with the query has a zero dot product and no defined rank, so it never surfaces from the sparse leg - it can still reach the result through the dense one.
common mistakes
- Comparing a hybrid score to a top-k score. They are different quantities. A hybrid score near 0.03 is not "worse" than a cosine score of 0.94; it is a rank sum. Compare hybrid scores only to other hybrid scores from the same
rrf_k. - Writing the sparse and dense sides under different ids. The id is the join. Two ids means two documents, and fusion has nothing to combine.
- Sending an index equal to
dim. Indices are zero-based and must be strictly below the width - an out-of-bounds component is a 400, on the write and on the query. - Sending NaN or an infinity in
values. Refused at the boundary with 400 rather than allowed to poison a score. - Mismatched array lengths.
indicesandvaluesmust be the same length, on both the write and the query side. - Calling it as a row-restricted user. The fused score encodes ranks assigned in lists that include rows a row-level policy would hide, so the route refuses for such a caller rather than serving a leak that looks fused.