Coverage for haystack/components/samplers/top_p.py: 100%
65 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
6from typing import Any
8from haystack import Document, component, logging
9from haystack.lazy_imports import LazyImport
11logger = logging.getLogger(__name__)
14with LazyImport(message="Run 'pip install \"torch>=1.13\"'") as torch_import:
15 import torch
18@component
19class TopPSampler:
20 """
21 Implements top-p (nucleus) sampling for document filtering based on cumulative probability scores.
23 This component provides functionality to filter a list of documents by selecting those whose scores fall
24 within the top 'p' percent of the cumulative distribution. It is useful for focusing on high-probability
25 documents while filtering out less relevant ones based on their assigned scores.
27 Usage example:
29 ```python
30 from haystack import Document
31 from haystack.components.samplers import TopPSampler
33 sampler = TopPSampler(top_p=0.95, score_field="similarity_score")
34 docs = [
35 Document(content="Berlin", meta={"similarity_score": -10.6}),
36 Document(content="Belgrade", meta={"similarity_score": -8.9}),
37 Document(content="Sarajevo", meta={"similarity_score": -4.6}),
38 ]
39 output = sampler.run(documents=docs)
40 docs = output["documents"]
41 assert len(docs) == 1
42 assert docs[0].content == "Sarajevo"
43 ```
44 """
46 def __init__(self, top_p: float = 1.0, score_field: str | None = None, min_top_k: int | None = None) -> None:
47 """
48 Creates an instance of TopPSampler.
50 :param top_p: Float between 0 and 1 representing the cumulative probability threshold for document selection.
51 A value of 1.0 indicates no filtering (all documents are retained).
52 :param score_field: Name of the field in each document's metadata that contains the score. If None, the default
53 document score field is used.
54 :param min_top_k: If specified, the minimum number of documents to return. If the top_p selects
55 fewer documents, additional ones with the next highest scores are added to the selection.
56 """
57 torch_import.check()
59 self.top_p = top_p
60 if not 0 <= top_p <= 1:
61 raise ValueError(f"top_p must be between 0 and 1. Got {top_p}.")
62 self.score_field = score_field
63 self.min_top_k = min_top_k
65 @component.output_types(documents=list[Document])
66 def run(self, documents: list[Document], top_p: float | None = None) -> dict[str, Any]:
67 """
68 Filters documents using top-p sampling based on their scores.
70 If the specified top_p results in no documents being selected (especially in cases of a low top_p value), the
71 method returns the document with the highest score.
73 :param documents: List of Document objects to be filtered.
74 :param top_p: If specified, a float to override the cumulative probability threshold set during initialization.
76 :returns: A dictionary with the following key:
77 - `documents`: List of Document objects that have been selected based on the top-p sampling.
78 :raises ValueError: If the top_p value is not within the range [0, 1].
79 """
80 if not documents:
81 return {"documents": []}
83 top_p = top_p if top_p is not None else self.top_p
84 if not 0 <= top_p <= 1:
85 raise ValueError(f"top_p must be between 0 and 1. Got {top_p}.")
87 documents_with_scores, scores = self._get_documents_and_scores(documents)
88 if len(documents_with_scores) == 0:
89 logger.warning("No documents with scores found. Returning the original documents.")
90 return {"documents": documents}
92 sorted_docs_with_scores = sorted(
93 zip(documents_with_scores, scores, strict=True), key=lambda x: x[1], reverse=True
94 )
95 sorted_documents, sorted_scores = [list(t) for t in zip(*sorted_docs_with_scores, strict=True)]
97 tensor_scores = torch.tensor(sorted_scores, dtype=torch.float32)
98 probs = torch.nn.functional.softmax(tensor_scores, dim=-1)
99 cumulative_probs = torch.cumsum(probs, dim=-1)
101 # Check if the cumulative probabilities are close to top_p with a 1e-6 tolerance
102 close_to_top_p = torch.isclose(cumulative_probs, torch.tensor(top_p, device=cumulative_probs.device), atol=1e-6)
104 # Combine the close_to_top_p with original condition using logical OR
105 condition = (cumulative_probs <= top_p) | close_to_top_p
107 # Find the indices with cumulative probabilities that exceed top_p
108 top_p_indices = torch.where(torch.BoolTensor(condition))[0]
110 # Map the selected indices back to their original indices
111 selected_docs = [sorted_documents[i.item()] for i in top_p_indices]
113 if self.min_top_k and len(selected_docs) < self.min_top_k:
114 selected_docs = sorted_documents[: self.min_top_k]
116 # If low p resulted in no documents being selected, then return at least one document
117 if len(selected_docs) == 0:
118 logger.warning(
119 "Top-p sampling with p={top_p} resulted in no documents being selected. "
120 "Returning the document with the highest score.",
121 top_p=top_p,
122 )
123 selected_docs = [sorted_documents[0]]
125 return {"documents": selected_docs}
127 @staticmethod
128 def _get_doc_score(doc: Document, score_field: str | None = None) -> float | None:
129 """
130 Get the score of a document.
132 :param doc: Document object.
133 :param score_field: Name of the field in the document's metadata that contains the score.
134 If None, the document score field is used.
136 :return: Score of the document.
137 """
138 if score_field:
139 score = doc.meta.get(score_field)
140 else:
141 score = doc.score
143 # bool is a subclass of int but is not a valid score
144 if isinstance(score, bool) or not isinstance(score, (int, float)):
145 return None
146 return float(score)
148 def _get_documents_and_scores(self, documents: list[Document]) -> tuple[list[Document], list[float]]:
149 """
150 Checks if documents have scores in their metadata or score field and returns the documents with scores.
152 :param documents: List of Documents.
153 :return: List of scores.
154 """
155 docs_with_scores = []
156 scores = []
157 docs_missing_scores = []
158 for doc in documents:
159 score = self._get_doc_score(doc=doc, score_field=self.score_field)
160 if score is None:
161 docs_missing_scores.append(doc)
162 else:
163 scores.append(score)
164 docs_with_scores.append(doc)
166 if len(docs_missing_scores) > 0:
167 missing_scores_docs_ids = [d.id for d in docs_missing_scores if d.id]
168 if self.score_field:
169 logger.warning(
170 "Score field '{score_field}' not found in metadata of documents with IDs: {doc_ids}."
171 "Make sure that all documents have a score field '{score_field_2}' in their metadata.",
172 score_field=self.score_field,
173 doc_ids=",".join(missing_scores_docs_ids),
174 score_field_2=self.score_field,
175 )
176 else:
177 logger.warning(
178 "Ensure all documents have a valid score value. These documents {doc_ids} are missing scores.",
179 doc_ids=",".join(missing_scores_docs_ids),
180 )
181 return docs_with_scores, scores