Combining Semantic and Lexical Search using Hybrid Search
Hybrid search enables you to combine semantic and lexical search in a single query, returning results that match the semantic meaning, the exact keywords, or both. This is useful when you don’t know whether the user is looking for a specific keyword or a semantically similar document. For example, when a user searches for “time travel”, semantic search finds books whose descriptions are about the concept of time travel, even if they use different words, while lexical search finds books with those exact words in the title. Hybrid queries enable you to return results for both cases in a single query.
For example, you can combine a dense vector for semantic search with BM25 full-text search on a payload field for lexical search. To do so, configure a dense vector, and create a full-text index with BM25 scoring enabled on the payload field you want to search:
PUT /collections/books
{
"vectors": {
"description-dense": {
"size": 384,
"distance": "Cosine"
}
}
}
PUT /collections/books/index?wait=true
{
"field_name": "title",
"field_schema": {
"type": "text",
"scoring": true
}
}
from qdrant_client import QdrantClient, models
client.create_collection(
collection_name="books",
vectors_config={
"description-dense": models.VectorParams(size=384, distance=models.Distance.COSINE)
},
)
client.create_payload_index(
collection_name="books",
field_name="title",
field_schema=models.TextIndexParams(
type=models.TextIndexType.TEXT,
scoring=True,
),
)
import { QdrantClient } from "@qdrant/js-client-rest";
await client.createCollection("books", {
vectors: {
"description-dense": { size: 384, distance: "Cosine" },
},
});
await client.createPayloadIndex("books", {
field_name: "title",
field_schema: {
type: "text",
scoring: true,
},
});
use qdrant_client::Qdrant;
use qdrant_client::qdrant::{
CreateCollectionBuilder, CreateFieldIndexCollectionBuilder, Distance, FieldType,
TextIndexParamsBuilder, TokenizerType, VectorParamsBuilder, VectorsConfigBuilder,
};
let mut vectors = VectorsConfigBuilder::default();
vectors.add_named_vector_params(
"description-dense",
VectorParamsBuilder::new(384, Distance::Cosine),
);
client
.create_collection(CreateCollectionBuilder::new("books").vectors_config(vectors))
.await?;
client
.create_field_index(
CreateFieldIndexCollectionBuilder::new("books", "title", FieldType::Text)
.field_index_params(
TextIndexParamsBuilder::new(TokenizerType::Word)
.bm25_scoring()
.build(),
),
)
.await?;
import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Collections.*;
QdrantClient client =
client
.createCollectionAsync(
CreateCollection.newBuilder()
.setCollectionName("books")
.setVectorsConfig(
VectorsConfig.newBuilder()
.setParamsMap(
VectorParamsMap.newBuilder()
.putMap(
"description-dense",
VectorParams.newBuilder()
.setSize(384)
.setDistance(Distance.Cosine)
.build())
.build())
.build())
.build())
.get();
client
.createPayloadIndexAsync(
"books",
"title",
PayloadSchemaType.Text,
PayloadIndexParams.newBuilder()
.setTextIndexParams(
TextIndexParams.newBuilder()
.setTokenizer(TokenizerType.Word)
.setScoring(
TextScoringParams.newBuilder().setType(TextScoringType.Bm25).build())
.build())
.build(),
null,
null,
null)
.get();
using Qdrant.Client;
using Qdrant.Client.Grpc;
await client.CreateCollectionAsync(
collectionName: "books",
vectorsConfig: new VectorParamsMap
{
Map =
{
["description-dense"] = new VectorParams
{
Size = 384,
Distance = Distance.Cosine,
},
},
}
);
await client.CreatePayloadIndexAsync(
collectionName: "books",
fieldName: "title",
schemaType: PayloadSchemaType.Text,
indexParams: new PayloadIndexParams
{
TextIndexParams = new TextIndexParams
{
Tokenizer = TokenizerType.Word,
Scoring = new TextScoringParams { Type = TextScoringType.Bm25 },
},
}
);
import (
"context"
"github.com/qdrant/go-client/qdrant"
)
client.CreateCollection(context.Background(), &qdrant.CreateCollection{
CollectionName: "books",
VectorsConfig: qdrant.NewVectorsConfigMap(
map[string]*qdrant.VectorParams{
"description-dense": {Size: 384, Distance: qdrant.Distance_Cosine},
}),
})
client.CreateFieldIndex(context.Background(), &qdrant.CreateFieldIndexCollection{
CollectionName: "books",
FieldName: "title",
FieldType: qdrant.FieldType_FieldTypeText.Enum(),
FieldIndexParams: qdrant.NewPayloadIndexParamsText(
&qdrant.TextIndexParams{
Tokenizer: qdrant.TokenizerType_Word,
Scoring: &qdrant.TextScoringParams{
Type: qdrant.TextScoringType_Bm25,
},
}),
})
from qdrant_edge import (
Distance,
EdgeConfig,
EdgeShard,
EdgeVectorParams,
TextIndexParams,
UpdateOperation,
)
edge_shard = EdgeShard.create(
"./shard",
EdgeConfig(
vectors={
"description-dense": EdgeVectorParams(size=384, distance=Distance.Cosine),
},
),
)
edge_shard.update(UpdateOperation.create_field_index(
"title",
TextIndexParams(scoring=True),
))
use std::path::Path;
use qdrant_edge::*;
let config = EdgeConfigBuilder::new()
.vector(
"description-dense",
EdgeVectorParamsBuilder::new(384, Distance::Cosine).build(),
)
.build();
let edge_shard = EdgeShard::new(Path::new("./shard"), config)?;
edge_shard.update(UpdateOperation::FieldIndexOperation(
FieldIndexOperations::CreateIndex(CreateIndex {
field_name: "title".try_into().unwrap(),
field_schema: Some(PayloadFieldSchema::FieldParams(PayloadSchemaParams::Text(
TextIndexParamsBuilder::new()
.scoring(TextScoringParams::default())
.build(),
))),
}),
))?;
After ingesting data with both the dense vector and the payload field, you can use the prefetch feature to run both semantic and lexical queries in a single request. The results of both queries are then combined using a fusion method like Reciprocal Rank Fusion (RRF).
POST /collections/books/points/query
{
"prefetch": [
{
"query": {
"text": "time travel",
"model": "sentence-transformers/all-minilm-l6-v2"
},
"using": "description-dense",
"score_threshold": 0.5
},
{
"query": {
"text": "time travel"
},
"using": "title"
}
],
"query": {
"fusion": "rrf"
},
"limit": 10,
"with_payload": true
}
from qdrant_client import QdrantClient, models
client = QdrantClient(
url="https://xyz-example.qdrant.io:6333",
api_key="<your-api-key>",
cloud_inference=True,
)
client.query_points(
collection_name="books",
prefetch=[
models.Prefetch(
query=models.Document(
text="time travel",
model="sentence-transformers/all-minilm-l6-v2",
),
using="description-dense",
score_threshold=0.5,
),
models.Prefetch(
query=models.TextQuery(text="time travel"),
using="title",
),
],
query=models.FusionQuery(fusion=models.Fusion.RRF),
limit=10,
with_payload=True,
)
import { QdrantClient } from "@qdrant/js-client-rest";
client.query("books", {
prefetch: [
{
query: { text: "time travel", model: "sentence-transformers/all-minilm-l6-v2" },
using: "description-dense",
score_threshold: 0.5,
},
{
query: { text: "time travel" },
using: "title",
},
],
query: { fusion: "rrf" },
limit: 10,
with_payload: true,
});
use qdrant_client::Qdrant;
use qdrant_client::qdrant::{Document, Fusion, PrefetchQueryBuilder, Query, QueryPointsBuilder};
let dense_prefetch = PrefetchQueryBuilder::default()
.query(Query::new_nearest(Document::new(
"time travel",
"sentence-transformers/all-minilm-l6-v2",
)))
.using("description-dense")
.score_threshold(0.5)
.build();
let bm25_prefetch = PrefetchQueryBuilder::default()
.query(Query::new_text("time travel"))
.using("title")
.build();
client
.query(
QueryPointsBuilder::new("books")
.add_prefetch(dense_prefetch)
.add_prefetch(bm25_prefetch)
.query(Query::new_fusion(Fusion::Rrf))
.limit(10)
.with_payload(true)
.build(),
)
.await?;
import static io.qdrant.client.QueryFactory.nearest;
import static io.qdrant.client.WithPayloadSelectorFactory.enable;
import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Points.*;
QdrantClient client =
PrefetchQuery densePrefetch =
PrefetchQuery.newBuilder()
.setUsing("description-dense")
.setScoreThreshold(0.5f)
.setQuery(
nearest(
Document.newBuilder()
.setText("time travel")
.setModel("sentence-transformers/all-minilm-l6-v2")
.build()))
.build();
PrefetchQuery bm25Prefetch =
PrefetchQuery.newBuilder()
.setUsing("title")
.setQuery(
Query.newBuilder()
.setText(TextQuery.newBuilder().setQuery("time travel").build())
.build())
.build();
client
.queryAsync(
QueryPoints.newBuilder()
.setCollectionName("books")
.addPrefetch(densePrefetch)
.addPrefetch(bm25Prefetch)
.setQuery(Query.newBuilder().setFusion(Fusion.RRF).build())
.setLimit(10)
.setWithPayload(enable(true))
.build())
.get();
using Qdrant.Client;
using Qdrant.Client.Grpc;
await client.QueryAsync(
collectionName: "books",
prefetch: new List<PrefetchQuery>
{
new()
{
Using = "description-dense",
Query = new Document
{
Text = "time travel",
Model = "sentence-transformers/all-minilm-l6-v2",
},
ScoreThreshold = 0.5f,
},
new()
{
Using = "title",
Query = new Query { Text = new TextQuery { Query = "time travel" } },
},
},
query: Fusion.Rrf,
payloadSelector: true,
limit: 10
);
import (
"context"
"github.com/qdrant/go-client/qdrant"
)
client.Query(context.Background(), &qdrant.QueryPoints{
CollectionName: "books",
Prefetch: []*qdrant.PrefetchQuery{
{
Using: qdrant.PtrOf("description-dense"),
Query: qdrant.NewQueryDocument(&qdrant.Document{
Text: "time travel",
Model: "sentence-transformers/all-minilm-l6-v2",
}),
ScoreThreshold: qdrant.PtrOf(float32(0.5)),
},
{
Using: qdrant.PtrOf("title"),
Query: qdrant.NewQueryText(&qdrant.TextQuery{Query: "time travel"}),
},
},
Query: qdrant.NewQueryFusion(qdrant.Fusion_RRF),
WithPayload: qdrant.NewWithPayload(true),
Limit: qdrant.PtrOf(uint64(10)),
})
from fastembed import TextEmbedding
from qdrant_edge import Fusion, Prefetch, Query, QueryRequest, TextQuery
model = TextEmbedding("sentence-transformers/all-MiniLM-L6-v2")
dense_vector = list(model.query_embed("time travel"))[0].tolist()
results = edge_shard.query(QueryRequest(
prefetches=[
Prefetch(
query=Query.Nearest(dense_vector, using="description-dense"),
limit=10,
score_threshold=0.5,
),
Prefetch(
query=TextQuery("title", "time travel"),
limit=10,
),
],
query=Fusion.Rrf(k=2),
limit=10,
with_payload=True,
))
use qdrant_edge::*;
let dense_vector: Vec<f32> = vec![0.01, 0.45, 0.67]; // <-- all-MiniLM-L6-v2 embedding of "time travel"
let results = edge_shard.query(
QueryRequestBuilder::new(10)
.add_prefetch(
PrefetchBuilder::new(10)
.query(ScoringQuery::Vector(QueryEnum::Nearest(NamedQuery {
query: dense_vector.into(),
using: Some("description-dense".to_string()),
})))
.score_threshold(0.5)
.build(),
)
.add_prefetch(
PrefetchBuilder::new(10)
.query(TextQueryBuilder::new("title".try_into().unwrap(), "time travel").build())
.build(),
)
.query(ScoringQuery::Fusion(Fusion::Rrf { k: 2, weights: None }))
.with_payload(WithPayloadInterface::Bool(true))
.build(),
)?;
The semantic query returns books whose descriptions are about time travel, and the BM25 query returns books with “time” or “travel” in the title. Books that appear in both result lists rank highest after fusion. The score_threshold for the semantic query prevents weak semantic matches from being returned (0.5 is just an example threshold; you need to tune what a good threshold is for your data and model). If only one of the queries finds matches, the results of that query are returned.
You are not limited to prefetching just two queries. Examples include, but are not limited to:
- Fuse multiple lexical queries across the
titleandauthorfields alongside a semantic query to achieve a comprehensive search across all data. - Prefetch using BM25, dense vectors, or filters, and rescore with dense vectors or BM25.
- Prefetch with dense vectors and BM25, and rerank using late interaction embeddings.
- Combine dense vectors with learned sparse vectors, such as SPLADE++ or miniCOIL, instead of BM25.