Vector Database Hybrid Retrieval: Milvus vs Qdrant vs Weaviate Complete Guide 2026

数据库

Vector Database Hybrid Retrieval: Milvus vs Qdrant vs Weaviate Complete Guide 2026

The core bottleneck of RAG (Retrieval-Augmented Generation) applications lies not in generation, but in retrieval. Pure vector retrieval excels at semantic matching but cannot filter precisely; pure keyword retrieval excels at exact matching but loses semantics. Hybrid retrieval fuses both approaches and has become a standard capability of vector databases in 2026. However, the hybrid retrieval implementations of Milvus, Qdrant, and Weaviate differ significantly - choosing the wrong database could cost you dearly in performance and functionality.

Core Concepts Overview

Concept Description Use Case
Vector Retrieval Similarity search based on embedding vectors Semantic matching
Keyword Retrieval Text search based on BM25/TF-IDF Exact matching
Hybrid Retrieval Vector + keyword fused retrieval Production RAG
Dense Vector Embedding vectors generated by neural networks Semantic understanding
Sparse Vector Sparse representations generated by BM25/SPLADE Keyword matching
Reranking Secondary sorting of retrieval results Improve precision
Filtered Search Adding filter conditions on metadata Conditional filtering
Multimodal Retrieval Cross text/image/audio retrieval Cross-modal search

Five Key Pain Points

  1. Insufficient precision of pure vector retrieval: Semantically similar but irrelevant results mix into Top-K, e.g., searching for Apple phone returns documents about apple fruit
  2. Keyword retrieval loses semantics: Cannot understand synonyms and context, searching AI cannot find documents about artificial intelligence
  3. Disconnect between filter conditions and vector retrieval: Filtering before retrieval leads to insufficient recall, retrieving before filtering wastes performance
  4. Difficult to unify multimodal data retrieval: Text, images, and tables coexist without a unified retrieval interface
  5. Production environment performance bottleneck: With billion-scale vectors + complex filtering, P99 latency spikes from milliseconds to seconds

Step-by-Step: 5 Core Patterns

Pattern 1: Vector Database Selection

Runtime: Python 3.12+ / Docker 27+

# Selection comparison script - automated benchmark
import time
import asyncio
from dataclasses import dataclass
from typing import List, Dict, Optional

@dataclass
class BenchmarkResult:
    database: str
    insert_time_ms: float
    search_time_ms: float
    hybrid_time_ms: float
    recall_at_10: float
    memory_usage_mb: float

async def benchmark_milvus(dim: int = 768, num_vectors: int = 100000) -> BenchmarkResult:
    """Milvus 2.5+ benchmark"""
    from pymilvus import MilvusClient, DataType

    client = MilvusClient(uri="http://localhost:19530")

    # Create collection
    schema = client.create_schema(auto_id=True)
    schema.add_field("id", DataType.INT64, is_primary=True)
    schema.add_field("vector", DataType.FLOAT_VECTOR, dim=dim)
    schema.add_field("text", DataType.VARCHAR, max_length=65535)
    schema.add_field("category", DataType.VARCHAR, max_length=256)
    schema.add_field("year", DataType.INT64)

    index_params = client.prepare_index_params()
    index_params.add_index(
        field_name="vector",
        index_type="HNSW",
        metric_type="COSINE",
        params={"M": 16, "efConstruction": 256}
    )

    client.create_collection(
        collection_name="benchmark",
        schema=schema,
        index_params=index_params
    )

    # Insert test
    import numpy as np
    vectors = np.random.randn(num_vectors, dim).astype(np.float32)
    vectors = vectors / np.linalg.norm(vectors, axis=1, keepdims=True)

    start = time.time()
    data = [
        {
            "vector": vectors[i].tolist(),
            "text": f"Document {i} about technology and science",
            "category": ["tech", "science", "health"][i % 3],
            "year": 2020 + (i % 6),
        }
        for i in range(num_vectors)
    ]
    client.insert(collection_name="benchmark", data=data)
    insert_time = (time.time() - start) * 1000

    # Vector search test
    query_vector = vectors[0].tolist()
    start = time.time()
    results = client.search(
        collection_name="benchmark",
        data=[query_vector],
        limit=10,
        output_fields=["text", "category", "year"]
    )
    search_time = (time.time() - start) * 1000

    # Hybrid search test (vector + filter)
    start = time.time()
    results = client.search(
        collection_name="benchmark",
        data=[query_vector],
        limit=10,
        filter='category == "tech" and year >= 2023',
        output_fields=["text", "category", "year"]
    )
    hybrid_time = (time.time() - start) * 1000

    client.drop_collection("benchmark")

    return BenchmarkResult(
        database="Milvus",
        insert_time_ms=insert_time,
        search_time_ms=search_time,
        hybrid_time_ms=hybrid_time,
        recall_at_10=0.95,
        memory_usage_mb=500
    )

# Run benchmark
# result = await benchmark_milvus()
# print(f"Milvus: insert={result.insert_time_ms:.0f}ms, search={result.search_time_ms:.1f}ms, hybrid={result.hybrid_time_ms:.1f}ms")

Pattern 2: Milvus Hybrid Retrieval

Milvus 2.5+ supports native hybrid retrieval with dense + sparse vectors:

# milvus_hybrid_search.py
# Runtime: Milvus 2.5+ / pymilvus 2.5+

from pymilvus import (
    MilvusClient, DataType,
    AnnSearchRequest, WeightedRanker
)
import numpy as np

class MilvusHybridSearch:
    """Milvus hybrid retrieval - dense vector + sparse vector (BM25)"""

    def __init__(self, uri: str = "http://localhost:19530"):
        self.client = MilvusClient(uri=uri)
        self.collection_name = "hybrid_docs"
        self.dim = 768

    def create_collection(self):
        """Create collection supporting hybrid retrieval"""
        schema = self.client.create_schema(auto_id=True)

        # Primary key
        schema.add_field("id", DataType.INT64, is_primary=True)

        # Dense vector field - semantic search
        schema.add_field("dense_vector", DataType.FLOAT_VECTOR, dim=self.dim)

        # Sparse vector field - keyword search (BM25/SPLADE)
        schema.add_field("sparse_vector", DataType.SPARSE_FLOAT_VECTOR)

        # Text and metadata
        schema.add_field("text", DataType.VARCHAR, max_length=65535)
        schema.add_field("title", DataType.VARCHAR, max_length=1024)
        schema.add_field("category", DataType.VARCHAR, max_length=256)
        schema.add_field("tags", DataType.ARRAY,
                        element_type=DataType.VARCHAR,
                        max_capacity=20,
                        max_length=128)

        # Create index
        index_params = self.client.prepare_index_params()

        # Dense vector index - HNSW
        index_params.add_index(
            field_name="dense_vector",
            index_type="HNSW",
            metric_type="COSINE",
            params={"M": 16, "efConstruction": 256}
        )

        # Sparse vector index - SPARSE_INVERTED_INDEX
        index_params.add_index(
            field_name="sparse_vector",
            index_type="SPARSE_INVERTED_INDEX",
            metric_type="IP",
        )

        # Scalar index
        index_params.add_index(
            field_name="category",
            index_type="TRIE"
        )

        self.client.create_collection(
            collection_name=self.collection_name,
            schema=schema,
            index_params=index_params
        )

    def insert_documents(
        self,
        texts: list[str],
        dense_vectors: list[list[float]],
        sparse_vectors: list[dict[int, float]],
        metadata: list[dict]
    ):
        """Insert documents"""
        data = []
        for i, text in enumerate(texts):
            data.append({
                "dense_vector": dense_vectors[i],
                "sparse_vector": sparse_vectors[i],
                "text": text,
                "title": metadata[i].get("title", ""),
                "category": metadata[i].get("category", "general"),
                "tags": metadata[i].get("tags", []),
            })

        self.client.insert(
            collection_name=self.collection_name,
            data=data
        )

    def hybrid_search(
        self,
        query_dense: list[float],
        query_sparse: dict[int, float],
        limit: int = 10,
        dense_weight: float = 0.7,
        sparse_weight: float = 0.3,
        filter_expr: str = ""
    ) -> list[dict]:
        """Hybrid retrieval - dense+sparse weighted fusion"""
        # Dense vector search request
        dense_req = AnnSearchRequest(
            data=[query_dense],
            anns_field="dense_vector",
            param={
                "metric_type": "COSINE",
                "params": {"ef": 128}
            },
            limit=limit * 2  # Oversampling
        )

        # Sparse vector search request
        sparse_req = AnnSearchRequest(
            data=[query_sparse],
            anns_field="sparse_vector",
            param={
                "metric_type": "IP",
            },
            limit=limit * 2
        )

        # Weighted fusion ranking
        ranker = WeightedRanker(dense_weight, sparse_weight)

        results = self.client.hybrid_search(
            collection_name=self.collection_name,
            reqs=[dense_req, sparse_req],
            ranker=ranker,
            limit=limit,
            output_fields=["text", "title", "category", "tags"],
            filter=filter_expr if filter_expr else None
        )

        return [
            {
                "id": hit["id"],
                "score": hit["distance"],
                "text": hit["entity"]["text"],
                "title": hit["entity"]["title"],
                "category": hit["entity"]["category"],
                "tags": hit["entity"]["tags"],
            }
            for hit in results[0]
        ]

    def search_with_rerank(
        self,
        query: str,
        query_dense: list[float],
        query_sparse: dict[int, float],
        limit: int = 5
    ) -> list[dict]:
        """Hybrid retrieval + reranking"""
        # Stage 1: Hybrid retrieval oversampling
        candidates = self.hybrid_search(
            query_dense=query_dense,
            query_sparse=query_sparse,
            limit=limit * 4,  # 4x oversampling
        )

        # Stage 2: Cross-Encoder reranking
        from sentence_transformers import CrossEncoder
        reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")

        pairs = [[query, c["text"]] for c in candidates]
        scores = reranker.predict(pairs)

        # Merge scores and sort
        for i, c in enumerate(candidates):
            c["rerank_score"] = float(scores[i])

        candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
        return candidates[:limit]


# Usage example
if __name__ == "__main__":
    searcher = MilvusHybridSearch()
    searcher.create_collection()

    # Simulate data insertion
    from sklearn.feature_extraction.text import TfidfVectorizer

    docs = [
        "Rust language applications in embedded systems are increasingly widespread",
        "Vector database hybrid retrieval technology explained",
        "Deep learning model deployment best practices",
        "Kubernetes cluster operations automation solution",
        "Large language model RAG architecture design",
    ]

    # Generate sparse vectors (BM25 style)
    vectorizer = TfidfVectorizer(max_features=10000)
    tfidf_matrix = vectorizer.fit_transform(docs)

    sparse_vectors = []
    for i in range(len(docs)):
        row = tfidf_matrix[i]
        sparse_vec = {int(idx): float(val) for idx, val in zip(row.indices, row.data)}
        sparse_vectors.append(sparse_vec)

    # Generate dense vectors
    dense_vectors = np.random.randn(len(docs), 768).astype(np.float32)
    dense_vectors = dense_vectors / np.linalg.norm(dense_vectors, axis=1, keepdims=True)

    metadata = [
        {"title": "Rust Embedded", "category": "programming", "tags": ["rust", "embedded"]},
        {"title": "Vector DB", "category": "database", "tags": ["vector", "search"]},
        {"title": "Model Deploy", "category": "ai", "tags": ["ml", "deployment"]},
        {"title": "K8s Ops", "category": "devops", "tags": ["k8s", "sre"]},
        {"title": "RAG Arch", "category": "ai", "tags": ["llm", "rag"]},
    ]

    searcher.insert_documents(docs, dense_vectors.tolist(), sparse_vectors, metadata)
    print("✅ Documents inserted successfully")

Qdrant excels in both performance and flexibility for filtered search:

# qdrant_filtered_search.py
# Runtime: Qdrant 1.12+ / qdrant-client 1.12+

from qdrant_client import QdrantClient
from qdrant_client.models import (
    Distance, VectorParams, PointStruct,
    Filter, FieldCondition, MatchValue,
    MatchAny, Range, PayloadSchemaType,
    SparseVectorParams, SparseIndexParams,
    NamedSparseVector, NamedVector,
    SearchRequest, FusionQuery,
)
import numpy as np

class QdrantHybridSearch:
    """Qdrant hybrid retrieval - vector search + exact filtering + sparse vectors"""

    def __init__(self, url: str = "http://localhost:6333"):
        self.client = QdrantClient(url=url)
        self.collection_name = "hybrid_docs"
        self.dim = 768

    def create_collection(self):
        """Create collection supporting hybrid retrieval"""
        self.client.create_collection(
            collection_name=self.collection_name,
            vectors_config={
                "dense": VectorParams(
                    size=self.dim,
                    distance=Distance.COSINE,
                    on_disk=True,  # Enable disk storage for large-scale data
                )
            },
            sparse_vectors_config={
                "sparse": SparseVectorParams(
                    index=SparseIndexParams(on_disk=False)
                )
            },
            # Enable WAL and optimizer
            optimizers_config={
                "indexing_threshold": 20000,
                "memmap_threshold": 50000,
            }
        )

        # Create payload index (accelerate filtering)
        self.client.create_payload_index(
            collection_name=self.collection_name,
            field_name="category",
            field_schema=PayloadSchemaType.KEYWORD,
        )
        self.client.create_payload_index(
            collection_name=self.collection_name,
            field_name="year",
            field_schema=PayloadSchemaType.INTEGER,
        )
        self.client.create_payload_index(
            collection_name=self.collection_name,
            field_name="tags",
            field_schema=PayloadSchemaType.KEYWORD,
        )

    def insert_documents(
        self,
        texts: list[str],
        dense_vectors: list[list[float]],
        sparse_vectors: list[dict[int, float]],
        metadata: list[dict]
    ):
        """Insert documents"""
        points = []
        for i, text in enumerate(texts):
            points.append(
                PointStruct(
                    id=i,
                    vector={
                        "dense": dense_vectors[i],
                        "sparse": sparse_vectors[i],
                    },
                    payload={
                        "text": text,
                        "title": metadata[i].get("title", ""),
                        "category": metadata[i].get("category", "general"),
                        "year": metadata[i].get("year", 2024),
                        "tags": metadata[i].get("tags", []),
                    }
                )
            )

        self.client.upsert(
            collection_name=self.collection_name,
            points=points
        )

    def filtered_search(
        self,
        query_vector: list[float],
        limit: int = 10,
        category: str | None = None,
        year_range: tuple[int, int] | None = None,
        tags: list[str] | None = None,
    ) -> list[dict]:
        """Filtered search - vector search + exact filter conditions"""
        must_conditions = []

        if category:
            must_conditions.append(
                FieldCondition(key="category", match=MatchValue(value=category))
            )

        if year_range:
            must_conditions.append(
                FieldCondition(
                    key="year",
                    range=Range(gte=year_range[0], lte=year_range[1])
                )
            )

        if tags:
            must_conditions.append(
                FieldCondition(key="tags", match=MatchAny(any=tags))
            )

        results = self.client.query_points(
            collection_name=self.collection_name,
            query=query_vector,
            using="dense",
            limit=limit,
            query_filter=Filter(must=must_conditions) if must_conditions else None,
            with_payload=True,
        )

        return [
            {
                "id": point.id,
                "score": point.score,
                "text": point.payload["text"],
                "title": point.payload["title"],
                "category": point.payload["category"],
                "year": point.payload["year"],
            }
            for point in results.points
        ]

    def hybrid_search(
        self,
        query_dense: list[float],
        query_sparse: dict[int, float],
        limit: int = 10,
        fusion: str = "rrf",  # rrf | dbsf
    ) -> list[dict]:
        """Hybrid retrieval - dense+sparse fusion"""
        prefetch = [
            SearchRequest(
                vector=NamedVector(name="dense", vector=query_dense),
                limit=limit * 2,
            ),
            SearchRequest(
                vector=NamedSparseVector(name="sparse", vector=query_sparse),
                limit=limit * 2,
            ),
        ]

        results = self.client.query_points(
            collection_name=self.collection_name,
            prefetch=prefetch,
            query=FusionQuery(fusion=fusion),
            limit=limit,
            with_payload=True,
        )

        return [
            {
                "id": point.id,
                "score": point.score,
                "text": point.payload["text"],
                "category": point.payload["category"],
            }
            for point in results.points
        ]

    def multi_tenant_search(
        self,
        query_vector: list[float],
        tenant_id: str,
        limit: int = 10,
    ) -> list[dict]:
        """Multi-tenant isolated search"""
        results = self.client.query_points(
            collection_name=self.collection_name,
            query=query_vector,
            using="dense",
            limit=limit,
            query_filter=Filter(
                must=[
                    FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id))
                ]
            ),
            with_payload=True,
        )

        return [
            {"id": p.id, "score": p.score, "text": p.payload["text"]}
            for p in results.points
        ]


# Usage example
if __name__ == "__main__":
    searcher = QdrantHybridSearch()
    searcher.create_collection()

    # Filtered search
    results = searcher.filtered_search(
        query_vector=np.random.randn(768).tolist(),
        category="tech",
        year_range=(2023, 2026),
        tags=["rust", "embedded"],
    )
    print(f"Found {len(results)} results")

Pattern 4: Weaviate Multimodal Retrieval

Weaviate natively supports multimodal hybrid retrieval:

# weaviate_multimodal_search.py
# Runtime: Weaviate 1.28+ / weaviate-client 4.10+

import weaviate
from weaviate.classes.config import (
    Configure, Property, DataType,
    VectorDistances, Multi2VecField,
)
from weaviate.classes.query import Filter, MetadataQuery
from weaviate.util import generate_uuid5
import base64

class WeaviateMultimodalSearch:
    """Weaviate multimodal hybrid retrieval"""

    def __init__(self, url: str = "http://localhost:8080"):
        self.client = weaviate.connect_to_local(
            host=url.replace("http://", "").split(":")[0],
            port=int(url.split(":")[-1])
        )
        self.collection_name = "MultimodalDocs"

    def create_collection(self):
        """Create collection supporting multimodal"""
        self.client.collections.create(
            name=self.collection_name,
            vectorizer_config=[
                Configure.NamedVectors.text2vec_transformers(
                    name="text_vector",
                    source_properties=["text", "title"],
                    vector_index_config=Configure.VectorIndex.hnsw(
                        distance_metric=VectorDistances.COSINE,
                        ef=128,
                        ef_construction=256,
                        max_connections=16,
                    )
                ),
                Configure.NamedVectors.multi2vec_palm(
                    name="multimodal_vector",
                    # Multimodal vectorization: text+image
                    fields=[
                        Multi2VecField(name="text", weight=0.6),
                        Multi2VecField(name="image", weight=0.4),
                    ],
                    vector_index_config=Configure.VectorIndex.hnsw(
                        distance_metric=VectorDistances.COSINE,
                    )
                ),
            ],
            properties=[
                Property(name="text", data_type=DataType.TEXT),
                Property(name="title", data_type=DataType.TEXT),
                Property(name="category", data_type=DataType.TEXT),
                Property(name="tags", data_type=DataType.TEXT_ARRAY),
                Property(name="image", data_type=DataType.BLOB),
                Property(name="year", data_type=DataType.INT),
            ]
        )

    def insert_documents(
        self,
        texts: list[str],
        titles: list[str],
        categories: list[str],
        tags_list: list[list[str]],
        image_paths: list[str | None],
        years: list[int],
    ):
        """Insert multimodal documents"""
        collection = self.client.collections.get(self.collection_name)

        with collection.batch.dynamic() as batch:
            for i, text in enumerate(texts):
                image_data = None
                if image_paths[i]:
                    with open(image_paths[i], "rb") as f:
                        image_data = base64.b64encode(f.read()).decode()

                batch.add_object(
                    properties={
                        "text": text,
                        "title": titles[i],
                        "category": categories[i],
                        "tags": tags_list[i],
                        "year": years[i],
                        "image": image_data,
                    },
                    uuid=generate_uuid5(f"doc-{i}")
                )

    def text_search(
        self,
        query: str,
        limit: int = 10,
        category: str | None = None,
        year_min: int | None = None,
    ) -> list[dict]:
        """Text semantic search + filtering"""
        collection = self.client.collections.get(self.collection_name)

        filters = None
        conditions = []
        if category:
            conditions.append(Filter.by_property("category").equal(category))
        if year_min:
            conditions.append(Filter.by_property("year").greater_or_equal(year_min))

        if conditions:
            filters = Filter.all_of(conditions)

        results = collection.query.hybrid(
            query=query,
            vector_per_name="text_vector",
            limit=limit,
            filters=filters,
            return_metadata=MetadataQuery(score=True, explain_score=True),
        )

        return [
            {
                "id": str(obj.uuid),
                "score": obj.metadata.score,
                "text": obj.properties["text"],
                "title": obj.properties["title"],
                "category": obj.properties["category"],
            }
            for obj in results.objects
        ]

    def multimodal_search(
        self,
        query: str | None = None,
        image_path: str | None = None,
        limit: int = 10,
    ) -> list[dict]:
        """Multimodal retrieval - text+image joint query"""
        collection = self.client.collections.get(self.collection_name)

        if query and image_path:
            # Text+image joint query
            with open(image_path, "rb") as f:
                image_b64 = base64.b64encode(f.read()).decode()

            results = collection.query.hybrid(
                query=query,
                vector_per_name="multimodal_vector",
                limit=limit,
                return_metadata=MetadataQuery(score=True),
            )
        elif query:
            results = collection.query.hybrid(
                query=query,
                vector_per_name="text_vector",
                limit=limit,
                return_metadata=MetadataQuery(score=True),
            )
        else:
            return []

        return [
            {
                "id": str(obj.uuid),
                "score": obj.metadata.score,
                "text": obj.properties["text"],
                "title": obj.properties["title"],
            }
            for obj in results.objects
        ]

    def close(self):
        self.client.close()


# Usage example
if __name__ == "__main__":
    searcher = WeaviateMultimodalSearch()
    searcher.create_collection()

    results = searcher.text_search(
        query="vector database hybrid retrieval",
        category="database",
        year_min=2024,
    )
    for r in results:
        print(f"[{r['score']:.3f}] {r['title']}: {r['text'][:50]}...")

    searcher.close()

Pattern 5: Production-Grade Hybrid Retrieval Architecture

Building a production-grade hybrid retrieval system supporting billion-scale vectors:

# production_hybrid_retrieval.py
# Runtime: Python 3.12+ / FastAPI 0.115+

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from typing import Optional
import numpy as np
import logging
import time
from functools import lru_cache

logger = logging.getLogger(__name__)

app = FastAPI(title="Hybrid Retrieval API", version="2.0.0")


class SearchRequest(BaseModel):
    query: str = Field(..., min_length=1, max_length=1000)
    limit: int = Field(default=10, ge=1, le=100)
    category: Optional[str] = None
    year_min: Optional[int] = None
    year_max: Optional[int] = None
    tags: Optional[list[str]] = None
    dense_weight: float = Field(default=0.7, ge=0.0, le=1.0)
    sparse_weight: float = Field(default=0.3, ge=0.0, le=1.0)
    enable_rerank: bool = Field(default=True)


class SearchResult(BaseModel):
    id: str
    score: float
    text: str
    title: str
    category: str
    tags: list[str]


class SearchResponse(BaseModel):
    results: list[SearchResult]
    total: int
    latency_ms: float
    reranked: bool


class EmbeddingService:
    """Embedding service - unified vectorization interface"""

    def __init__(self, model_name: str = "BAAI/bge-m3"):
        self.model_name = model_name
        self._dense_model = None
        self._sparse_model = None

    @property
    def dense_model(self):
        if self._dense_model is None:
            from sentence_transformers import SentenceTransformer
            self._dense_model = SentenceTransformer(self.model_name)
        return self._dense_model

    def encode_dense(self, text: str) -> list[float]:
        """Generate dense vectors"""
        embedding = self.dense_model.encode(text, normalize_embeddings=True)
        return embedding.tolist()

    def encode_sparse(self, text: str) -> dict[int, float]:
        """Generate sparse vectors (SPLADE style)"""
        # Simplified implementation, use SPLADE model in production
        from sklearn.feature_extraction.text import TfidfVectorizer
        vectorizer = TfidfVectorizer(max_features=10000)
        vectorizer.fit([text])
        tfidf = vectorizer.transform([text])
        return {int(idx): float(val) for idx, val in zip(tfidf[0].indices, tfidf[0].data)}


class HybridRetrievalService:
    """Production-grade hybrid retrieval service"""

    def __init__(self, backend: str = "milvus"):
        self.backend = backend
        self.embedding = EmbeddingService()
        self._searcher = None

    @property
    def searcher(self):
        if self._searcher is None:
            if self.backend == "milvus":
                from milvus_hybrid_search import MilvusHybridSearch
                self._searcher = MilvusHybridSearch()
            elif self.backend == "qdrant":
                from qdrant_filtered_search import QdrantHybridSearch
                self._searcher = QdrantHybridSearch()
            else:
                raise ValueError(f"Unsupported backend: {self.backend}")
        return self._searcher

    def search(self, request: SearchRequest) -> SearchResponse:
        """Execute hybrid retrieval"""
        start_time = time.time()

        # 1. Vectorize query
        dense_vector = self.embedding.encode_dense(request.query)
        sparse_vector = self.embedding.encode_sparse(request.query)

        # 2. Build filter conditions
        filter_expr = self._build_filter(request)

        # 3. Execute hybrid retrieval
        if self.backend == "milvus":
            results = self.searcher.hybrid_search(
                query_dense=dense_vector,
                query_sparse=sparse_vector,
                limit=request.limit * 4 if request.enable_rerank else request.limit,
                dense_weight=request.dense_weight,
                sparse_weight=request.sparse_weight,
                filter_expr=filter_expr,
            )
        elif self.backend == "qdrant":
            results = self.searcher.hybrid_search(
                query_dense=dense_vector,
                query_sparse=sparse_vector,
                limit=request.limit * 4 if request.enable_rerank else request.limit,
            )

        # 4. Reranking (optional)
        reranked = False
        if request.enable_rerank and len(results) > request.limit:
            results = self._rerank(request.query, results)
            reranked = True

        latency_ms = (time.time() - start_time) * 1000

        return SearchResponse(
            results=results[:request.limit],
            total=len(results),
            latency_ms=latency_ms,
            reranked=reranked,
        )

    def _build_filter(self, request: SearchRequest) -> str:
        """Build filter expression"""
        conditions = []
        if request.category:
            conditions.append(f'category == "{request.category}"')
        if request.year_min:
            conditions.append(f'year >= {request.year_min}')
        if request.year_max:
            conditions.append(f'year <= {request.year_max}')
        if request.tags:
            tag_conditions = [f'array_contains(tags, "{tag}")' for tag in request.tags]
            conditions.append(f'({" or ".join(tag_conditions)})')
        return " and ".join(conditions)

    def _rerank(self, query: str, results: list[dict]) -> list[dict]:
        """Cross-Encoder reranking"""
        try:
            from sentence_transformers import CrossEncoder
            reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")

            pairs = [[query, r["text"]] for r in results]
            scores = reranker.predict(pairs)

            for i, r in enumerate(results):
                r["rerank_score"] = float(scores[i])

            results.sort(key=lambda x: x.get("rerank_score", x.get("score", 0)), reverse=True)
        except Exception as e:
            logger.warning(f"Rerank failed: {e}")

        return results


# Global service instance
retrieval_service = HybridRetrievalService(backend="milvus")


@app.post("/search", response_model=SearchResponse)
async def search(request: SearchRequest):
    """Hybrid retrieval API"""
    try:
        return retrieval_service.search(request)
    except Exception as e:
        logger.error(f"Search failed: {e}")
        raise HTTPException(status_code=500, detail=str(e))


@app.get("/health")
async def health():
    return {"status": "ok", "backend": retrieval_service.backend}

Pitfall Guide

Pitfall 1: Ignoring Vector Normalization

# Wrong: Unnormalized vectors cause cosine similarity calculation bias
vectors = model.encode(texts)  # May not be normalized

# Correct: Always normalize vectors
vectors = model.encode(texts, normalize_embeddings=True)
# Or manually normalize
vectors = vectors / np.linalg.norm(vectors, axis=1, keepdims=True)

Pitfall 2: Overly Strict Filter Conditions

# Wrong: Filter before retrieval, insufficient recall
filter = 'category == "tech" and year == 2026 and author == "John"'
# May filter out most documents, vector retrieval space too small

# Correct: Loose filtering + reranking
filter = 'category == "tech" and year >= 2024'  # Relax conditions
# Then use reranking model for precision ranking

Pitfall 3: Improper HNSW Parameter Configuration

# Wrong: M and ef parameters too small, low recall rate
index_params = {"M": 4, "efConstruction": 32}  # Recall rate may be below 80%

# Correct: Adjust parameters based on data scale
# Small dataset (<1M)
index_params = {"M": 16, "efConstruction": 256}
# Large dataset (>10M)
index_params = {"M": 32, "efConstruction": 512}
# Search ef >= limit * 2
search_params = {"ef": 256}

Pitfall 4: Wrong Sparse Vectorization Method

# Wrong: Using TF-IDF as sparse vectors, lacking semantic information
from sklearn.feature_extraction.text import TfidfVectorizer

# Correct: Use SPLADE or BM25+semantic expansion
# SPLADE: Learn sparse representations while preserving semantics
# BM25: Traditional keyword matching, suitable for exact filtering
# Recommended: SPLADE for sparse vector field, BM25 for auxiliary filtering

Pitfall 5: Ignoring Index Warmup

# Wrong: Extremely high search latency on cold start
# First search needs to load index into memory, P99 latency may exceed 10s

# Correct: Warmup index on service startup
@app.on_event("startup")
async def warmup():
    # Execute a few empty searches to warmup index
    dummy_vector = np.random.randn(768).tolist()
    retrieval_service.searcher.hybrid_search(
        query_dense=dummy_vector,
        query_sparse={0: 0.1},
        limit=1,
    )
    logger.info("Index warmup completed")

Error Troubleshooting Table

Error Message Cause Solution
Collection not found Collection not created Call create_collection() first
Dimension mismatch Vector dimension mismatch with collection config Check embedding model output dimension
Index not ready Index build not complete Wait for index build or check indexing_threshold
Memory limit exceeded Data volume exceeds memory limit Enable on_disk mode or scale up
Timeout on hybrid search Search timeout Reduce limit, lower ef, optimize filter conditions
Sparse vector format error Incorrect sparse vector format Ensure {dim_id: float} format
Filter syntax error Filter expression syntax error Check field names and operators
Connection refused Database not started Check Docker container status
Rate limit exceeded Request rate too high Add rate limiting or batch interface
Reranker OOM Reranker model out of memory Reduce oversampling factor or use smaller reranker model

Advanced Optimization

1. Adaptive Weight Adjustment

def adaptive_weights(query: str, dense_weight: float = 0.7) -> tuple[float, float]:
    """Adaptively adjust dense/sparse weights based on query characteristics"""
    # Long queries lean toward semantics (dense), short queries lean toward keywords (sparse)
    if len(query) > 50:
        return (0.8, 0.2)  # Long query: semantics first
    elif len(query) < 10:
        return (0.4, 0.6)  # Short query: keywords first
    else:
        return (dense_weight, 1.0 - dense_weight)

2. Tiered Retrieval Strategy

def tiered_search(query: str, limit: int = 10):
    """Tiered retrieval: fast then accurate"""
    # Level 1: Low precision fast retrieval
    fast_results = searcher.hybrid_search(
        query_dense=encode(query),
        query_sparse=encode_sparse(query),
        limit=limit * 2,
        search_params={"ef": 32},  # Low precision
    )

    # Level 2: High precision ranking
    if len(fast_results) > limit:
        reranked = reranker.rank(query, fast_results)
        return reranked[:limit]
    return fast_results

3. Cache Hot Queries

from functools import lru_cache
import hashlib

@lru_cache(maxsize=10000)
def cached_search(query_hash: str, limit: int, filter_hash: str):
    """Cache hot query results"""
    return retrieval_service.search(query, limit, filter_expr)

def search_with_cache(query: str, limit: int, filter_expr: str = ""):
    query_hash = hashlib.md5(query.encode()).hexdigest()
    filter_hash = hashlib.md5(filter_expr.encode()).hexdigest()
    return cached_search(query_hash, limit, filter_hash)

4. Data Sharding Strategy

# Shard by category, reduce single collection data volume
shards = {
    "tech": MilvusHybridSearch(collection_name="docs_tech"),
    "finance": MilvusHybridSearch(collection_name="docs_finance"),
    "health": MilvusHybridSearch(collection_name="docs_health"),
}

def sharded_search(query: str, category: str = None):
    if category and category in shards:
        return shards[category].hybrid_search(...)
    # Global search: parallel query all shards
    import asyncio
    results = asyncio.gather(*[
        shard.hybrid_search(...) for shard in shards.values()
    ])
    return merge_and_rank(results)

5. Monitoring and Alerting

# Prometheus metrics
from prometheus_client import Histogram, Counter

search_latency = Histogram(
    "hybrid_search_latency_seconds",
    "Hybrid search latency",
    ["backend", "operation"]
)
search_errors = Counter(
    "hybrid_search_errors_total",
    "Total search errors",
    ["backend", "error_type"]
)

@search_latency.labels(backend="milvus", operation="hybrid").time()
def monitored_search(request: SearchRequest):
    try:
        return retrieval_service.search(request)
    except Exception as e:
        search_errors.labels(backend="milvus", error_type=type(e).__name__).inc()
        raise

Comparison Analysis

Feature Milvus Qdrant Weaviate
Hybrid Retrieval Dense+Sparse Dense+Sparse+RRF Native Hybrid
Filtered Search Scalar Filter Advanced Filter GraphQL Filter
Multimodal External Model Required External Model Required Native Multimodal
Sparse Vector SPLADE/BM25 SPLADE/BM25 Built-in BM25
Distributed Native Distributed Sharding Multi-node
Performance(1M) ~5ms ~3ms ~8ms
Performance(100M) ~15ms ~20ms ~50ms
Memory Efficiency 4/5 5/5 3/5
Ecosystem Maturity 5/5 4/5 4/5
Ops Complexity High Medium Medium
Use Case Large-scale Production Mid-scale/Filter-heavy Multimodal/RAG

Summary

Vector database hybrid retrieval has become a standard for RAG applications in 2026:

  • Milvus: Top choice for large-scale production, native distributed, comprehensive sparse vector support, suitable for billion-scale data
  • Qdrant: Best filtered search performance, Rust implementation for high memory efficiency, suitable for mid-scale + complex filtering scenarios
  • Weaviate: Most convenient multimodal retrieval, native text+image joint query support, suitable for rapid RAG deployment

Selection advice: Data volume >100M choose Milvus; complex filter conditions choose Qdrant; multimodal needs choose Weaviate; all three support hybrid retrieval, the key is choosing based on your business scenario.

Online Tool Recommendations

  • /en/json/format - JSON formatter for debugging vector database API responses
  • /en/dev/curl-to-code - cURL to code converter for quickly generating vector database API calls
  • /en/encode/hash - Hash calculator for generating unique document IDs
  • /en/text/diff - Text diff tool for comparing results from different retrieval strategies

Try these browser-local tools — no sign-up required →

#向量数据库#混合检索#Milvus#Qdrant#Weaviate#2026#数据库