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,
			},
		}),
})
Deployment type:

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)),
})
Deployment type:

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:

Was this page useful?

Thank you for your feedback! 🙏

We are sorry to hear that. 😔 You can edit this page on GitHub, or create a GitHub issue.