Source code for langchain_mongodb.retrievers.hybrid_search

import warnings
from typing import Annotated, Any, Dict, List, Optional, Union

from langchain_core.callbacks.manager import CallbackManagerForRetrieverRun
from langchain_core.documents import Document
from langchain_core.retrievers import BaseRetriever
from pydantic import Field
from pymongo.collection import Collection

from langchain_mongodb import MongoDBAtlasVectorSearch
from langchain_mongodb.index import create_fulltext_search_index
from langchain_mongodb.pipelines import (
    autoembedding_vector_search_stage,
    combine_pipelines,
    final_hybrid_stage,
    reciprocal_rank_stage,
    rerank_stage,
    text_search_stage,
    vector_search_stage,
)
from langchain_mongodb.utils import make_serializable, prepare_query_for_vector_search


[docs] class MongoDBAtlasHybridSearchRetriever(BaseRetriever): """Hybrid Search Retriever combines vector and full-text searches weighting them the via Reciprocal Rank Fusion (RRF) algorithm. Increasing the vector_penalty will reduce the importance on the vector search. Increasing the fulltext_penalty will correspondingly reduce the fulltext score. For more on the algorithm,see https://learn.microsoft.com/en-us/azure/search/hybrid-search-ranking """ vectorstore: MongoDBAtlasVectorSearch """MongoDBAtlas VectorStore""" search_index_name: str """Atlas Search Index (full-text) name""" k: int = 4 """Number of documents to return.""" oversampling_factor: int = 10 """This times k is the number of candidates chosen at each step""" pre_filter: Optional[Dict[str, Any]] = None """(Optional) Any MQL match expression comparing an indexed field""" post_filter: Optional[List[Dict[str, Any]]] = None """(Optional) Pipeline of MongoDB aggregation stages for postprocessing.""" vector_penalty: float = 60.0 """Penalty applied to vector search results in RRF: scores=1/(rank + penalty)""" fulltext_penalty: float = 60.0 """Penalty applied to full-text search results in RRF: scores=1/(rank + penalty)""" vector_weight: float = 1.0 """Weight applied to vector search results in RRF: score = weight * (1 / (rank + penalty + 1))""" fulltext_weight: float = 1.0 """Weight applied to full-text search results in RRF: score = weight * (1 / (rank + penalty + 1))""" show_embeddings: float = False """If true, returned Document metadata will include vectors.""" rerank_path: Optional[Union[str, List[str]]] = None """Field or list of fields to rerank on. Enables $rerank when set.""" rerank_model: Optional[str] = None """Voyage AI reranking model (e.g. 'rerank-2.5'). Uses latest model if omitted.""" num_docs_to_rerank: Optional[int] = None """Candidates passed to the reranker. Defaults to k. Max 1000.""" top_k: Annotated[ Optional[int], Field(deprecated='top_k is deprecated, use "k" instead') ] = None """Number of documents to return.""" def __init__( self, *, vectorstore: MongoDBAtlasVectorSearch, search_index_name: str, k: int = 4, oversampling_factor: int = 10, pre_filter: Optional[Dict[str, Any]] = None, post_filter: Optional[List[Dict[str, Any]]] = None, vector_penalty: float = 60.0, fulltext_penalty: float = 60.0, vector_weight: float = 1.0, fulltext_weight: float = 1.0, show_embeddings: float = False, rerank_path: Optional[Union[str, List[str]]] = None, rerank_model: Optional[str] = None, num_docs_to_rerank: Optional[int] = None, top_k: Optional[int] = None, auto_create_index: bool = True, auto_index_timeout: int = 15, **kwargs: Any, ) -> None: """Initialize the MongoDBAtlasHybridSearchRetriever. Args: vectorstore: MongoDBAtlasVectorSearch instance. search_index_name: Atlas Search Index (full-text) name. k: Number of documents to return. Defaults to 4. oversampling_factor: This times k is the number of candidates chosen at each step. Defaults to 10. pre_filter: (Optional) Any MQL match expression comparing an indexed field. post_filter: (Optional) Pipeline of MongoDB aggregation stages for postprocessing. vector_penalty: Penalty applied to vector search results in RRF: scores=1/(rank + penalty). Defaults to 60.0. fulltext_penalty: Penalty applied to full-text search results in RRF: scores=1/(rank + penalty). Defaults to 60.0. vector_weight: Weight applied to vector search results in RRF: score = weight * (1 / (rank + penalty + 1)). Defaults to 1.0. fulltext_weight: Weight applied to full-text search results in RRF: score = weight * (1 / (rank + penalty + 1)). Defaults to 1.0. show_embeddings: If true, returned Document metadata will include vectors. Defaults to False. rerank_path: Field or list of fields to rerank on. Enables $rerank when set. rerank_model: Voyage AI reranking model. Uses latest model if omitted. num_docs_to_rerank: Candidates passed to the reranker. Defaults to k. Max 1000. top_k: (Deprecated) Number of documents to return. Use k instead. auto_create_index: Whether to automatically create the full-text search index if it does not exist. Defaults to True. auto_index_timeout: How long to wait for the automatic index creation to complete, in seconds. Defaults to 15. vector_index_options: Unused; kept for backward compatibility. Defaults to None. **kwargs: Additional keyword arguments. """ super().__init__( # type: ignore[call-arg] vectorstore=vectorstore, search_index_name=search_index_name, k=k, oversampling_factor=oversampling_factor, pre_filter=pre_filter, post_filter=post_filter, vector_penalty=vector_penalty, fulltext_penalty=fulltext_penalty, vector_weight=vector_weight, fulltext_weight=fulltext_weight, show_embeddings=show_embeddings, rerank_path=rerank_path, rerank_model=rerank_model, num_docs_to_rerank=num_docs_to_rerank, top_k=top_k, **kwargs, ) if auto_create_index and not any( ix["name"] == search_index_name for ix in self.vectorstore._collection.list_search_indexes() ): create_fulltext_search_index( collection=self.vectorstore._collection, index_name=search_index_name, field=self.vectorstore._text_key, wait_until_complete=auto_index_timeout, ) @property def collection(self) -> Collection: return self.vectorstore._collection
[docs] def close(self) -> None: """Close the resources used by the MongoDBAtlasHybridSearchRetriever.""" self.vectorstore.close()
def _get_relevant_documents( self, query: str, *, run_manager: CallbackManagerForRetrieverRun, **kwargs: Any ) -> List[Document]: """Retrieve documents that are highest scoring / most similar to query. Note that the same query is used in both searches, embedded for vector search, and as-is for full-text search. Args: query: String to find relevant documents for run_manager: The callback handler to use Returns: List of relevant documents """ # Prepare query for vector search (handles auto embeddings check) query_input, is_autoembedding = prepare_query_for_vector_search( query, self.vectorstore._embedding ) scores_fields = ["vector_score", "fulltext_score"] pipeline: List[Any] = [] # Get the appropriate value for k. is_top_k_set = False with warnings.catch_warnings(): # Ignore warning raised by checking the value of top_k. warnings.simplefilter("ignore", DeprecationWarning) if self.top_k is not None: is_top_k_set = True default_k = self.k if not is_top_k_set else self.top_k k: int = kwargs.get("k", default_k) # type:ignore[assignment] # First we build up the aggregation pipeline, # then it is passed to the server to execute # Vector Search stage if is_autoembedding: assert isinstance(query_input, str) auto_embedding = self.vectorstore._embedding # type: ignore[attr-defined] vector_pipeline = [ autoembedding_vector_search_stage( query=query_input, search_field=self.vectorstore._text_key, index_name=self.vectorstore._index_name, model=auto_embedding.model, # type: ignore[attr-defined] top_k=k, filter=self.pre_filter, oversampling_factor=self.oversampling_factor, ) ] else: assert self.vectorstore._embedding_key is not None assert isinstance(query_input, list) vector_pipeline = [ vector_search_stage( query_vector=query_input, search_field=self.vectorstore._embedding_key, index_name=self.vectorstore._index_name, top_k=k, filter=self.pre_filter, oversampling_factor=self.oversampling_factor, ) ] vector_pipeline += reciprocal_rank_stage( score_field="vector_score", penalty=self.vector_penalty, weight=self.vector_weight, ) combine_pipelines(pipeline, vector_pipeline, self.collection.name) # Full-Text Search stage text_pipeline = text_search_stage( query=query, search_field=self.vectorstore._text_key, index_name=self.search_index_name, limit=k, filter=self.pre_filter, ) text_pipeline.extend( reciprocal_rank_stage( score_field="fulltext_score", penalty=self.fulltext_penalty, weight=self.fulltext_weight, ) ) combine_pipelines(pipeline, text_pipeline, self.collection.name) # Sum and sort stage — expand limit when reranking so the reranker has candidates. n_to_rerank = self.num_docs_to_rerank or k hybrid_limit = n_to_rerank if self.rerank_path else k pipeline.extend( final_hybrid_stage(scores_fields=scores_fields, limit=hybrid_limit) ) # Removal of embeddings unless requested. if not self.show_embeddings and not is_autoembedding: pipeline.append({"$project": {self.vectorstore._embedding_key: 0}}) # Native Reranking via $rerank (requires MongoDB 8.3+ and Atlas project setting). if self.rerank_path is not None: pipeline.extend( rerank_stage(query, self.rerank_path, n_to_rerank, self.rerank_model) ) if n_to_rerank > k: pipeline.append({"$limit": k}) # Post filtering if self.post_filter is not None: pipeline.extend(self.post_filter) # Execution cursor = self.collection.aggregate(pipeline) # type: ignore[arg-type] # Formatting docs = [] for res in cursor: text = res.pop(self.vectorstore._text_key) # score = res.pop("score") # The score remains buried! make_serializable(res) docs.append(Document(page_content=text, metadata=res)) return docs