Coverage for haystack/components/builders/answer_builder.py: 97%
98 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
5import re
6from dataclasses import replace
7from typing import Any
9from haystack import Document, GeneratedAnswer, component, logging
10from haystack.dataclasses.chat_message import ChatMessage
12logger = logging.getLogger(__name__)
14DEFAULT_REFERENCE_PATTERN = r"\[(\d+)\]"
15EXPANDED_REFERENCE_PATTERN = r"\[(\d+(?:[,-]\d+)*)\]"
18@component
19class AnswerBuilder:
20 """
21 Converts a query and Generator replies into a `GeneratedAnswer` object.
23 AnswerBuilder parses Generator replies using custom regular expressions.
24 Check out the usage example below to see how it works.
25 Optionally, it can also take documents and metadata from the Generator to add to the `GeneratedAnswer` object.
26 AnswerBuilder works with both non-chat and chat Generators.
28 ### Usage example
30 ```python
31 from haystack.components.builders import AnswerBuilder
33 builder = AnswerBuilder(pattern="Answer: (.*)")
34 builder.run(query="What's the answer?", replies=["This is an argument. Answer: This is the answer."])
35 ```
37 ### Usage example with documents and reference pattern
39 ```python
40 from haystack import Document
41 from haystack.components.builders import AnswerBuilder
43 replies = ["The capital of France is Paris [2]."]
45 docs = [
46 Document(content="Berlin is the capital of Germany."),
47 Document(content="Paris is the capital of France."),
48 Document(content="Rome is the capital of Italy."),
49 ]
51 builder = AnswerBuilder(reference_pattern="\\[(\\d+)\\]", return_only_referenced_documents=False)
52 result = builder.run(query="What is the capital of France?", replies=replies, documents=docs)["answers"][0]
54 print(f"Answer: {result.data}")
55 print("References:")
56 for doc in result.documents:
57 if doc.meta["referenced"]:
58 print(f"[{doc.meta['source_index']}] {doc.content}")
59 print("Other sources:")
60 for doc in result.documents:
61 if not doc.meta["referenced"]:
62 print(f"[{doc.meta['source_index']}] {doc.content}")
64 # >> Answer: The capital of France is Paris
65 # >> References:
66 # >> [2] Paris is the capital of France.
67 # >> Other sources:
68 # >> [1] Berlin is the capital of Germany.
69 # >> [3] Rome is the capital of Italy.
70 ```
71 """
73 def __init__(
74 self,
75 pattern: str | None = None,
76 reference_pattern: str | None = None,
77 last_message_only: bool = False,
78 *,
79 return_only_referenced_documents: bool = True,
80 expand_reference_ranges: bool = False,
81 ) -> None:
82 """
83 Creates an instance of the AnswerBuilder component.
85 :param pattern:
86 The regular expression pattern to extract the answer text from the Generator.
87 If not specified, the entire response is used as the answer.
88 The regular expression can have one capture group at most.
89 If present, the capture group text
90 is used as the answer. If no capture group is present, the whole match is used as the answer.
91 Examples:
92 `[^\\n]+$` finds "this is an answer" in a string "this is an argument.\\nthis is an answer".
93 `Answer: (.*)` finds "this is an answer" in a string "this is an argument. Answer: this is an answer".
95 :param reference_pattern:
96 The regular expression pattern used for parsing the document references.
97 If not specified, no parsing is done, and all documents are returned.
98 References need to be specified as indices of the input documents and start at [1].
99 Example: `\\[(\\d+)\\]` finds "1" in a string "this is an answer[1]".
100 If this parameter is provided, documents metadata will contain a "referenced" key with a boolean value.
102 :param last_message_only:
103 If False (default value), all messages are used as the answer.
104 If True, only the last message is used as the answer.
106 :param return_only_referenced_documents:
107 To be used in conjunction with `reference_pattern`.
108 If True (default value), only the documents that were actually referenced in `replies` are returned.
109 If False, all documents are returned.
110 If `reference_pattern` is not provided, this parameter has no effect, and all documents are returned.
111 :param expand_reference_ranges:
112 If True, reference ranges like `[6-10]` are expanded to documents 6 through 10.
113 Defaults to False for backwards compatibility.
114 When enabled with the default `reference_pattern`, a broader pattern is used automatically.
115 """
116 if pattern:
117 AnswerBuilder._check_num_groups_in_regex(pattern)
119 self.pattern = pattern
120 self.reference_pattern = reference_pattern
121 self.last_message_only = last_message_only
122 self.return_only_referenced_documents = return_only_referenced_documents
123 self.expand_reference_ranges = expand_reference_ranges
125 @component.output_types(answers=list[GeneratedAnswer])
126 def run(
127 self,
128 query: str,
129 replies: list[str] | list[ChatMessage],
130 meta: list[dict[str, Any]] | None = None,
131 documents: list[Document] | None = None,
132 pattern: str | None = None,
133 reference_pattern: str | None = None,
134 expand_reference_ranges: bool | None = None,
135 ) -> dict[str, Any]:
136 """
137 Turns the output of a Generator into `GeneratedAnswer` objects using regular expressions.
139 :param query:
140 The input query used as the Generator prompt.
141 :param replies:
142 The output of the Generator. Can be a list of strings or a list of `ChatMessage` objects.
143 :param meta:
144 The metadata returned by the Generator. If not specified, the generated answer will contain no metadata.
145 :param documents:
146 The documents used as the Generator inputs. If specified, they are added to
147 the `GeneratedAnswer` objects.
148 The Document copies inside the returned `GeneratedAnswer.documents` each include a "source_index" key,
149 representing the document's 1-based position in the input list. The original input documents are
150 not modified.
151 When `reference_pattern` is provided:
152 - "referenced" key is added to the Document copies inside `GeneratedAnswer.documents`, indicating if
153 the document was referenced in the output.
154 - `return_only_referenced_documents` init parameter controls if all or only referenced documents are
155 returned.
156 :param pattern:
157 The regular expression pattern to extract the answer text from the Generator.
158 If not specified, the entire response is used as the answer.
159 The regular expression can have one capture group at most.
160 If present, the capture group text
161 is used as the answer. If no capture group is present, the whole match is used as the answer.
162 Examples:
163 `[^\\n]+$` finds "this is an answer" in a string "this is an argument.\\nthis is an answer".
164 `Answer: (.*)` finds "this is an answer" in a string
165 "this is an argument. Answer: this is an answer".
166 :param reference_pattern:
167 The regular expression pattern used for parsing the document references.
168 If not specified, no parsing is done, and all documents are returned.
169 References need to be specified as indices of the input documents and start at [1].
170 Example: `\\[(\\d+)\\]` finds "1" in a string "this is an answer[1]".
171 :param expand_reference_ranges:
172 If True, reference ranges like `[6-10]` are expanded to documents 6 through 10.
173 If not specified, the value from the component initialization is used.
175 :returns: A dictionary with the following keys:
176 - `answers`: The answers received from the output of the Generator.
177 """
178 if not meta:
179 meta = [{}] * len(replies)
180 elif len(replies) != len(meta):
181 raise ValueError(f"Number of replies ({len(replies)}), and metadata ({len(meta)}) must match.")
183 if pattern:
184 AnswerBuilder._check_num_groups_in_regex(pattern)
186 pattern = pattern or self.pattern
187 reference_pattern = reference_pattern or self.reference_pattern
188 expand_reference_ranges = (
189 self.expand_reference_ranges if expand_reference_ranges is None else expand_reference_ranges
190 )
191 reference_pattern = AnswerBuilder._resolve_reference_pattern(
192 reference_pattern=reference_pattern, expand_reference_ranges=expand_reference_ranges
193 )
195 replies_to_iterate = replies[-1:] if self.last_message_only and replies else replies
196 meta_to_iterate = meta[-1:] if self.last_message_only and meta else meta
198 all_answers = []
199 for reply, given_metadata in zip(replies_to_iterate, meta_to_iterate, strict=True):
200 # Extract content from ChatMessage objects if reply is a ChatMessages, else use the string as is
201 extracted_reply = reply.text or "" if isinstance(reply, ChatMessage) else str(reply)
202 extracted_metadata = reply.meta if isinstance(reply, ChatMessage) else {}
204 extracted_metadata = {**extracted_metadata, **given_metadata}
205 extracted_metadata["all_messages"] = replies
207 referenced_docs = []
208 if documents:
209 referenced_idxs = (
210 AnswerBuilder._extract_reference_idxs(
211 extracted_reply,
212 reference_pattern,
213 expand_ranges=expand_reference_ranges,
214 num_documents=len(documents),
215 )
216 if reference_pattern
217 else set()
218 )
219 doc_idxs = (
220 referenced_idxs
221 if reference_pattern and self.return_only_referenced_documents
222 else set(range(len(documents)))
223 )
225 for idx in sorted(doc_idxs):
226 # An explicit bounds check is needed because references are 1-based: a reference like [0]
227 # yields idx = -1, which Python would otherwise silently resolve to the last document.
228 if not 0 <= idx < len(documents):
229 logger.warning(
230 "Document index '{index}' referenced in Generator output is out of range. ", index=idx + 1
231 )
232 continue
233 doc = documents[idx]
235 doc_meta: dict[str, Any] = dict(doc.meta or {})
236 doc_meta["source_index"] = idx + 1
237 if reference_pattern:
238 doc_meta["referenced"] = idx in referenced_idxs
239 referenced_docs.append(replace(doc, meta=doc_meta))
241 answer_string = AnswerBuilder._extract_answer_string(extracted_reply, pattern)
242 answer = GeneratedAnswer(
243 data=answer_string, query=query, documents=referenced_docs, meta=extracted_metadata
244 )
245 all_answers.append(answer)
247 return {"answers": all_answers}
249 @staticmethod
250 def _extract_answer_string(reply: str, pattern: str | None = None) -> str:
251 """
252 Extract the answer string from the generator output using the specified pattern.
254 If no pattern is specified, the whole string is used as the answer.
256 :param reply:
257 The output of the Generator. A string.
258 :param pattern:
259 The regular expression pattern to use to extract the answer text from the generator output.
260 """
261 if pattern is None:
262 return reply
264 if match := re.search(pattern, reply):
265 # No capture group in pattern -> use the whole match as answer
266 if not match.lastindex:
267 return match.group(0)
268 # One capture group in pattern -> use the capture group as answer
269 return match.group(1)
270 return ""
272 @staticmethod
273 def _resolve_reference_pattern(reference_pattern: str | None, expand_reference_ranges: bool) -> str | None:
274 if not reference_pattern or not expand_reference_ranges:
275 return reference_pattern
276 if reference_pattern == DEFAULT_REFERENCE_PATTERN:
277 return EXPANDED_REFERENCE_PATTERN
278 return reference_pattern
280 @staticmethod
281 def _extract_reference_idxs(
282 reply: str, reference_pattern: str, expand_ranges: bool = False, num_documents: int | None = None
283 ) -> set[int]:
284 matches = re.findall(reference_pattern, reply)
285 idxs: set[int] = set()
286 for match in matches:
287 if expand_ranges:
288 for part in match.split(","):
289 part = part.strip()
290 if not part:
291 continue
292 if "-" in part:
293 start_str, end_str = part.split("-", 1)
294 start, end = int(start_str), int(end_str)
295 if start > end:
296 continue
297 # Clamp the range end to the number of documents to avoid materializing a huge
298 # set from an out-of-range citation like `[1-999999999]` in the Generator output.
299 if num_documents is not None:
300 end = min(end, num_documents)
301 idxs.update(range(start - 1, end))
302 else:
303 idxs.add(int(part) - 1)
304 else:
305 idxs.add(int(match) - 1)
306 return idxs
308 @staticmethod
309 def _check_num_groups_in_regex(pattern: str) -> None:
310 num_groups = re.compile(pattern).groups
311 if num_groups > 1:
312 raise ValueError(
313 f"Pattern '{pattern}' contains multiple capture groups. "
314 f"Please specify a pattern with at most one capture group."
315 )