Hybrid Search Using Qdrant Cloud Inference
| Time: 15 min | Level: Beginner |
|---|
In this tutorial, we’ll walk through building a hybrid semantic search engine using Qdrant Cloud’s built-in inference capabilities. You’ll learn how to:
- Automatically embed your data using cloud Inference without needing to run local models,
- Combine dense semantic embeddings with BM25 keyword search, and
- Perform hybrid search using Reciprocal Rank Fusion (RRF) to retrieve the most relevant results.
Hybrid search combines two retrieval methods. Lexical retrieval (here with BM25) matches the words of the query, which helps with names, abbreviations, and rare terms. Semantic retrieval (here with the all-minilm-l6-v2 embedding model) matches meaning, so it also finds passages that use different words. Each method can find results the other misses, so hybrid search runs both and merges their result lists.
Initialize the Client
For Python, install the Qdrant client and the Hugging Face datasets library:
pip install qdrant-client datasets
For the other languages, install a client from the API & SDKs page.
Initialize the Qdrant client after creating a Qdrant Cloud account and a cluster. In Python, set cloud_inference to True to use Cloud Inference.
from qdrant_client import QdrantClient
client = QdrantClient(
"xyz-example.cloud-region.cloud-provider.cloud.qdrant.io",
api_key="<paste-your-api-key-here>",
cloud_inference=True,
timeout=30,
)
import {QdrantClient} from "@qdrant/js-client-rest";
const client = new QdrantClient({
url: 'https://xyz-example.cloud-region.cloud-provider.cloud.qdrant.io:6333',
apiKey: '<paste-your-api-key-here>',
});
use qdrant_client::Qdrant;
let client = Qdrant::from_url("https://xyz-example.cloud-region.cloud-provider.cloud.qdrant.io:6334")
.api_key("<paste-your-api-key-here>")
.build()
.unwrap();
import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
QdrantClient client =
new QdrantClient(
QdrantGrpcClient.newBuilder("xyz-example.cloud-region.cloud-provider.cloud.qdrant.io", 6334, true)
.withApiKey("<paste-your-api-key-here>")
.build());
using Qdrant.Client;
using Qdrant.Client.Grpc;
var client = new QdrantClient(
host: "xyz-example.cloud-region.cloud-provider.cloud.qdrant.io",
https: true,
apiKey: "<paste-your-api-key-here>"
);
import "github.com/qdrant/go-client/qdrant"
client, err := qdrant.NewClient(&qdrant.Config{
Host: "xyz-example.cloud-region.cloud-provider.cloud.qdrant.io",
Port: 6334,
APIKey: "<paste-your-api-key-here>",
UseTLS: true,
})
if err != nil { panic(err) }
Create a Collection
Qdrant stores vectors and associated metadata in collections. A collection requires vector parameters to be set during creation. In this case, let’s set up a collection with a dense vector for all-minilm-l6-v2 embeddings. For keyword search, create a full-text index with BM25 scoring enabled on the passage_text payload field. BM25 uses the Inverse Document Frequency to reduce the weight of common terms that appear in many documents while boosting the importance of rare terms that are more discriminative for retrieval. Qdrant computes these statistics from the indexed text, so you don’t need to generate embeddings for BM25.
from qdrant_client import QdrantClient, models
client.create_collection(
collection_name="{collection_name}",
vectors_config={
"dense_vector": models.VectorParams(
size=384,
distance=models.Distance.COSINE
)
}
)
client.create_payload_index(
collection_name="{collection_name}",
field_name="passage_text",
field_schema=models.TextIndexParams(
type=models.TextIndexType.TEXT,
scoring=True, # Enable BM25 scoring
),
)
import { QdrantClient } from "@qdrant/js-client-rest";
await client.createCollection("{collection_name}", {
vectors: {
dense_vector: { size: 384, distance: "Cosine" },
},
});
await client.createPayloadIndex("{collection_name}", {
field_name: "passage_text",
field_schema: {
type: "text",
scoring: true, // Enable BM25 scoring
},
});
use qdrant_client::qdrant::{
CreateCollectionBuilder, CreateFieldIndexCollectionBuilder, Distance, FieldType,
TextIndexParamsBuilder, TokenizerType, VectorParamsBuilder, VectorsConfigBuilder,
};
let mut vector_config = VectorsConfigBuilder::default();
vector_config.add_named_vector_params(
"dense_vector",
VectorParamsBuilder::new(384, Distance::Cosine),
);
client
.create_collection(
CreateCollectionBuilder::new("{collection_name}").vectors_config(vector_config),
)
.await?;
client
.create_field_index(
CreateFieldIndexCollectionBuilder::new(
"{collection_name}",
"passage_text",
FieldType::Text,
)
.field_index_params(
TextIndexParamsBuilder::new(TokenizerType::Word)
.bm25_scoring() // Enable BM25 scoring
.build(),
),
)
.await?;
import io.qdrant.client.grpc.Collections.CreateCollection;
import io.qdrant.client.grpc.Collections.Distance;
import io.qdrant.client.grpc.Collections.PayloadIndexParams;
import io.qdrant.client.grpc.Collections.PayloadSchemaType;
import io.qdrant.client.grpc.Collections.TextIndexParams;
import io.qdrant.client.grpc.Collections.TextScoringParams;
import io.qdrant.client.grpc.Collections.TextScoringType;
import io.qdrant.client.grpc.Collections.TokenizerType;
import io.qdrant.client.grpc.Collections.VectorParams;
import io.qdrant.client.grpc.Collections.VectorParamsMap;
import io.qdrant.client.grpc.Collections.VectorsConfig;
import java.util.Map;
client
.createCollectionAsync(
CreateCollection.newBuilder()
.setCollectionName("{collection_name}")
.setVectorsConfig(
VectorsConfig.newBuilder()
.setParamsMap(
VectorParamsMap.newBuilder()
.putAllMap(
Map.of(
"dense_vector",
VectorParams.newBuilder()
.setSize(384)
.setDistance(Distance.Cosine)
.build())))
.build())
.build())
.get();
client
.createPayloadIndexAsync(
"{collection_name}",
"passage_text",
PayloadSchemaType.Text,
PayloadIndexParams.newBuilder()
.setTextIndexParams(
TextIndexParams.newBuilder()
.setTokenizer(TokenizerType.Word)
// Enable BM25 scoring
.setScoring(
TextScoringParams.newBuilder()
.setType(TextScoringType.Bm25)
.build())
.build())
.build(),
null,
null,
null)
.get();
using Qdrant.Client;
using Qdrant.Client.Grpc;
await client.CreateCollectionAsync(
collectionName: "{collection_name}",
vectorsConfig: new VectorParamsMap
{
Map = {
["dense_vector"] = new VectorParams {
Size = 384, Distance = Distance.Cosine
},
}
}
);
await client.CreatePayloadIndexAsync(
collectionName: "{collection_name}",
fieldName: "passage_text",
schemaType: PayloadSchemaType.Text,
indexParams: new PayloadIndexParams
{
TextIndexParams = new TextIndexParams
{
Tokenizer = TokenizerType.Word,
// Enable BM25 scoring
Scoring = new TextScoringParams { Type = TextScoringType.Bm25 }
}
}
);
import (
"context"
"github.com/qdrant/go-client/qdrant"
)
client.CreateCollection(context.Background(), &qdrant.CreateCollection{
CollectionName: "{collection_name}",
VectorsConfig: qdrant.NewVectorsConfigMap(
map[string]*qdrant.VectorParams{
"dense_vector": {
Size: 384,
Distance: qdrant.Distance_Cosine,
},
}),
})
client.CreateFieldIndex(context.Background(), &qdrant.CreateFieldIndexCollection{
CollectionName: "{collection_name}",
FieldName: "passage_text",
FieldType: qdrant.FieldType_FieldTypeText.Enum(),
FieldIndexParams: qdrant.NewPayloadIndexParamsText(
&qdrant.TextIndexParams{
Tokenizer: qdrant.TokenizerType_Word,
// Enable BM25 scoring
Scoring: &qdrant.TextScoringParams{
Type: qdrant.TextScoringType_Bm25,
},
}),
})
Add Data
Now you can add sample documents, their associated metadata, and a point ID for each. Here’s a sample of the miriad/miriad-4.4M dataset:
| qa_id | paper_id | question | year | venue | specialty | passage_text |
|---|---|---|---|---|---|---|
| 87_18093431_2_1 | 18093431 | What are the leading reasons for the failure of stoma closure after laparoscopic low anterior resection? | 2017 | Annals of surgical treatment and research | General Surgery | Other studies have also disclosed that the rate of stoma rev… |
| 87_19380024_5_1 | 19380024 | What are some considerations when deciding whether to perform salvage surgery for aggressive tumors in critical neurovascular structures? | 2016 | Head & neck | Neurology | However, salvage surgery should not be routinely performed i… |
| 87_80622582_1_1 | 80622582 | How does the pedicled anterolateral thigh (ALT) flap compare to other flaps in terms of advantages and donor site morbidity? | 2017 | International Microsurgery Journal | General Surgery | When the ALT flap was tunnelled through the muscular compart… |
We won’t ingest all the entries from the dataset, but for demo purposes, just take a random sample of 100 distinct passages:
from qdrant_client import QdrantClient
from qdrant_client.http.models import PointStruct, Document
from datasets import load_dataset
dense_model = "sentence-transformers/all-minilm-l6-v2"
ds = (
load_dataset("miriad/miriad-4.4M", split="train", streaming=True) # no full download
.shuffle(seed=42, buffer_size=1_000)
.filter(lambda row: row["qa_id"].endswith("_1")) # one question per passage
.take(100)
)
points = []
for idx, item in enumerate(ds):
passage = item["passage_text"]
point = PointStruct(
id=idx, # stable IDs: a rerun overwrites points instead of adding duplicates
payload=item, # includes passage_text, used for BM25
vector={
"dense_vector": Document(
text=passage,
model=dense_model
)
}
)
points.append(point)
client.upload_points(
collection_name="{collection_name}",
points=points,
batch_size=8,
wait=True, # return only after all points are stored, so the search sees them
)
import { QdrantClient } from "@qdrant/js-client-rest";
import { randomUUID } from "crypto";
const denseModel = "sentence-transformers/all-minilm-l6-v2";
// NOTE: loadDataset is a user-defined function.
// Implement it to return 100 random rows with distinct passages, as the Python version does.
const dataset = loadDataset("miriad/miriad-4.4M", "train");
const points = dataset.map((item) => {
const passage = item.passage_text;
return {
id: randomUUID().toString(),
vector: {
dense_vector: {
text: passage,
model: denseModel,
},
},
payload: {
passage_text: passage, // used for BM25
question: item.question,
},
};
});
await client.upsert("{collection_name}", { points });
use qdrant_client::qdrant::{
Document, NamedVectors, PointStruct, UpsertPointsBuilder,
};
use qdrant_client::Payload;
use uuid::Uuid;
let dense_model = "sentence-transformers/all-minilm-l6-v2";
// NOTE: load_dataset is a user-defined function.
// Implement it to return 100 random rows with distinct passages, as the Python version does.
let dataset: Vec<_> = load_dataset("miriad/miriad-4.4M", "train");
let points: Vec<PointStruct> = dataset
.iter()
.map(|item| {
let passage = item["passage_text"].as_str().unwrap();
let vectors = NamedVectors::default().add_vector(
"dense_vector",
Document::new(passage, dense_model),
);
// The payload includes passage_text, used for BM25
let payload = Payload::try_from(item.clone()).unwrap();
PointStruct::new(Uuid::new_v4().to_string(), vectors, payload)
})
.collect();
client
.upsert_points(UpsertPointsBuilder::new("{collection_name}", points))
.await?;
import static io.qdrant.client.PointIdFactory.id;
import static io.qdrant.client.ValueFactory.value;
import static io.qdrant.client.VectorFactory.vector;
import static io.qdrant.client.VectorsFactory.namedVectors;
import io.qdrant.client.grpc.Points.Document;
import io.qdrant.client.grpc.Points.PointStruct;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
String denseModel = "sentence-transformers/all-minilm-l6-v2";
// NOTE: loadDataset is a user-defined function.
// Implement it to return 100 random rows with distinct passages, as the Python version does.
List<Map<String, String>> dataset = loadDataset("miriad/miriad-4.4M", "train");
List<PointStruct> points = new ArrayList<>();
for (Map<String, String> item : dataset) {
String passage = item.get("passage_text");
PointStruct point =
PointStruct.newBuilder()
.setId(id(UUID.randomUUID()))
.setVectors(
namedVectors(
Map.of(
"dense_vector",
vector(
Document.newBuilder().setText(passage).setModel(denseModel).build()))))
// Used for BM25
.putAllPayload(
Map.of(
"passage_text", value(passage),
"question", value(item.get("question"))))
.build();
points.add(point);
}
client.upsertAsync("{collection_name}", points).get();
using Qdrant.Client;
using Qdrant.Client.Grpc;
var denseModel = "sentence-transformers/all-minilm-l6-v2";
// NOTE: LoadDataset is a user-defined function.
// Implement it to return 100 random rows with distinct passages, as the Python version does.
var dataset = LoadDataset("miriad/miriad-4.4M", "train");
var points = new List<PointStruct>();
foreach (var item in dataset)
{
var passage = item["passage_text"].ToString();
var point = new PointStruct
{
Id = Guid.NewGuid(),
Vectors = new Dictionary<string, Vector>
{
["dense_vector"] = new Document
{
Text = passage,
Model = denseModel
}
},
// Used for BM25
Payload = { ["passage_text"] = passage, ["question"] = item["question"].ToString() }
};
points.Add(point);
}
await client.UpsertAsync(
collectionName: "{collection_name}",
points: points
);
import (
"context"
"github.com/google/uuid"
"github.com/qdrant/go-client/qdrant"
)
denseModel := "sentence-transformers/all-minilm-l6-v2"
// NOTE: loadDataset is a user-defined function.
// Implement it to return 100 random rows with distinct passages, as the Python version does.
dataset := loadDataset("miriad/miriad-4.4M", "train")
points := make([]*qdrant.PointStruct, 0, 100)
for _, item := range dataset {
passage := item["passage_text"]
point := &qdrant.PointStruct{
Id: qdrant.NewID(uuid.New().String()),
Vectors: qdrant.NewVectorsMap(map[string]*qdrant.Vector{
"dense_vector": qdrant.NewVectorDocument(&qdrant.Document{
Text: passage,
Model: denseModel,
}),
}),
// Used for BM25
Payload: qdrant.NewValueMap(map[string]any{
"passage_text": passage,
"question": item["question"],
}),
}
points = append(points, point)
}
_, err = client.Upsert(context.Background(), &qdrant.UpsertPoints{
CollectionName: "{collection_name}",
Points: points,
})
if err != nil {
panic(err)
}
Set Up Input Query
Create a sample query:
query_text = "Why does stoma closure fail?"
const queryText = "Why does stoma closure fail?";
let query_text = "Why does stoma closure fail?";
String queryText = "Why does stoma closure fail?";
var queryText = "Why does stoma closure fail?";
queryText := "Why does stoma closure fail?"
Run Hybrid Search
The query runs a dense prefetch and a BM25 prefetch, then fuses both result lists with Reciprocal Rank Fusion.
from qdrant_client import QdrantClient, models
results = client.query_points(
collection_name="{collection_name}",
prefetch=[
models.Prefetch(
query=models.Document(
text=query_text,
model=dense_model
),
using="dense_vector",
limit=5
),
models.Prefetch(
query=models.TextQuery(text=query_text),
using="passage_text",
limit=5
)
],
query=models.FusionQuery(fusion=models.Fusion.RRF),
limit=5,
with_payload=True
)
for point in results.points:
print(f"{point.score:.3f} {point.payload['question'].strip()}")
import { QdrantClient } from "@qdrant/js-client-rest";
const results = await client.query("{collection_name}", {
prefetch: [
{
query: {
text: queryText,
model: denseModel,
},
using: "dense_vector",
limit: 5,
},
{
query: {
text: queryText,
},
using: "passage_text",
limit: 5,
},
],
query: {
fusion: "rrf",
},
limit: 5,
with_payload: true,
});
for (const point of results.points) {
console.log(`${point.score.toFixed(3)} ${String(point.payload?.question).trim()}`);
}
use anyhow::Context;
use qdrant_client::qdrant::{Document, Fusion, PrefetchQueryBuilder, Query, QueryPointsBuilder};
let dense_prefetch = PrefetchQueryBuilder::default()
.query(Query::new_nearest(Document::new(query_text, dense_model)))
.using("dense_vector")
.limit(5u64)
.build();
let bm25_prefetch = PrefetchQueryBuilder::default()
.query(Query::new_text(query_text))
.using("passage_text")
.limit(5u64)
.build();
let query_request = QueryPointsBuilder::new("{collection_name}")
.add_prefetch(dense_prefetch)
.add_prefetch(bm25_prefetch)
.query(Query::new_fusion(Fusion::Rrf))
.limit(5)
.with_payload(true)
.build();
let results = client.query(query_request).await?;
for point in results.result {
let question = point
.payload
.get("question")
.and_then(|v| v.as_str())
.context("point has no question in its payload")?;
println!("{:.3} {}", point.score, question.trim());
}
import static io.qdrant.client.QueryFactory.fusion;
import static io.qdrant.client.QueryFactory.nearest;
import static io.qdrant.client.WithPayloadSelectorFactory.enable;
import io.qdrant.client.grpc.Points.Document;
import io.qdrant.client.grpc.Points.Fusion;
import io.qdrant.client.grpc.Points.PrefetchQuery;
import io.qdrant.client.grpc.Points.Query;
import io.qdrant.client.grpc.Points.QueryPoints;
import io.qdrant.client.grpc.Points.ScoredPoint;
import io.qdrant.client.grpc.Points.TextQuery;
import java.util.List;
PrefetchQuery densePrefetch =
PrefetchQuery.newBuilder()
.setQuery(
nearest(Document.newBuilder().setText(queryText).setModel(denseModel).build()))
.setUsing("dense_vector")
.setLimit(5)
.build();
PrefetchQuery bm25Prefetch =
PrefetchQuery.newBuilder()
.setQuery(
Query.newBuilder()
.setText(TextQuery.newBuilder().setQuery(queryText).build())
.build())
.setUsing("passage_text")
.setLimit(5)
.build();
QueryPoints request =
QueryPoints.newBuilder()
.setCollectionName("{collection_name}")
.addPrefetch(densePrefetch)
.addPrefetch(bm25Prefetch)
.setQuery(fusion(Fusion.RRF))
.setLimit(5)
.setWithPayload(enable(true))
.build();
List<ScoredPoint> results = client.queryAsync(request).get();
for (ScoredPoint point : results) {
String question = point.getPayloadMap().get("question").getStringValue();
System.out.printf("%.3f %s%n", point.getScore(), question.strip());
}
using Qdrant.Client;
using Qdrant.Client.Grpc;
var results = await client.QueryAsync(
collectionName: "{collection_name}", prefetch: new List <PrefetchQuery> {
new() {
Query = new Query { Text = new TextQuery { Query = queryText } },
Using = "passage_text",
Limit = 5
},
new() {
Query = new Document {
Text = queryText,
Model = denseModel
},
Using = "dense_vector",
Limit = 5
}
},
query: Fusion.Rrf,
payloadSelector: true,
limit: 5
);
foreach (var point in results)
{
var question = point.Payload["question"].StringValue;
Console.WriteLine($"{point.Score:F3} {question.Trim()}");
}
import (
"context"
"fmt"
"strings"
"github.com/qdrant/go-client/qdrant"
)
prefetch := []*qdrant.PrefetchQuery{
{
Query: qdrant.NewQueryText(&qdrant.TextQuery{Query: queryText}),
Using: qdrant.PtrOf("passage_text"),
Limit: qdrant.PtrOf(uint64(5)),
},
{
Query: qdrant.NewQueryDocument(&qdrant.Document{
Text: queryText,
Model: denseModel,
}),
Using: qdrant.PtrOf("dense_vector"),
Limit: qdrant.PtrOf(uint64(5)),
},
}
results, err := client.Query(context.Background(), &qdrant.QueryPoints{
CollectionName: "{collection_name}",
Prefetch: prefetch,
Query: qdrant.NewQueryFusion(qdrant.Fusion_RRF),
Limit: qdrant.PtrOf(uint64(5)),
WithPayload: qdrant.NewWithPayload(true),
})
if err != nil {
panic(err)
}
for _, point := range results {
question := point.Payload["question"].GetStringValue()
fmt.Printf("%.3f %s\n", point.Score, strings.TrimSpace(question))
}
The search returns the five results with the highest RRF scores. Each line shows the score and the dataset question paired with the passage. Only the first result is about stoma closure:
1.000 What are the leading reasons for the failure of stoma closure after laparoscopic low anterior resection?
0.500 What are some considerations when deciding whether to perform salvage surgery for aggressive tumors in critical neurovascular structures?
0.333 What are the potential reasons for the elevated risks of second ovarian and kidney cancers found after follow-up within one year of endometrial cancer diagnosis?
0.250 How do leukocytes and microparticles contribute to the transfer of tissue factor (TF) between different cell types in thrombosis?
0.250 What are the benefits of a multidisciplinary team approach in the management of patients with endometriosis?
RRF ignores the raw scores and looks only at where each result lands in the two lists. A result in position n of a list earns 1/(n + 1) from that list, and the earnings from both lists add up:
| Result | Dense position | BM25 position | RRF score |
|---|---|---|---|
| Stoma closure failure | 1 | 1 | 1/2 + 1/2 = 1.000 |
| Salvage surgery | 5 | 2 | 1/6 + 1/3 = 0.500 |
| Second ovarian and kidney cancers | 2 | not in top 5 | 1/3 = 0.333 |
| Leukocytes and tissue factor | not in top 5 | 3 | 1/4 = 0.250 |
| Endometriosis team approach | 3 | not in top 5 | 1/4 = 0.250 |
The salvage surgery passage is only fifth for the dense model, but BM25 ranks it second, so it beats passages that only one of the two methods found. The last two results have the same score, so they can be returned in either order.
Clean Up
Delete the collection when you no longer need it:
client.delete_collection(collection_name="{collection_name}")
client.deleteCollection("{collection_name}");
client.delete_collection("{collection_name}").await?;
client.deleteCollectionAsync("{collection_name}").get();
await client.DeleteCollectionAsync("{collection_name}");
import "context"
client.DeleteCollection(context.Background(), "{collection_name}")
curl -X DELETE http://localhost:6333/collections/{collection_name}
DELETE /collections/{collection_name}
Next Steps
Hybrid search result quality can be improved by reranking the results using a more expensive but higher quality model. Learn more in the hybrid search with reranking tutorial.