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

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import re 

6from dataclasses import replace 

7from typing import Any 

8 

9from haystack import Document, GeneratedAnswer, component, logging 

10from haystack.dataclasses.chat_message import ChatMessage 

11 

12logger = logging.getLogger(__name__) 

13 

14DEFAULT_REFERENCE_PATTERN = r"\[(\d+)\]" 

15EXPANDED_REFERENCE_PATTERN = r"\[(\d+(?:[,-]\d+)*)\]" 

16 

17 

18@component 

19class AnswerBuilder: 

20 """ 

21 Converts a query and Generator replies into a `GeneratedAnswer` object. 

22 

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. 

27 

28 ### Usage example 

29 

30 ```python 

31 from haystack.components.builders import AnswerBuilder 

32 

33 builder = AnswerBuilder(pattern="Answer: (.*)") 

34 builder.run(query="What's the answer?", replies=["This is an argument. Answer: This is the answer."]) 

35 ``` 

36 

37 ### Usage example with documents and reference pattern 

38 

39 ```python 

40 from haystack import Document 

41 from haystack.components.builders import AnswerBuilder 

42 

43 replies = ["The capital of France is Paris [2]."] 

44 

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 ] 

50 

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] 

53 

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}") 

63 

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 """ 

72 

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. 

84 

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". 

94 

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. 

101 

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. 

105 

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) 

118 

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 

124 

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. 

138 

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. 

174 

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.") 

182 

183 if pattern: 

184 AnswerBuilder._check_num_groups_in_regex(pattern) 

185 

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 ) 

194 

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 

197 

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 {} 

203 

204 extracted_metadata = {**extracted_metadata, **given_metadata} 

205 extracted_metadata["all_messages"] = replies 

206 

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 ) 

224 

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] 

234 

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)) 

240 

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) 

246 

247 return {"answers": all_answers} 

248 

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. 

253 

254 If no pattern is specified, the whole string is used as the answer. 

255 

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 

263 

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 "" 

271 

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 

279 

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 

307 

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 )