Set up hybrid search
This guide runs hybrid search on a pipeline: one request that searches a vector index and a text index for the same query and fuses the two result lists by rank with reciprocal rank fusion (RRF). The guide covers the default indexes, named indexes, the number of results, the interpretation of the fused score, and weighted fusion in the client application for cases that the native search does not cover. Integrators who build a search box that serves questions and exact terms alike need this guide. Choose and tune a search mode describes each search type separately.
Requirements
Hybrid search reads two or more search indexes of the pipeline. A request that names no index reads the pipeline's default vector index and default text index, so the pipeline needs both defaults:
- Vector index. Created with the pipeline by
embedding_model_id, or added later. - Text index. A full-text index or a BM25 index, created with the pipeline by
full_text_search, or added later and made the default.
The support-kb pipeline from First search has a default vector index for the all-MiniLM-L6-v2 embedding model and a default full-text index. The examples use that pipeline and the shell variables from the tutorial. A pipeline without a text index receives one as described in Add, rebuild or switch a search index.
Hybrid search on the default indexes
Send a hybrid search for a query that contains exact terms:
curl -X POST "$FOUNDATION4_URL/pipelines/$PIPELINE_ID/search" \
-H "x-api-key: $FOUNDATION4_API_KEY" \
-H "x-api-key-secret: $FOUNDATION4_API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"query": "annual plan refunds",
"type": "hybrid",
"classification": "internal"
}'
Expected result: status 200 and the three fragments of the pipeline, ordered by descending fused score. The following response omits the fields other than document_id, page_content and score, and rounds the scores:
[
{
"document_id": "01a0ed0d-df3f-7f80-9a1b-2c3d4e5f6a7b",
"page_content": "Refunds for annual plans are prorated. Support agents must record the refund reason in the ticket before approving a refund.",
"score": 0.0327869
},
{
"document_id": "01a0ed0d-d75f-7d5e-8f60-718293a4b5c6",
"page_content": "Invoices are generated on the first day of each month. Finance staff can export invoices as PDF or CSV files from the Billing page.",
"score": 0.016129
},
{
"document_id": "01a0ed0d-cf82-7c3d-8e4f-5a6b7c8d9e0f",
"page_content": "To reset a password, open Settings, choose Security and select Reset password. A reset link is sent to the registered email address and expires after 30 minutes.",
"score": 0.015873
}
]
Foundation4 ran two searches with the same query, classifications and filter. The vector index ranked the refund fragment first, the invoice fragment second and the password fragment third. The full-text index returned the refund fragment alone, the only fragment that contains annual, plan and refunds after stemming. The fused list therefore places the refund fragment, which leads both lists, well ahead of the other two fragments.
Fused score
A fragment at rank r in a list, counting from 1, receives 1 / (60 + r) from that list. The fused score of a fragment is the sum over all lists, and a fragment that is missing from a list receives nothing from that list:
| Fragment | Vector rank | Full-text rank | Fused score |
|---|---|---|---|
| Refund | 1 | 1 | 1/61 + 1/61 = 0.0327869 |
| Invoice | 2 | Not returned | 1/62 = 0.0161290 |
| Password | 3 | Not returned | 1/63 = 0.0158730 |
The constant 60 flattens the differences between neighboring ranks, so a fragment that appears in both lists outranks a fragment that leads only one list. The fused score depends on ranks only: the distances of the vector search and the ranks of the full-text search, which run on unrelated scales, never enter the sum. The highest possible fused score is the number of lists divided by 61, so a score near 2/61 in a search of two indexes marks a fragment that leads both lists. The fused score is not comparable across queries or with the scores of other search types.
Named indexes
params.indexes names the indexes to fuse. The identifiers come from the indexers field of the pipeline, or from the list of the pipeline's search indexes:
curl "$FOUNDATION4_URL/pipelines/$PIPELINE_ID/search-indexers" \
-H "x-api-key: $FOUNDATION4_API_KEY" \
-H "x-api-key-secret: $FOUNDATION4_API_SECRET"
Expected result: status 200 and an array with the two indexes of support-kb, a vchord index and an fts index, each with status set to ready and is_default set to true. Set two variables from the id fields:
export VECTOR_INDEX_ID=<id of the vchord index>
export TEXT_INDEX_ID=<id of the fts index>
The following request names both indexes, and returns the same result as the search on the default indexes:
curl -X POST "$FOUNDATION4_URL/pipelines/$PIPELINE_ID/search" \
-H "x-api-key: $FOUNDATION4_API_KEY" \
-H "x-api-key-secret: $FOUNDATION4_API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"query": "annual plan refunds",
"type": "hybrid",
"classification": "internal",
"params": {"indexes": ["'"$VECTOR_INDEX_ID"'", "'"$TEXT_INDEX_ID"'"]}
}'
params.indexes accepts any number and mix of indexes of the pipeline, in any order, and ignores repeated identifiers. Useful sets include the following:
- A vector index and a BM25 index. BM25 ranks fragments that share only some words of the query, so the text list contributes to more queries than a full-text index, which requires every word.
- A vector index, a full-text index and a BM25 index. Fragments that contain every query word gain from three lists.
- Two vector indexes for different embedding models. The fused list combines the views of two models, for example while a pipeline moves to a new model.
An identifier that is not a search index of the pipeline returns HTTP 400 with details.error set to Not a search indexer of this pipeline: <id>, and an index that is still being built returns HTTP 409.
Number of results
params.k, 5 by default, sets both the number of fragments that each index returns and the length of the fused list. Each index contributes its first k fragments, the fused list is sorted by fused score and the first k fragments are returned. A larger k therefore gives fragments that rank just below the cut-off in one list a chance to enter the fused list through the other list. The search request has no offset, so a client application that pages through results repeats the search with a larger k.
The vector part of a hybrid search uses the distance strategy of the request, or the default strategy of each vector index. The distance strategy changes the scale of the distances but, for embedding models with vectors of unit length, not the ranks, so the fused list stays the same.
Thresholds and weights
Native hybrid search gives every list the same weight and applies no threshold: a threshold in params is ignored. A client application that needs either runs the searches separately and fuses the lists itself, as described in Weighted fusion in the client application. Typical reasons are the following:
- Weights. A list that should count more than another, such as meaning over exact terms for questions in natural language.
- A similarity threshold. A cut-off that removes distant fragments from the vector list before fusion, while exact matches still reach the fused list through the full-text list.
Weighted fusion in the client application
The client application sends a similarity search and a full-text search with the same query, classification, filter and k, in parallel, and fuses the two lists. Send the similarity search and save the response in a file. The -w option prints the HTTP status code, because the response body goes to the file:
curl -sS -o similarity.json -w '%{http_code}\n' -X POST "$FOUNDATION4_URL/pipelines/$PIPELINE_ID/search" \
-H "x-api-key: $FOUNDATION4_API_KEY" \
-H "x-api-key-secret: $FOUNDATION4_API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"query": "annual plan refunds",
"type": "similarity",
"classification": "internal",
"params": {"k": 20}
}'
Expected result: the command prints 200, and similarity.json holds the three fragments of the pipeline, ordered by ascending distance: the refund fragment at 0.2773, the invoice fragment at 0.8543 and the password fragment at 0.9152. Send the full-text search:
curl -sS -o fulltext.json -w '%{http_code}\n' -X POST "$FOUNDATION4_URL/pipelines/$PIPELINE_ID/search" \
-H "x-api-key: $FOUNDATION4_API_KEY" \
-H "x-api-key-secret: $FOUNDATION4_API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"query": "annual plan refunds",
"type": "search",
"classification": "internal",
"params": {"k": 20}
}'
Expected result: the command prints 200, and fulltext.json holds the refund fragment alone, with a rank of about 0.0936. Both searches return the refund fragment with the same id, so the fusion identifies a fragment across the lists by id.
Fusion script
The following Python script uses only the standard library. The script reads the two files, fuses the lists with weighted RRF and prints the fused score, the document identifier and the start of the text of each fragment. Save the script as fuse.py in the directory of the two files:
import json
RRF_K = 60
def fuse(result_lists, weights=None, key="id", limit=10):
"""Fuse ranked result lists by weighted reciprocal rank fusion."""
weights = weights or [1.0] * len(result_lists)
fused = {}
for results, weight in zip(result_lists, weights):
seen = set()
for fragment in results:
item_key = fragment[key]
if item_key in seen:
continue
seen.add(item_key)
rank = len(seen)
entry = fused.setdefault(item_key, {"fragment": fragment, "rrf": 0.0})
entry["rrf"] += weight / (RRF_K + rank)
ranked = sorted(fused.values(), key=lambda entry: entry["rrf"], reverse=True)
return ranked[:limit]
with open("similarity.json", encoding="utf-8") as f:
similarity = json.load(f)
with open("fulltext.json", encoding="utf-8") as f:
fulltext = json.load(f)
for entry in fuse([similarity, fulltext], weights=[0.7, 0.3]):
fragment = entry["fragment"]
text = fragment.get("page_content", "")[:48].rstrip()
print(f'{entry["rrf"]:.5f} {fragment["document_id"]} {text}')
Run the script:
python3 fuse.py
Expected result: the fused list, with the refund fragment first:
0.01639 01a0ed0d-df3f-7f80-9a1b-2c3d4e5f6a7b Refunds for annual plans are prorated. Support a
0.01129 01a0ed0d-d75f-7d5e-8f60-718293a4b5c6 Invoices are generated on the first day of each
0.01111 01a0ed0d-cf82-7c3d-8e4f-5a6b7c8d9e0f To reset a password, open Settings, choose Secur
The weights 0.7 and 0.3 favor meaning over exact terms, and weights=[0.3, 0.7] favors exact terms. With equal weights, the script returns the same fused scores as native hybrid search, rounded. The script processes the lists in the order of the response arrays, which are sorted best first in both search types, so the script never reads the score field.
The key argument selects the unit of the fused list. With id, each fragment is one entry. With document_id, each list keeps the best-ranked fragment of each document, so a result list for readers shows one entry for each document. Native hybrid search always fuses fragments.
Similarity threshold before fusion
A similarity threshold removes distant fragments from the similarity list before fusion. A fragment that the threshold removes can still reach the fused list through the full-text list, which suits exact terms, such as error codes, that an embedding model represents poorly. For example, the similarity search for PDF CSV scores the invoice fragment, which contains both terms, at 0.5645, so a similarity threshold of 0.5 returns no fragment. The full-text search for PDF CSV returns the invoice fragment, and the fused list holds the invoice fragment alone. Choose and tune a search mode describes how to choose a threshold.
A client application that fuses the lists itself also handles the failure of one search: when one request fails or exceeds a timeout, the application displays the list of the other search and records the failure. Native hybrid search fails as a whole when one of its indexes cannot be searched.