searchapi-haystack 2.0.0__tar.gz → 2.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- searchapi_haystack-2.1.0/CHANGELOG.md +29 -0
- {searchapi_haystack-2.0.0 → searchapi_haystack-2.1.0}/PKG-INFO +1 -1
- {searchapi_haystack-2.0.0 → searchapi_haystack-2.1.0}/src/haystack_integrations/components/websearch/searchapi/websearch.py +62 -0
- {searchapi_haystack-2.0.0 → searchapi_haystack-2.1.0}/tests/test_websearch.py +111 -0
- searchapi_haystack-2.0.0/CHANGELOG.md +0 -9
- {searchapi_haystack-2.0.0 → searchapi_haystack-2.1.0}/.gitignore +0 -0
- {searchapi_haystack-2.0.0 → searchapi_haystack-2.1.0}/LICENSE.txt +0 -0
- {searchapi_haystack-2.0.0 → searchapi_haystack-2.1.0}/README.md +0 -0
- {searchapi_haystack-2.0.0 → searchapi_haystack-2.1.0}/pydoc/config_docusaurus.yml +0 -0
- {searchapi_haystack-2.0.0 → searchapi_haystack-2.1.0}/pyproject.toml +0 -0
- {searchapi_haystack-2.0.0 → searchapi_haystack-2.1.0}/src/haystack_integrations/components/websearch/py.typed +0 -0
- {searchapi_haystack-2.0.0 → searchapi_haystack-2.1.0}/src/haystack_integrations/components/websearch/searchapi/__init__.py +0 -0
- {searchapi_haystack-2.0.0 → searchapi_haystack-2.1.0}/tests/__init__.py +0 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [integrations/searchapi-v2.0.0] - 2026-10-16
|
|
4
|
+
|
|
5
|
+
### 🐛 Bug Fixes
|
|
6
|
+
|
|
7
|
+
- Fix new issues raised by ruff 0.16.0 (#3670)
|
|
8
|
+
|
|
9
|
+
### 🚜 Refactor
|
|
10
|
+
|
|
11
|
+
- [**breaking**] Remove from_dict overrides that only call default_from_dict from 14 components (#4059)
|
|
12
|
+
|
|
13
|
+
### 🧪 Testing
|
|
14
|
+
|
|
15
|
+
- Test compatible integrations with Python 3.15; update pyproject (#4104)
|
|
16
|
+
|
|
17
|
+
### 🧹 Chores
|
|
18
|
+
|
|
19
|
+
- Re-standardize READMEs (#3768)
|
|
20
|
+
- [**breaking**] Drop Python 3.10 support (#4105)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## [integrations/searchapi-v1.0.0] - 2026-06-15
|
|
24
|
+
|
|
25
|
+
### 🚀 Features
|
|
26
|
+
|
|
27
|
+
- Add searchapi integration (moved from Haystack core) (#3437)
|
|
28
|
+
|
|
29
|
+
<!-- generated by git-cliff -->
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: searchapi-haystack
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Haystack integration for SearchApi Web Search
|
|
5
5
|
Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/searchapi#readme
|
|
6
6
|
Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
|
|
@@ -34,6 +34,11 @@ class SearchApiWebSearch:
|
|
|
34
34
|
assert results["documents"]
|
|
35
35
|
assert results["links"]
|
|
36
36
|
```
|
|
37
|
+
|
|
38
|
+
SearchApi's AI answer engines are supported through `search_params`. With `{"engine": "google_ai_mode"}`,
|
|
39
|
+
`{"engine": "chatgpt"}`, `{"engine": "gemini"}` or `{"engine": "bing_copilot"}`, the first document holds the
|
|
40
|
+
synthesized answer as Markdown. The following documents and `links` hold the reference links it cites and,
|
|
41
|
+
for `google_ai_mode` and `chatgpt`, the web results the engine retrieved.
|
|
37
42
|
"""
|
|
38
43
|
|
|
39
44
|
def __init__(
|
|
@@ -169,6 +174,11 @@ class SearchApiWebSearch:
|
|
|
169
174
|
def _parse_response(response: httpx.Response) -> tuple[list[Document], list[str]]:
|
|
170
175
|
json_result = response.json()
|
|
171
176
|
|
|
177
|
+
# AI answer engines (google_ai_mode, chatgpt, gemini, bing_copilot) return a
|
|
178
|
+
# synthesized answer instead of organic results
|
|
179
|
+
if any(key in json_result for key in ("text_blocks", "markdown", "reference_links")):
|
|
180
|
+
return SearchApiWebSearch._parse_ai_answer(json_result)
|
|
181
|
+
|
|
172
182
|
# organic results are the main results from the search engine
|
|
173
183
|
organic_results = []
|
|
174
184
|
if "organic_results" in json_result:
|
|
@@ -218,3 +228,55 @@ class SearchApiWebSearch:
|
|
|
218
228
|
|
|
219
229
|
links = [result["link"] for result in json_result.get("organic_results", [])]
|
|
220
230
|
return documents, links
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def _parse_ai_answer(json_result: dict[str, Any]) -> tuple[list[Document], list[str]]:
|
|
234
|
+
"""
|
|
235
|
+
Parse the shared AI answer shape returned by SearchApi's answer engines.
|
|
236
|
+
|
|
237
|
+
Engines such as `google_ai_mode`, `chatgpt`, `gemini` and `bing_copilot` return the answer as `markdown`
|
|
238
|
+
and the sources it cites as `reference_links`. `google_ai_mode` and `chatgpt` also return the `web_results`
|
|
239
|
+
they retrieved. The answer becomes the first document, each reference link a document after it, followed by
|
|
240
|
+
any web results. Links are the reference links, then the web result links.
|
|
241
|
+
"""
|
|
242
|
+
parameters = json_result.get("search_parameters", {})
|
|
243
|
+
documents: list[Document] = []
|
|
244
|
+
|
|
245
|
+
answer = json_result.get("markdown")
|
|
246
|
+
if answer:
|
|
247
|
+
documents.append(
|
|
248
|
+
Document.from_dict({"content": answer, "engine": parameters.get("engine", ""), "type": "ai_answer"})
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
links: list[str] = []
|
|
252
|
+
for result in json_result.get("reference_links", []) or []:
|
|
253
|
+
documents.append(
|
|
254
|
+
Document.from_dict(
|
|
255
|
+
{
|
|
256
|
+
"title": result.get("title", ""),
|
|
257
|
+
"content": result.get("snippet", ""),
|
|
258
|
+
"link": result.get("link", ""),
|
|
259
|
+
"source": result.get("source", ""),
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
if result.get("link"):
|
|
264
|
+
links.append(result["link"])
|
|
265
|
+
|
|
266
|
+
for result in json_result.get("web_results", []) or []:
|
|
267
|
+
link = result.get("link")
|
|
268
|
+
if link in links:
|
|
269
|
+
continue
|
|
270
|
+
documents.append(
|
|
271
|
+
Document.from_dict(
|
|
272
|
+
{
|
|
273
|
+
"title": result.get("title", ""),
|
|
274
|
+
"content": result.get("snippet", ""),
|
|
275
|
+
"link": link or "",
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
if link:
|
|
280
|
+
links.append(link)
|
|
281
|
+
|
|
282
|
+
return documents, links
|
|
@@ -398,6 +398,48 @@ def mock_searchapi_search_result_async() -> Generator[MagicMock, None, None]:
|
|
|
398
398
|
yield mock_get
|
|
399
399
|
|
|
400
400
|
|
|
401
|
+
EXAMPLE_AI_ANSWER_RESPONSE = {
|
|
402
|
+
"search_metadata": {"id": "search_ai_answer", "status": "Success", "created_at": "2026-10-01T00:00:00Z"},
|
|
403
|
+
"search_parameters": {"engine": "google_ai_mode", "q": "What is pgvector?"},
|
|
404
|
+
"text_blocks": [
|
|
405
|
+
{"type": "header", "answer": "pgvector"},
|
|
406
|
+
{
|
|
407
|
+
"type": "paragraph",
|
|
408
|
+
"answer": "pgvector adds vector similarity search to PostgreSQL.",
|
|
409
|
+
"reference_indexes": [0],
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
"type": "unordered_list",
|
|
413
|
+
"answer": "It supports:",
|
|
414
|
+
"items": [
|
|
415
|
+
{"type": "paragraph", "answer": "HNSW indexes"},
|
|
416
|
+
{"type": "paragraph", "answer": "IVFFlat indexes"},
|
|
417
|
+
],
|
|
418
|
+
},
|
|
419
|
+
{"type": "table", "table": {"headers": ["Index", "Recall"], "rows": [["HNSW", "0.75"], ["IVFFlat", "0.70"]]}},
|
|
420
|
+
],
|
|
421
|
+
"markdown": "## pgvector\n\npgvector adds vector similarity search to PostgreSQL.[1]",
|
|
422
|
+
"reference_links": [
|
|
423
|
+
{
|
|
424
|
+
"index": 0,
|
|
425
|
+
"title": "pgvector on GitHub",
|
|
426
|
+
"link": "https://github.com/pgvector/pgvector",
|
|
427
|
+
"snippet": "Open-source vector similarity search for Postgres.",
|
|
428
|
+
"source": "GitHub",
|
|
429
|
+
},
|
|
430
|
+
{"index": 1, "title": "Vectors in Postgres", "link": "https://example.com/vectors", "source": "Example"},
|
|
431
|
+
],
|
|
432
|
+
"related_questions": [{"question": "How does HNSW work?"}],
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@pytest.fixture
|
|
437
|
+
def mock_searchapi_ai_answer_result() -> Generator[MagicMock, None, None]:
|
|
438
|
+
with patch("haystack_integrations.components.websearch.searchapi.websearch.httpx.get") as mock_get:
|
|
439
|
+
mock_get.return_value = Mock(status_code=200, json=lambda: EXAMPLE_AI_ANSWER_RESPONSE)
|
|
440
|
+
yield mock_get
|
|
441
|
+
|
|
442
|
+
|
|
401
443
|
class TestSearchApiSearchAPI:
|
|
402
444
|
def test_init_fail_wo_api_key(self, monkeypatch):
|
|
403
445
|
monkeypatch.delenv("SEARCHAPI_API_KEY", raising=False)
|
|
@@ -444,6 +486,75 @@ class TestSearchApiSearchAPI:
|
|
|
444
486
|
assert documents[0].content == "Satya Nadella"
|
|
445
487
|
assert links == []
|
|
446
488
|
|
|
489
|
+
def test_parse_ai_answer_response(self) -> None:
|
|
490
|
+
documents, links = SearchApiWebSearch._parse_ai_answer(EXAMPLE_AI_ANSWER_RESPONSE)
|
|
491
|
+
|
|
492
|
+
assert documents[0].content == EXAMPLE_AI_ANSWER_RESPONSE["markdown"]
|
|
493
|
+
assert documents[0].meta["engine"] == "google_ai_mode"
|
|
494
|
+
assert documents[0].meta["type"] == "ai_answer"
|
|
495
|
+
|
|
496
|
+
assert [doc.meta["link"] for doc in documents[1:]] == [
|
|
497
|
+
"https://github.com/pgvector/pgvector",
|
|
498
|
+
"https://example.com/vectors",
|
|
499
|
+
]
|
|
500
|
+
assert documents[1].content == "Open-source vector similarity search for Postgres."
|
|
501
|
+
assert documents[1].meta["source"] == "GitHub"
|
|
502
|
+
assert documents[2].content == ""
|
|
503
|
+
assert links == ["https://github.com/pgvector/pgvector", "https://example.com/vectors"]
|
|
504
|
+
|
|
505
|
+
def test_parse_ai_answer_includes_web_results_after_references(self) -> None:
|
|
506
|
+
response = {
|
|
507
|
+
**EXAMPLE_AI_ANSWER_RESPONSE,
|
|
508
|
+
"search_parameters": {"engine": "google_ai_mode", "q": "What is pgvector?"},
|
|
509
|
+
"web_results": [
|
|
510
|
+
{
|
|
511
|
+
"title": "pgvector on GitHub",
|
|
512
|
+
"link": "https://github.com/pgvector/pgvector",
|
|
513
|
+
"displayed_link": "github.com",
|
|
514
|
+
"source": "GitHub",
|
|
515
|
+
"snippet": "Already cited as a reference link.",
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
"title": "A web result",
|
|
519
|
+
"link": "https://example.com/web",
|
|
520
|
+
"displayed_link": "example.com",
|
|
521
|
+
"source": "Example",
|
|
522
|
+
"snippet": "Web snippet.",
|
|
523
|
+
},
|
|
524
|
+
],
|
|
525
|
+
}
|
|
526
|
+
documents, links = SearchApiWebSearch._parse_ai_answer(response)
|
|
527
|
+
|
|
528
|
+
# A web result that is already a reference link is skipped in both documents and links.
|
|
529
|
+
assert links == [
|
|
530
|
+
"https://github.com/pgvector/pgvector",
|
|
531
|
+
"https://example.com/vectors",
|
|
532
|
+
"https://example.com/web",
|
|
533
|
+
]
|
|
534
|
+
assert [doc.meta.get("link") for doc in documents].count("https://github.com/pgvector/pgvector") == 1
|
|
535
|
+
assert documents[-1].meta["link"] == "https://example.com/web"
|
|
536
|
+
assert documents[-1].content == "Web snippet."
|
|
537
|
+
|
|
538
|
+
def test_parse_ai_answer_without_answer_or_references(self) -> None:
|
|
539
|
+
documents, links = SearchApiWebSearch._parse_ai_answer(
|
|
540
|
+
{"search_parameters": {"engine": "gemini", "q": "nothing"}, "reference_links": []}
|
|
541
|
+
)
|
|
542
|
+
assert documents == []
|
|
543
|
+
assert links == []
|
|
544
|
+
|
|
545
|
+
@pytest.mark.usefixtures("mock_searchapi_ai_answer_result")
|
|
546
|
+
def test_web_search_ai_answer_engine(self) -> None:
|
|
547
|
+
ws = SearchApiWebSearch(
|
|
548
|
+
top_k=2, api_key=Secret.from_token("test-api-key"), search_params={"engine": "google_ai_mode"}
|
|
549
|
+
)
|
|
550
|
+
results = ws.run(query="What is pgvector?")
|
|
551
|
+
|
|
552
|
+
documents = results["documents"]
|
|
553
|
+
assert len(documents) == 2
|
|
554
|
+
assert documents[0].meta["type"] == "ai_answer"
|
|
555
|
+
assert documents[1].meta["link"] == "https://github.com/pgvector/pgvector"
|
|
556
|
+
assert results["links"] == ["https://github.com/pgvector/pgvector", "https://example.com/vectors"]
|
|
557
|
+
|
|
447
558
|
@pytest.mark.parametrize("top_k", [1, 5, 7])
|
|
448
559
|
@pytest.mark.usefixtures("mock_searchapi_search_result")
|
|
449
560
|
def test_web_search_top_k(self, top_k: int) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|