Read one level up: https://search.qdrant.tech/md/documentation/tutorials-basics

> Explore Qdrant's agent skills catalog at https://skills.qdrant.tech/
> Search the documentation at https://skills.qdrant.tech/search?query=your+query+here
> Use this file for an index of the main pages: https://qdrant.tech/llms.txt


<link rel="stylesheet" href="/documentation/examples/reranking-hybrid-search/figures.css">

# Qdrant Hybrid Search with Reranking

| Time: 40 min | Level: Intermediate |
| --- | ----------- |

Reranking is a powerful technique for improving search precision: rather than running an expensive model over your entire corpus, you apply it to a smaller set of candidates already retrieved by a faster method. This keeps latency low while surfacing the most relevant results.

Reranking pairs especially well with [hybrid search](https://qdrant.tech/documentation/search/hybrid-queries/index.md), which casts a wide retrieval net, maximizing recall across several retrieval paths. Reranking can sort the hybrid search results with a deeper relevance signal. A [late interaction model](https://qdrant.tech/course/multi-vector-search/module-1/late-interaction-basics/index.md), for instance, represents both query and document as multiple vectors, enabling more nuanced term-level comparisons than a single embedding can capture.

In this tutorial, you'll learn how to build a hybrid search engine that uses dense embeddings for semantic search, [BM25 full-text search](https://qdrant.tech/documentation/search/text-search/full-text-search/index.md#bm25) for keyword search, and late interaction embeddings for reranking. The result is a powerful search engine that delivers highly relevant results by combining the strengths of different retrieval methods.

You'll use [Qdrant Cloud Inference](https://qdrant.tech/documentation/inference/cloud-inference/index.md) to generate vector embeddings. The two embedding models used in this tutorial (dense and late interaction) are available free of charge on Qdrant Cloud. If you prefer to manage your own embedding infrastructure, you can apply the same principles, but you will need to adapt the code examples to use your embedding service.

## Overview

Let's start by breaking down the architecture:

### Ingestion Stage

<figure class="hybrid-reranking-figure">
  <picture>
    <source media="(max-width: 600px)" srcset="/documentation/examples/reranking-hybrid-search/image3.mobile.svg" width="320" height="560">
    <img src="/documentation/examples/reranking-hybrid-search/image3.svg" alt="A document produces a dense vector and multiple late interaction vectors, and its terms are added to a full-text index. All three representations are stored in the Qdrant search engine." width="800" height="400" loading="lazy">
  </picture>
  <picture class="hybrid-reranking-figure__dark">
    <source media="(max-width: 600px)" srcset="/documentation/examples/reranking-hybrid-search/image3.mobile.dark.svg" width="320" height="560">
    <img src="/documentation/examples/reranking-hybrid-search/image3.dark.svg" alt="A document produces a dense vector and multiple late interaction vectors, and its terms are added to a full-text index. All three representations are stored in the Qdrant search engine." width="800" height="400" loading="lazy">
  </picture>
</figure>

You'll start by ingesting a CSV file containing information about science fiction books. Each row is a **document**, corresponding to a book, with fields for the title, author, and description. Each book description will be indexed in three ways:
- **Dense embeddings** capture the deeper, semantic meanings behind the text.
- **A full-text index** supports more traditional, keyword-based methods. Specifically, you'll use [BM25](https://qdrant.tech/documentation/search/text-search/full-text-search/index.md#bm25), a probabilistic retrieval model. BM25 ranks documents based on how relevant their terms are to a given query, taking into account how often terms appear, document length, and how common the term is across all documents. It's perfect for keyword-heavy searches.
- **Late interaction embeddings** capture the nuanced interactions between query and document terms. You'll use a ColBERT model, which uses a two-stage approach. First, it generates contextualized embeddings for both queries and documents using BERT, and then it performs late interaction, matching those embeddings efficiently to fine-tune relevance. Learn more about late interaction models in the [Multivector Representations for Reranking in Qdrant](https://qdrant.tech/documentation/tutorials-search-engineering/using-multivector-representations/index.md) tutorial and the [Multi-Vector Search](https://qdrant.tech/course/multi-vector-search/index.md) course.

The data, including the embeddings and the full-text index, is stored in Qdrant, a **vector search engine**. This enables you to efficiently search, retrieve, and rerank your documents based on multiple layers of relevance.

### Retrieval Stage

<figure class="hybrid-reranking-figure">
  <picture>
    <source media="(max-width: 600px)" srcset="/documentation/examples/reranking-hybrid-search/image2.mobile.svg" width="320" height="616">
    <img src="/documentation/examples/reranking-hybrid-search/image2.svg" alt="A user query produces dense embeddings, query terms for BM25, and late interaction embeddings. Dense embeddings and BM25 feed hybrid search; its candidate documents and the late interaction embeddings feed reranking." width="800" height="400" loading="lazy">
  </picture>
  <picture class="hybrid-reranking-figure__dark">
    <source media="(max-width: 600px)" srcset="/documentation/examples/reranking-hybrid-search/image2.mobile.dark.svg" width="320" height="616">
    <img src="/documentation/examples/reranking-hybrid-search/image2.dark.svg" alt="A user query produces dense embeddings, query terms for BM25, and late interaction embeddings. Dense embeddings and BM25 feed hybrid search; its candidate documents and the late interaction embeddings feed reranking." width="800" height="400" loading="lazy">
  </picture>
</figure>

When a user submits a **query**, it is, just like documents, transformed into dense embeddings for semantic search and late interaction embeddings for precise reranking. For keyword search, Qdrant processes the query text with the same text processing as the full-text index.

Next, **hybrid search** uses dense embeddings and BM25 to find the most relevant documents. The dense embeddings are used for semantic search, while BM25 is used for keyword search. The resulting sets of documents are then **reranked** using late interaction embeddings, giving results that are not only relevant but also tuned to your query by prioritizing the documents that truly meet the user's intent.

## Implementation

### Install and Initialize the Qdrant Client

First, install the Qdrant client:

```python
pip install qdrant-client
```

> This snippet is also available in Typescript, Rust, Java, Csharp, and Go. See the [full snippet](https://qdrant.tech/documentation/snippets/install-client/index.md).


Next, initialize the client:

```python
from qdrant_client import QdrantClient

client = QdrantClient(
    url="https://xyz-example.eu-central.aws.cloud.qdrant.io:6333",
    api_key="<your-api-key>",
    cloud_inference=True,
)
```

> This snippet is also available in Typescript, Rust, Java, Csharp, and Go. See the [full snippet](https://qdrant.tech/documentation/snippets/tutorial-reranking-hybrid-search/client-connection/index.md).


### Models

Next, define the two embedding models. You'll use the 384-dimensional [`sentence-transformers/all-MiniLM-L6-v2`](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) model for dense embeddings and the 96-dimensional [`answerdotai/answerai-colbert-small-v1`](https://huggingface.co/answerdotai/answerai-colbert-small-v1) multivector model for late interaction embeddings. BM25 runs on a full-text index, so it doesn't need an embedding model.

```python
dense_embedding_model = "sentence-transformers/all-MiniLM-L6-v2"
late_interaction_embedding_model = "answerdotai/answerai-colbert-small-v1"
```

> This snippet is also available in Typescript, Rust, Java, Csharp, and Go. See the [full snippet](https://qdrant.tech/documentation/snippets/tutorial-reranking-hybrid-search/define-models/index.md).


### Create Collection

Create a new collection called `hybrid-search`, configured with two vector types and a full-text index:

- **Dense embeddings** (`dense`) using cosine distance for semantic comparisons.
- **Late interaction embeddings** (`multi`) using cosine distance, with a multivector configuration using the maximum similarity comparator. Note the `m=0` configuration to disable HNSW indexing. These embeddings are used for reranking, not ANN retrieval, so an HNSW index is not needed.
- **Full-text index** on the `description` payload field, with [BM25 scoring](https://qdrant.tech/documentation/manage-data/indexing/index.md#bm25-scoring) enabled for keyword-based searches. The index uses English [stemming](https://qdrant.tech/documentation/manage-data/indexing/index.md#stemmer) and [stopword removal](https://qdrant.tech/documentation/manage-data/indexing/index.md#stopwords), so a query for "travel" also matches "traveling".

```python
from qdrant_client.models import Distance, VectorParams, models

collection_name = "hybrid-search"

if client.collection_exists(collection_name=collection_name):
    client.delete_collection(collection_name=collection_name)

client.create_collection(
    collection_name,
    vectors_config={
        "dense": models.VectorParams(
            size=384,
            distance=models.Distance.COSINE,
        ),
        "multi": models.VectorParams(
            size=96,
            distance=models.Distance.COSINE,
            multivector_config=models.MultiVectorConfig(
                comparator=models.MultiVectorComparator.MAX_SIM,
            ),
            hnsw_config=models.HnswConfigDiff(m=0)  #  Disable HNSW for reranking
        ),
    },
)

client.create_payload_index(
    collection_name,
    field_name="description",
    field_schema=models.TextIndexParams(
        type=models.TextIndexType.TEXT,
        stemmer=models.SnowballParams(
            type=models.Snowball.SNOWBALL,
            language=models.SnowballLanguage.ENGLISH,
        ),
        stopwords=models.Language.ENGLISH,
        scoring=True,  # Enable BM25 scoring
    ),
)
```

> This snippet is also available in Typescript, Rust, Java, Csharp, and Go. See the [full snippet](https://qdrant.tech/documentation/snippets/tutorial-reranking-hybrid-search/create-collection/index.md).


### Ingest Data

Now you can load the sci-fi book descriptions from a CSV and insert them into the `hybrid-search` collection. With Cloud Inference, embeddings are computed server-side by wrapping the text in a `Document` object.

This code uses a helper function to stream and parse the CSV file:

<details><summary>Details</summary> 
```python
import csv
import urllib.request

def parse_csv(url):
    with urllib.request.urlopen(url) as response:
        reader = csv.DictReader(line.decode('utf-8') for line in response)
        yield from reader
```

> This snippet is also available in Typescript, Rust, Java, Csharp, and Go. See the [full snippet](https://qdrant.tech/documentation/snippets/tutorial-reranking-hybrid-search/parse-csv/index.md).

</details>

With the helper defined, upload the books:

```python
from qdrant_client.models import Document, PointStruct

csv_url = 'https://raw.githubusercontent.com/qdrant/examples/refs/heads/master/sci-fi-books/top_100_scifi_books_full.csv'

points = (
    PointStruct(
        id=idx,
        vector={
            "dense": Document(text=row['Description'], model=dense_embedding_model),
            "multi": Document(text=row['Description'], model=late_interaction_embedding_model),
        },
        payload={"title": row['Title'], "author": row['Author'], "description": row['Description']}
    )
    for idx, row in enumerate(parse_csv(csv_url))
)
client.upload_points(
    collection_name=collection_name,
    points=points,
    batch_size=25
)
```

> This snippet is also available in Typescript, Rust, Java, Csharp, and Go. See the [full snippet](https://qdrant.tech/documentation/snippets/tutorial-reranking-hybrid-search/ingest-data/index.md).


This code creates a point for each book, with two vector types and a payload containing the title, author, and description. Documents are uploaded to Qdrant in batches of 25, with Cloud Inference generating both embeddings on the fly. Qdrant adds each description to the full-text index as it's uploaded. In Production, the optimal batch size depends on your data and cluster, so you may want to experiment with different sizes for best performance.

### Retrieval

Before combining results, let's see how dense and BM25 retrieval perform individually.

For dense retrieval, wrap the query in a `Document` object so Cloud Inference computes the embeddings server-side.

**Dense retrieval** captures semantic meaning:

```python
import pprint

query = "time travel"

results = client.query_points(
    collection_name,
    query=models.Document(text=query, model=dense_embedding_model),
    using="dense",
    limit=10,
)

pprint.pp(results.points)
```

> This snippet is also available in Typescript, Rust, Java, Csharp, and Go. See the [full snippet](https://qdrant.tech/documentation/snippets/tutorial-reranking-hybrid-search/dense-retrieval/index.md).


Let's take a look at the top 5 results:

| Position | Title | Description |
|----------|-------|-------------|
| 1 | The Time Machine | A Victorian scientist travels far into the future to witness civilization's fate. |
| 2 | Slaughterhouse-Five | A nonlinear, time-tripping reflection on war and fate. |
| 3 | The Peripheral | Two timelines intersect through telepresence technology. |
| 4 | The Space Between Worlds | A multiverse traveler uncovers dangerous secrets across parallel Earths. |
| 5 | The Forever War | A soldier experiences extreme time dilation while fighting an interstellar war. |

Each of these books has a strong semantic connection to the concept of time travel, even if the exact phrase doesn't appear in the description.

**BM25 retrieval** focuses on keyword matches. Use a `text` query on the `description` field:

```python
results = client.query_points(
    collection_name,
    query=models.TextQuery(text=query),
    using="description",
    limit=10,
)

pprint.pp(results.points)
```

> This snippet is also available in Typescript, Rust, Java, Csharp, and Go. See the [full snippet](https://qdrant.tech/documentation/snippets/tutorial-reranking-hybrid-search/bm25-retrieval/index.md).


The top 5 results are:

| Position | Title | Description |
|----------|-------|-------------|
| 1 | Station Eleven | A traveling symphony roams a post-pandemic North America. |
| 2 | Slaughterhouse-Five | A nonlinear, time-tripping reflection on war and fate. |
| 3 | The Time Machine | A Victorian scientist travels far into the future to witness civilization's fate. |
| 4 | The Space Between Worlds | A multiverse traveler uncovers dangerous secrets across parallel Earths. |
| 5 | Hyperion | Travelers share haunting tales on a pilgrimage to confront the mysterious Shrike. |

The full-text index performs keyword matching with stemming. As a result, it returns books whose descriptions contain variants of the words "time" and "travel". For instance, "Station Eleven" and "Hyperion" mention "traveling" and "travelers" but aren't primarily about time travel.

**Hybrid search** can be used to prefetch the dense and BM25 results and next merge them using [Reciprocal Rank Fusion (RRF)](https://qdrant.tech/documentation/search/hybrid-queries/index.md#reciprocal-rank-fusion-rrf):

```python
prefetch = [
    models.Prefetch(
        query=models.Document(text=query, model=dense_embedding_model),
        using="dense",
        limit=20,
    ),
    models.Prefetch(
        query=models.TextQuery(text=query),
        using="description",
        limit=20,
    ),
]

results = client.query_points(
    collection_name,
    prefetch=prefetch,
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    with_payload=True,
    limit=10,
)

pprint.pp(results.points)
```

> This snippet is also available in Typescript, Rust, Java, Csharp, and Go. See the [full snippet](https://qdrant.tech/documentation/snippets/tutorial-reranking-hybrid-search/hybrid-search/index.md).


This runs two sub-queries in parallel: one using dense embeddings for semantic meaning, the other using BM25 for keyword matching. The prefetch step retrieves the top 20 candidates from each sub-query (dense and BM25) and fuses the ranked lists into a single result using RRF.

The results are a mix of books that are semantically relevant to time travel and those that contain the keywords, giving you a broader set of relevant documents. However, the ranking may not be optimal since, [by default, RRF treats both signals equally](https://qdrant.tech/documentation/search/hybrid-queries/index.md#weighted-rrf) and doesn't capture the nuanced interactions between query and document terms. For example, "Station Eleven" ranks highly because it has stronger keyword matches, even though it is not about time travel.

| Position | Title | Description |
|----------|-------|-------------|
| 1 | The Time Machine | A Victorian scientist travels far into the future to witness civilization's fate. |
| 2 | Slaughterhouse-Five | A nonlinear, time-tripping reflection on war and fate. |
| 3 | Station Eleven | A traveling symphony roams a post-pandemic North America. |
| 4 | The Space Between Worlds | A multiverse traveler uncovers dangerous secrets across parallel Earths. |
| 5 | The Forever War | A soldier experiences extreme time dilation while fighting an interstellar war. |

### Rerank

The hybrid search results can be reranked using late interaction embeddings for maximum precision. Instead of fusing with RRF, use the ColBERT multi-vector as the final ranking signal:

```python
prefetch = [
    models.Prefetch(
        query=models.Document(text=query, model=dense_embedding_model),
        using="dense",
        limit=20,
    ),
    models.Prefetch(
        query=models.TextQuery(text=query),
        using="description",
        limit=20,
    ),
]

results = client.query_points(
    collection_name,
    prefetch=prefetch,
    query=models.Document(text=query, model=late_interaction_embedding_model),
    using="multi",
    with_payload=True,
    limit=10,
)

pprint.pp(results.points)
```

> This snippet is also available in Typescript, Rust, Java, Csharp, and Go. See the [full snippet](https://qdrant.tech/documentation/snippets/tutorial-reranking-hybrid-search/rerank/index.md).


The prefetch step retrieves the top 20 candidates from each sub-query (dense and BM25), and the ColBERT late interaction model reranks the combined candidates to surface the most relevant results.

### Compare results

Let's compare the top 5 reranked results with their positions in the RRF results. Notice how some documents shift in rank based on their relevance according to the late interaction embeddings.

| Title | Description | Reranked | RRF rank  | Rank Change |
|-------|-------------| ---------|----------|-------------|
| Slaughterhouse-Five | A nonlinear, time-tripping reflection on war and fate. | 1 | 2 | Moved up |
| The Forever War | A soldier experiences extreme time dilation while fighting an interstellar war. | 2 | 5 | Moved up |
| Kindred | A modern Black woman is pulled back in time to the antebellum South. | 3 | 6 | Moved up |
| Spin | Earth is enclosed in a time-distorting barrier by unknown forces. | 4 | 7 | Moved up |
| The Light Brigade | Soldiers are turned into light to fight a war across space-time. | 5 | 10 | Moved up |

## Best Practices in Reranking

Reranking with late interaction models can dramatically improve the relevance of search results, especially when combined with hybrid search. Here are some best practices to keep in mind:

- **Continuous testing and monitoring**: regularly evaluate your hybrid search pipelines to avoid overfitting and make timely adjustments to maintain performance.
- **Balance relevance and cost**: Reranking can be computationally expensive, and late interaction embeddings require significant storage. Aim for a balance between relevance and cost. Simple fusion methods like RRF can be effective for many use cases, while late interaction models can be reserved for queries where precision is critical.

## Conclusion

Reranking with late interaction models is a powerful tool that boosts the relevance of search results, especially when combined with hybrid search methods. While it can add some latency due to its complexity, applying it to a smaller, pre-filtered subset of results ensures both speed and relevance.