huggingface-api-haystack 0.4.0__tar.gz → 0.6.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.
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/CHANGELOG.md +12 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/PKG-INFO +8 -4
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/README.md +5 -2
- huggingface_api_haystack-0.6.0/docker-compose.yml +14 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/pydoc/config_docusaurus.yml +2 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/pyproject.toml +2 -1
- huggingface_api_haystack-0.6.0/src/haystack_integrations/components/embedders/huggingface_api/__init__.py +14 -0
- huggingface_api_haystack-0.6.0/src/haystack_integrations/components/embedders/huggingface_api/sparse_document_embedder.py +199 -0
- huggingface_api_haystack-0.6.0/src/haystack_integrations/components/embedders/huggingface_api/sparse_embedding_utils.py +72 -0
- huggingface_api_haystack-0.6.0/src/haystack_integrations/components/embedders/huggingface_api/sparse_text_embedder.py +116 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/generators/huggingface_api/chat/chat_generator.py +6 -2
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/rankers/huggingface_api/ranker.py +1 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/tests/test_chat_generator.py +29 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/tests/test_ranker.py +8 -0
- huggingface_api_haystack-0.6.0/tests/test_sparse_document_embedder.py +342 -0
- huggingface_api_haystack-0.6.0/tests/test_sparse_text_embedder.py +263 -0
- huggingface_api_haystack-0.4.0/src/haystack_integrations/components/embedders/huggingface_api/__init__.py +0 -7
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/.gitignore +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/LICENSE.txt +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/common/huggingface_api/__init__.py +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/common/huggingface_api/utils.py +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/common/py.typed +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/embedders/huggingface_api/document_embedder.py +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/embedders/huggingface_api/text_embedder.py +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/embedders/py.typed +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/generators/huggingface_api/__init__.py +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/generators/huggingface_api/chat/__init__.py +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/generators/py.typed +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/rankers/huggingface_api/__init__.py +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/rankers/py.typed +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/tests/__init__.py +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/tests/conftest.py +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/tests/test_document_embedder.py +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/tests/test_files/apple.jpg +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/tests/test_text_embedder.py +0 -0
- {huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/tests/test_utils.py +0 -0
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [unreleased]
|
|
4
|
+
|
|
5
|
+
### 🧪 Testing
|
|
6
|
+
|
|
7
|
+
- Trust test modules under Haystack 3.0's deserialization allowlist (#3537)
|
|
8
|
+
- Make Tool/Agent serialization assertions version-agnostic for Haystack 2.x/3.x (#3533)
|
|
9
|
+
|
|
10
|
+
### 🧹 Chores
|
|
11
|
+
|
|
12
|
+
- Support sync streaming callbacks in async contexts for Haystack 2.x/3.x compatibility (#3534)
|
|
13
|
+
|
|
14
|
+
|
|
3
15
|
## [integrations/huggingface_api-v0.3.0] - 2026-06-26
|
|
4
16
|
|
|
5
17
|
### 🐛 Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: huggingface-api-haystack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Haystack integration for Hugging Face API
|
|
5
5
|
Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/huggingface_api#readme
|
|
6
6
|
Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
|
|
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
|
20
20
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
21
21
|
Requires-Python: >=3.10
|
|
22
22
|
Requires-Dist: haystack-ai>=2.30.0
|
|
23
|
+
Requires-Dist: httpx>=0.27.0
|
|
23
24
|
Requires-Dist: huggingface-hub[inference]>=0.35.0
|
|
24
25
|
Description-Content-Type: text/markdown
|
|
25
26
|
|
|
@@ -28,7 +29,7 @@ Description-Content-Type: text/markdown
|
|
|
28
29
|
[](https://pypi.org/project/huggingface-api-haystack)
|
|
29
30
|
[](https://pypi.org/project/huggingface-api-haystack)
|
|
30
31
|
|
|
31
|
-
- [Integration page](https://haystack.deepset.ai/integrations/
|
|
32
|
+
- [Integration page](https://haystack.deepset.ai/integrations/huggingface-api)
|
|
32
33
|
- [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/huggingface_api/CHANGELOG.md)
|
|
33
34
|
|
|
34
35
|
---
|
|
@@ -37,4 +38,7 @@ Description-Content-Type: text/markdown
|
|
|
37
38
|
|
|
38
39
|
Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
|
|
39
40
|
|
|
40
|
-
To run integration tests locally, you need
|
|
41
|
+
To run integration tests locally, you need a Docker container running a Text Embeddings Inference (TEI) server with
|
|
42
|
+
a sparse embedding model. Use the provided `docker-compose.yml` file to start the container: `docker compose up -d`.
|
|
43
|
+
|
|
44
|
+
The tests that call the Hugging Face Inference API are skipped unless `HF_TOKEN` is set as an evironment variable.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://pypi.org/project/huggingface-api-haystack)
|
|
4
4
|
[](https://pypi.org/project/huggingface-api-haystack)
|
|
5
5
|
|
|
6
|
-
- [Integration page](https://haystack.deepset.ai/integrations/
|
|
6
|
+
- [Integration page](https://haystack.deepset.ai/integrations/huggingface-api)
|
|
7
7
|
- [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/huggingface_api/CHANGELOG.md)
|
|
8
8
|
|
|
9
9
|
---
|
|
@@ -12,4 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
|
|
14
14
|
|
|
15
|
-
To run integration tests locally, you need
|
|
15
|
+
To run integration tests locally, you need a Docker container running a Text Embeddings Inference (TEI) server with
|
|
16
|
+
a sparse embedding model. Use the provided `docker-compose.yml` file to start the container: `docker compose up -d`.
|
|
17
|
+
|
|
18
|
+
The tests that call the Hugging Face Inference API are skipped unless `HF_TOKEN` is set as an evironment variable.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
services:
|
|
2
|
+
tei-sparse:
|
|
3
|
+
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9.3
|
|
4
|
+
command: ["--model-id", "opensearch-project/opensearch-neural-sparse-encoding-doc-v2-mini", "--pooling", "splade"]
|
|
5
|
+
ports:
|
|
6
|
+
- "8080:80"
|
|
7
|
+
environment:
|
|
8
|
+
- HF_TOKEN
|
|
9
|
+
volumes:
|
|
10
|
+
- tei-data:/data
|
|
11
|
+
restart: no
|
|
12
|
+
|
|
13
|
+
volumes:
|
|
14
|
+
tei-data:
|
{huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/pydoc/config_docusaurus.yml
RENAMED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
loaders:
|
|
2
2
|
- modules:
|
|
3
3
|
- haystack_integrations.components.embedders.huggingface_api.document_embedder
|
|
4
|
+
- haystack_integrations.components.embedders.huggingface_api.sparse_document_embedder
|
|
5
|
+
- haystack_integrations.components.embedders.huggingface_api.sparse_text_embedder
|
|
4
6
|
- haystack_integrations.components.embedders.huggingface_api.text_embedder
|
|
5
7
|
- haystack_integrations.components.generators.huggingface_api.chat.chat_generator
|
|
6
8
|
- haystack_integrations.components.rankers.huggingface_api.ranker
|
|
@@ -23,7 +23,7 @@ classifiers = [
|
|
|
23
23
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
24
24
|
"Programming Language :: Python :: Implementation :: PyPy",
|
|
25
25
|
]
|
|
26
|
-
dependencies = ["haystack-ai>=2.30.0", "huggingface_hub[inference]>=0.35.0"]
|
|
26
|
+
dependencies = ["haystack-ai>=2.30.0", "httpx>=0.27.0", "huggingface_hub[inference]>=0.35.0"]
|
|
27
27
|
|
|
28
28
|
[project.urls]
|
|
29
29
|
Documentation = "https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/huggingface_api#readme"
|
|
@@ -132,6 +132,7 @@ ignore = [
|
|
|
132
132
|
"PLR0912",
|
|
133
133
|
"PLR0913",
|
|
134
134
|
"PLR0915",
|
|
135
|
+
"PLR0917",
|
|
135
136
|
# Allow `Any` type - used legitimately for dynamic types and SDK boundaries
|
|
136
137
|
"ANN401",
|
|
137
138
|
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
from .document_embedder import HuggingFaceAPIDocumentEmbedder
|
|
5
|
+
from .sparse_document_embedder import HuggingFaceAPISparseDocumentEmbedder
|
|
6
|
+
from .sparse_text_embedder import HuggingFaceAPISparseTextEmbedder
|
|
7
|
+
from .text_embedder import HuggingFaceAPITextEmbedder
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"HuggingFaceAPIDocumentEmbedder",
|
|
11
|
+
"HuggingFaceAPISparseDocumentEmbedder",
|
|
12
|
+
"HuggingFaceAPISparseTextEmbedder",
|
|
13
|
+
"HuggingFaceAPITextEmbedder",
|
|
14
|
+
]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
from asyncio import Semaphore, gather
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from itertools import chain
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from haystack import Document, component, default_from_dict, default_to_dict
|
|
12
|
+
from haystack.dataclasses import SparseEmbedding
|
|
13
|
+
from haystack.utils import Secret
|
|
14
|
+
from haystack.utils.url_validation import is_valid_http_url
|
|
15
|
+
from tqdm import tqdm
|
|
16
|
+
|
|
17
|
+
from .sparse_embedding_utils import _build_client_kwargs, _embed_sparse, _embed_sparse_async
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@component
|
|
21
|
+
class HuggingFaceAPISparseDocumentEmbedder:
|
|
22
|
+
"""
|
|
23
|
+
Embeds Documents into sparse vectors using a Hugging Face Text Embeddings Inference (TEI) server.
|
|
24
|
+
|
|
25
|
+
The component batches requests and returns copies of the input Documents with `sparse_embedding` set.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from haystack import Document
|
|
29
|
+
from haystack_integrations.components.embedders.huggingface_api import HuggingFaceAPISparseDocumentEmbedder
|
|
30
|
+
|
|
31
|
+
embedder = HuggingFaceAPISparseDocumentEmbedder(api_base_url="http://localhost:8080")
|
|
32
|
+
documents = embedder.run([Document(content="Sparse retrieval")])["documents"]
|
|
33
|
+
print(documents[0].sparse_embedding)
|
|
34
|
+
```
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
api_base_url: str = "http://localhost:8080",
|
|
41
|
+
token: Secret | None = Secret.from_env_var(["HF_API_TOKEN", "HF_TOKEN"], strict=False),
|
|
42
|
+
prefix: str = "",
|
|
43
|
+
suffix: str = "",
|
|
44
|
+
batch_size: int = 32,
|
|
45
|
+
progress_bar: bool = True,
|
|
46
|
+
meta_fields_to_embed: list[str] | None = None,
|
|
47
|
+
embedding_separator: str = "\n",
|
|
48
|
+
timeout: float | None = 30.0,
|
|
49
|
+
headers: dict[str, str] | None = None,
|
|
50
|
+
concurrency_limit: int = 4,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Create a sparse Document embedder backed by TEI.
|
|
54
|
+
|
|
55
|
+
:param api_base_url: Base URL of the TEI server.
|
|
56
|
+
:param token: Token sent to TEI as HTTP bearer authorization, if set.
|
|
57
|
+
:param prefix: A string to add before each prepared Document text.
|
|
58
|
+
:param suffix: A string to add after each prepared Document text.
|
|
59
|
+
:param batch_size: Number of Documents sent in each request.
|
|
60
|
+
:param progress_bar: If `True`, show a progress bar while embedding.
|
|
61
|
+
:param meta_fields_to_embed: Metadata fields to embed before the Document content.
|
|
62
|
+
:param embedding_separator: Separator for metadata fields and Document content.
|
|
63
|
+
:param timeout: HTTP request timeout in seconds. Set to `None` to disable it.
|
|
64
|
+
:param headers: Additional HTTP headers to send with each request.
|
|
65
|
+
:param concurrency_limit: Maximum concurrent requests made by `run_async`.
|
|
66
|
+
:raises ValueError: If `api_base_url` is invalid or a numeric parameter is not positive.
|
|
67
|
+
"""
|
|
68
|
+
if not is_valid_http_url(api_base_url):
|
|
69
|
+
msg = f"api_base_url must be a valid HTTP URL, but got {api_base_url}"
|
|
70
|
+
raise ValueError(msg)
|
|
71
|
+
if batch_size <= 0:
|
|
72
|
+
msg = f"batch_size must be > 0, but got {batch_size}"
|
|
73
|
+
raise ValueError(msg)
|
|
74
|
+
if concurrency_limit <= 0:
|
|
75
|
+
msg = f"concurrency_limit must be > 0, but got {concurrency_limit}"
|
|
76
|
+
raise ValueError(msg)
|
|
77
|
+
|
|
78
|
+
self.api_base_url = api_base_url
|
|
79
|
+
self.token = token
|
|
80
|
+
self.prefix = prefix
|
|
81
|
+
self.suffix = suffix
|
|
82
|
+
self.batch_size = batch_size
|
|
83
|
+
self.progress_bar = progress_bar
|
|
84
|
+
self.meta_fields_to_embed = meta_fields_to_embed or []
|
|
85
|
+
self.embedding_separator = embedding_separator
|
|
86
|
+
self.timeout = timeout
|
|
87
|
+
self.headers = headers or {}
|
|
88
|
+
self.concurrency_limit = concurrency_limit
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict[str, Any]:
|
|
91
|
+
"""Serialize this component to a dictionary."""
|
|
92
|
+
return default_to_dict(
|
|
93
|
+
self,
|
|
94
|
+
api_base_url=self.api_base_url,
|
|
95
|
+
token=self.token,
|
|
96
|
+
prefix=self.prefix,
|
|
97
|
+
suffix=self.suffix,
|
|
98
|
+
batch_size=self.batch_size,
|
|
99
|
+
progress_bar=self.progress_bar,
|
|
100
|
+
meta_fields_to_embed=self.meta_fields_to_embed,
|
|
101
|
+
embedding_separator=self.embedding_separator,
|
|
102
|
+
timeout=self.timeout,
|
|
103
|
+
headers=self.headers,
|
|
104
|
+
concurrency_limit=self.concurrency_limit,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_dict(cls, data: dict[str, Any]) -> "HuggingFaceAPISparseDocumentEmbedder":
|
|
109
|
+
"""Deserialize this component from a dictionary."""
|
|
110
|
+
return default_from_dict(cls, data)
|
|
111
|
+
|
|
112
|
+
def _client_kwargs(self) -> dict[str, Any]:
|
|
113
|
+
return _build_client_kwargs(
|
|
114
|
+
api_base_url=self.api_base_url, timeout=self.timeout, headers=self.headers, token=self.token
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _prepare_texts_to_embed(self, documents: list[Document]) -> list[str]:
|
|
118
|
+
texts = []
|
|
119
|
+
for document in documents:
|
|
120
|
+
meta = [
|
|
121
|
+
str(document.meta[field])
|
|
122
|
+
for field in self.meta_fields_to_embed
|
|
123
|
+
if field in document.meta and document.meta[field] is not None
|
|
124
|
+
]
|
|
125
|
+
texts.append(self.prefix + self.embedding_separator.join([*meta, document.content or ""]) + self.suffix)
|
|
126
|
+
return texts
|
|
127
|
+
|
|
128
|
+
def _embed_batches(self, client: httpx.Client, texts: list[str]) -> list[SparseEmbedding]:
|
|
129
|
+
embeddings = []
|
|
130
|
+
for start in tqdm(
|
|
131
|
+
range(0, len(texts), self.batch_size),
|
|
132
|
+
disable=not self.progress_bar,
|
|
133
|
+
desc="Calculating sparse embeddings",
|
|
134
|
+
):
|
|
135
|
+
embeddings.extend(_embed_sparse(client=client, inputs=texts[start : start + self.batch_size]))
|
|
136
|
+
return embeddings
|
|
137
|
+
|
|
138
|
+
async def _embed_batches_async(self, client: httpx.AsyncClient, texts: list[str]) -> list[SparseEmbedding]:
|
|
139
|
+
semaphore = Semaphore(self.concurrency_limit)
|
|
140
|
+
batches = [texts[start : start + self.batch_size] for start in range(0, len(texts), self.batch_size)]
|
|
141
|
+
progress = tqdm(total=len(batches), disable=not self.progress_bar, desc="Calculating sparse embeddings")
|
|
142
|
+
|
|
143
|
+
async def embed_batch(batch: list[str]) -> list[SparseEmbedding]:
|
|
144
|
+
async with semaphore:
|
|
145
|
+
result = await _embed_sparse_async(client=client, inputs=batch)
|
|
146
|
+
progress.update(1)
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
return list(chain.from_iterable(await gather(*(embed_batch(batch) for batch in batches))))
|
|
151
|
+
finally:
|
|
152
|
+
progress.close()
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _validate_documents(documents: list[Document]) -> None:
|
|
156
|
+
if not isinstance(documents, list) or any(not isinstance(document, Document) for document in documents):
|
|
157
|
+
msg = (
|
|
158
|
+
"HuggingFaceAPISparseDocumentEmbedder expects a list of Documents as input. "
|
|
159
|
+
"To embed a string, use HuggingFaceAPISparseTextEmbedder."
|
|
160
|
+
)
|
|
161
|
+
raise TypeError(msg)
|
|
162
|
+
|
|
163
|
+
@component.output_types(documents=list[Document])
|
|
164
|
+
def run(self, documents: list[Document]) -> dict[str, list[Document]]:
|
|
165
|
+
"""
|
|
166
|
+
Embed a list of Documents.
|
|
167
|
+
|
|
168
|
+
:param documents: Documents to embed.
|
|
169
|
+
:returns: Copies of the Documents with sparse embeddings.
|
|
170
|
+
"""
|
|
171
|
+
self._validate_documents(documents)
|
|
172
|
+
texts = self._prepare_texts_to_embed(documents)
|
|
173
|
+
with httpx.Client(**self._client_kwargs()) as client:
|
|
174
|
+
embeddings = self._embed_batches(client, texts)
|
|
175
|
+
return {
|
|
176
|
+
"documents": [
|
|
177
|
+
replace(document, sparse_embedding=embedding)
|
|
178
|
+
for document, embedding in zip(documents, embeddings, strict=True)
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@component.output_types(documents=list[Document])
|
|
183
|
+
async def run_async(self, documents: list[Document]) -> dict[str, list[Document]]:
|
|
184
|
+
"""
|
|
185
|
+
Embed a list of Documents asynchronously.
|
|
186
|
+
|
|
187
|
+
:param documents: Documents to embed.
|
|
188
|
+
:returns: Copies of the Documents with sparse embeddings.
|
|
189
|
+
"""
|
|
190
|
+
self._validate_documents(documents)
|
|
191
|
+
texts = self._prepare_texts_to_embed(documents)
|
|
192
|
+
async with httpx.AsyncClient(**self._client_kwargs()) as client:
|
|
193
|
+
embeddings = await self._embed_batches_async(client, texts)
|
|
194
|
+
return {
|
|
195
|
+
"documents": [
|
|
196
|
+
replace(document, sparse_embedding=embedding)
|
|
197
|
+
for document, embedding in zip(documents, embeddings, strict=True)
|
|
198
|
+
]
|
|
199
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from haystack.dataclasses import SparseEmbedding
|
|
9
|
+
from haystack.utils import Secret
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _build_client_kwargs(
|
|
13
|
+
*, api_base_url: str, timeout: float | None, headers: dict[str, str], token: Secret | None
|
|
14
|
+
) -> dict[str, Any]:
|
|
15
|
+
"""
|
|
16
|
+
Build the `httpx` client options shared by the sync and async clients.
|
|
17
|
+
|
|
18
|
+
`headers` is applied after the token, so an `Authorization` header passed at initialization wins over the one
|
|
19
|
+
derived from `token`. Otherwise, an explicit header would be silently replaced whenever `HF_TOKEN` happens to be
|
|
20
|
+
set in the environment.
|
|
21
|
+
"""
|
|
22
|
+
request_headers: dict[str, str] = {}
|
|
23
|
+
if token and (token_value := token.resolve_value()):
|
|
24
|
+
request_headers["Authorization"] = f"Bearer {token_value}"
|
|
25
|
+
request_headers.update(headers)
|
|
26
|
+
return {"base_url": f"{api_base_url.rstrip('/')}/", "timeout": timeout, "headers": request_headers}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_sparse_embeddings(result: Any, expected_count: int) -> list[SparseEmbedding]:
|
|
30
|
+
if not isinstance(result, list) or len(result) != expected_count:
|
|
31
|
+
msg = f"Expected one sparse embedding per input ({expected_count}), got {result!r}"
|
|
32
|
+
raise ValueError(msg)
|
|
33
|
+
|
|
34
|
+
embeddings = []
|
|
35
|
+
for sparse_values in result:
|
|
36
|
+
if not isinstance(sparse_values, list):
|
|
37
|
+
msg = f"Expected each sparse embedding to be a list, got {sparse_values!r}"
|
|
38
|
+
raise ValueError(msg)
|
|
39
|
+
|
|
40
|
+
indices = []
|
|
41
|
+
values = []
|
|
42
|
+
for sparse_value in sparse_values:
|
|
43
|
+
if not isinstance(sparse_value, dict) or "index" not in sparse_value or "value" not in sparse_value:
|
|
44
|
+
msg = f"Expected sparse values with 'index' and 'value' fields, got {sparse_value!r}"
|
|
45
|
+
raise ValueError(msg)
|
|
46
|
+
index = sparse_value["index"]
|
|
47
|
+
value = sparse_value["value"]
|
|
48
|
+
if (
|
|
49
|
+
not isinstance(index, int)
|
|
50
|
+
or isinstance(index, bool)
|
|
51
|
+
or not isinstance(value, int | float)
|
|
52
|
+
or isinstance(value, bool)
|
|
53
|
+
):
|
|
54
|
+
msg = f"Invalid sparse value returned by TEI: {sparse_value!r}"
|
|
55
|
+
raise ValueError(msg)
|
|
56
|
+
indices.append(index)
|
|
57
|
+
values.append(float(value))
|
|
58
|
+
|
|
59
|
+
embeddings.append(SparseEmbedding(indices=indices, values=values))
|
|
60
|
+
return embeddings
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _embed_sparse(*, client: httpx.Client, inputs: str | list[str]) -> list[SparseEmbedding]:
|
|
64
|
+
response = client.post("embed_sparse", json={"inputs": inputs})
|
|
65
|
+
response.raise_for_status()
|
|
66
|
+
return _parse_sparse_embeddings(response.json(), 1 if isinstance(inputs, str) else len(inputs))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _embed_sparse_async(*, client: httpx.AsyncClient, inputs: str | list[str]) -> list[SparseEmbedding]:
|
|
70
|
+
response = await client.post("embed_sparse", json={"inputs": inputs})
|
|
71
|
+
response.raise_for_status()
|
|
72
|
+
return _parse_sparse_embeddings(response.json(), 1 if isinstance(inputs, str) else len(inputs))
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from haystack import component, default_from_dict, default_to_dict
|
|
9
|
+
from haystack.dataclasses import SparseEmbedding
|
|
10
|
+
from haystack.utils import Secret
|
|
11
|
+
from haystack.utils.url_validation import is_valid_http_url
|
|
12
|
+
|
|
13
|
+
from .sparse_embedding_utils import _build_client_kwargs, _embed_sparse, _embed_sparse_async
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@component
|
|
17
|
+
class HuggingFaceAPISparseTextEmbedder:
|
|
18
|
+
"""
|
|
19
|
+
Embeds text into a sparse vector using a Hugging Face Text Embeddings Inference (TEI) server.
|
|
20
|
+
|
|
21
|
+
The TEI server must be running a sparse embedding model and expose the `/embed_sparse` endpoint.
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from haystack_integrations.components.embedders.huggingface_api import HuggingFaceAPISparseTextEmbedder
|
|
25
|
+
|
|
26
|
+
embedder = HuggingFaceAPISparseTextEmbedder(api_base_url="http://localhost:8080")
|
|
27
|
+
result = embedder.run("What is sparse retrieval?")
|
|
28
|
+
print(result["sparse_embedding"])
|
|
29
|
+
```
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
api_base_url: str = "http://localhost:8080",
|
|
36
|
+
token: Secret | None = Secret.from_env_var(["HF_API_TOKEN", "HF_TOKEN"], strict=False),
|
|
37
|
+
prefix: str = "",
|
|
38
|
+
suffix: str = "",
|
|
39
|
+
timeout: float | None = 30.0,
|
|
40
|
+
headers: dict[str, str] | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Create a sparse text embedder backed by TEI.
|
|
44
|
+
|
|
45
|
+
:param api_base_url: Base URL of the TEI server.
|
|
46
|
+
:param token: Token sent to TEI as HTTP bearer authorization, if set.
|
|
47
|
+
:param prefix: A string to add before the text.
|
|
48
|
+
:param suffix: A string to add after the text.
|
|
49
|
+
:param timeout: HTTP request timeout in seconds. Set to `None` to disable it.
|
|
50
|
+
:param headers: Additional HTTP headers to send with each request.
|
|
51
|
+
:raises ValueError: If `api_base_url` is not a valid HTTP URL.
|
|
52
|
+
"""
|
|
53
|
+
if not is_valid_http_url(api_base_url):
|
|
54
|
+
msg = f"api_base_url must be a valid HTTP URL, but got {api_base_url}"
|
|
55
|
+
raise ValueError(msg)
|
|
56
|
+
self.api_base_url = api_base_url
|
|
57
|
+
self.token = token
|
|
58
|
+
self.prefix = prefix
|
|
59
|
+
self.suffix = suffix
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
self.headers = headers or {}
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict[str, Any]:
|
|
64
|
+
"""Serialize this component to a dictionary."""
|
|
65
|
+
return default_to_dict(
|
|
66
|
+
self,
|
|
67
|
+
api_base_url=self.api_base_url,
|
|
68
|
+
token=self.token,
|
|
69
|
+
prefix=self.prefix,
|
|
70
|
+
suffix=self.suffix,
|
|
71
|
+
timeout=self.timeout,
|
|
72
|
+
headers=self.headers,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_dict(cls, data: dict[str, Any]) -> "HuggingFaceAPISparseTextEmbedder":
|
|
77
|
+
"""Deserialize this component from a dictionary."""
|
|
78
|
+
return default_from_dict(cls, data)
|
|
79
|
+
|
|
80
|
+
def _client_kwargs(self) -> dict[str, Any]:
|
|
81
|
+
return _build_client_kwargs(
|
|
82
|
+
api_base_url=self.api_base_url, timeout=self.timeout, headers=self.headers, token=self.token
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def _prepare_input(self, text: str) -> str:
|
|
86
|
+
if not isinstance(text, str):
|
|
87
|
+
msg = (
|
|
88
|
+
"HuggingFaceAPISparseTextEmbedder expects a string as input. "
|
|
89
|
+
"To embed Documents, use HuggingFaceAPISparseDocumentEmbedder."
|
|
90
|
+
)
|
|
91
|
+
raise TypeError(msg)
|
|
92
|
+
return self.prefix + text + self.suffix
|
|
93
|
+
|
|
94
|
+
@component.output_types(sparse_embedding=SparseEmbedding)
|
|
95
|
+
def run(self, text: str) -> dict[str, SparseEmbedding]:
|
|
96
|
+
"""
|
|
97
|
+
Embed a single string.
|
|
98
|
+
|
|
99
|
+
:param text: Text to embed.
|
|
100
|
+
:returns: The sparse embedding of the input text.
|
|
101
|
+
"""
|
|
102
|
+
with httpx.Client(**self._client_kwargs()) as client:
|
|
103
|
+
embeddings = _embed_sparse(client=client, inputs=self._prepare_input(text))
|
|
104
|
+
return {"sparse_embedding": embeddings[0]}
|
|
105
|
+
|
|
106
|
+
@component.output_types(sparse_embedding=SparseEmbedding)
|
|
107
|
+
async def run_async(self, text: str) -> dict[str, SparseEmbedding]:
|
|
108
|
+
"""
|
|
109
|
+
Embed a single string asynchronously.
|
|
110
|
+
|
|
111
|
+
:param text: Text to embed.
|
|
112
|
+
:returns: The sparse embedding of the input text.
|
|
113
|
+
"""
|
|
114
|
+
async with httpx.AsyncClient(**self._client_kwargs()) as client:
|
|
115
|
+
embeddings = await _embed_sparse_async(client=client, inputs=self._prepare_input(text))
|
|
116
|
+
return {"sparse_embedding": embeddings[0]}
|
|
@@ -505,7 +505,9 @@ class HuggingFaceAPIChatGenerator:
|
|
|
505
505
|
A list of ChatMessage objects representing the input messages. If a string is provided, it is converted
|
|
506
506
|
to a list containing a ChatMessage with user role.
|
|
507
507
|
:param generation_kwargs:
|
|
508
|
-
Additional keyword arguments for text generation.
|
|
508
|
+
Additional keyword arguments for text generation. These are merged per key with the
|
|
509
|
+
`generation_kwargs` passed at initialization: keys provided here take precedence, keys set only
|
|
510
|
+
at initialization are kept.
|
|
509
511
|
:param tools:
|
|
510
512
|
A list of tools or a Toolset for which the model can prepare calls. If set, it will override
|
|
511
513
|
the `tools` parameter set during component initialization. This parameter can accept either a
|
|
@@ -565,7 +567,9 @@ class HuggingFaceAPIChatGenerator:
|
|
|
565
567
|
A list of ChatMessage objects representing the input messages. If a string is provided, it is converted
|
|
566
568
|
to a list containing a ChatMessage with user role.
|
|
567
569
|
:param generation_kwargs:
|
|
568
|
-
Additional keyword arguments for text generation.
|
|
570
|
+
Additional keyword arguments for text generation. These are merged per key with the
|
|
571
|
+
`generation_kwargs` passed at initialization: keys provided here take precedence, keys set only
|
|
572
|
+
at initialization are kept.
|
|
569
573
|
:param tools:
|
|
570
574
|
A list of tools or a Toolset for which the model can prepare calls. If set, it will override the `tools`
|
|
571
575
|
parameter set during component initialization. This parameter can accept either a list of `Tool` objects
|
{huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/tests/test_chat_generator.py
RENAMED
|
@@ -374,6 +374,19 @@ class TestHuggingFaceAPIChatGenerator:
|
|
|
374
374
|
assert len(response["replies"]) == 1
|
|
375
375
|
assert [isinstance(reply, ChatMessage) for reply in response["replies"]]
|
|
376
376
|
|
|
377
|
+
def test_run_with_generation_kwargs(self, mock_check_valid_model, mock_chat_completion, chat_messages):
|
|
378
|
+
generator = HuggingFaceAPIChatGenerator(
|
|
379
|
+
api_type=HFGenerationAPIType.SERVERLESS_INFERENCE_API,
|
|
380
|
+
api_params={"model": "meta-llama/Llama-2-13b-chat-hf"},
|
|
381
|
+
generation_kwargs={"max_tokens": 100, "temperature": 0.5},
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
generator.run(messages=chat_messages, generation_kwargs={"temperature": 0.9})
|
|
385
|
+
|
|
386
|
+
_, kwargs = mock_chat_completion.call_args
|
|
387
|
+
assert kwargs["max_tokens"] == 100
|
|
388
|
+
assert kwargs["temperature"] == 0.9
|
|
389
|
+
|
|
377
390
|
def test_run_with_string_input(self, mock_check_valid_model, mock_chat_completion):
|
|
378
391
|
generator = HuggingFaceAPIChatGenerator(
|
|
379
392
|
api_type=HFGenerationAPIType.SERVERLESS_INFERENCE_API,
|
|
@@ -914,6 +927,22 @@ class TestHuggingFaceAPIChatGenerator:
|
|
|
914
927
|
assert len(response["replies"]) == 1
|
|
915
928
|
assert [isinstance(reply, ChatMessage) for reply in response["replies"]]
|
|
916
929
|
|
|
930
|
+
@pytest.mark.asyncio
|
|
931
|
+
async def test_run_async_with_generation_kwargs(
|
|
932
|
+
self, mock_check_valid_model, mock_chat_completion_async, chat_messages
|
|
933
|
+
):
|
|
934
|
+
generator = HuggingFaceAPIChatGenerator(
|
|
935
|
+
api_type=HFGenerationAPIType.SERVERLESS_INFERENCE_API,
|
|
936
|
+
api_params={"model": "meta-llama/Llama-2-13b-chat-hf"},
|
|
937
|
+
generation_kwargs={"max_tokens": 100, "temperature": 0.5},
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
await generator.run_async(messages=chat_messages, generation_kwargs={"temperature": 0.9})
|
|
941
|
+
|
|
942
|
+
_, kwargs = mock_chat_completion_async.call_args
|
|
943
|
+
assert kwargs["max_tokens"] == 100
|
|
944
|
+
assert kwargs["temperature"] == 0.9
|
|
945
|
+
|
|
917
946
|
@pytest.mark.asyncio
|
|
918
947
|
async def test_run_async_with_string_input(self, mock_check_valid_model, mock_chat_completion_async):
|
|
919
948
|
generator = HuggingFaceAPIChatGenerator(
|
|
@@ -60,6 +60,14 @@ class TestHuggingFaceTEIRanker:
|
|
|
60
60
|
assert data["init_parameters"]["max_retries"] == 4
|
|
61
61
|
assert data["init_parameters"]["retry_status_codes"] == [500, 502]
|
|
62
62
|
|
|
63
|
+
def test_raw_scores_survives_a_serialization_round_trip(self, del_hf_env_vars_if_empty):
|
|
64
|
+
"""raw_scores goes into the request payload, so losing it changes what the API returns."""
|
|
65
|
+
ranker = HuggingFaceTEIRanker(url="https://api.my-tei-service.com", raw_scores=True)
|
|
66
|
+
|
|
67
|
+
restored = HuggingFaceTEIRanker.from_dict(ranker.to_dict())
|
|
68
|
+
|
|
69
|
+
assert restored.raw_scores is True
|
|
70
|
+
|
|
63
71
|
def test_from_dict(self, del_hf_env_vars_if_empty):
|
|
64
72
|
"""Test deserialization from dict with environment variable token"""
|
|
65
73
|
data = {
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from typing import Any
|
|
9
|
+
from unittest.mock import MagicMock, call, patch
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import pytest
|
|
13
|
+
from haystack import Document
|
|
14
|
+
from haystack.dataclasses import SparseEmbedding
|
|
15
|
+
from haystack.utils import Secret
|
|
16
|
+
|
|
17
|
+
from haystack_integrations.components.embedders.huggingface_api import HuggingFaceAPISparseDocumentEmbedder
|
|
18
|
+
|
|
19
|
+
API_BASE_URL = "http://localhost:8080"
|
|
20
|
+
MODULE = "haystack_integrations.components.embedders.huggingface_api.sparse_document_embedder"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sparse_response(data: Any) -> MagicMock:
|
|
24
|
+
response = MagicMock(spec=httpx.Response)
|
|
25
|
+
response.json.return_value = data
|
|
26
|
+
return response
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@contextmanager
|
|
30
|
+
def patched_client(*, is_async: bool = False) -> Iterator[tuple[MagicMock, MagicMock]]:
|
|
31
|
+
"""
|
|
32
|
+
Patch the `httpx` client constructor and yield the (client, constructor) mocks.
|
|
33
|
+
|
|
34
|
+
The component builds a client per call, so tests reach the client through the constructor rather than through
|
|
35
|
+
an attribute. `__enter__`/`__aenter__` yield the same mock, so `client.post` assertions read naturally.
|
|
36
|
+
"""
|
|
37
|
+
name = "AsyncClient" if is_async else "Client"
|
|
38
|
+
client = MagicMock(spec=getattr(httpx, name))
|
|
39
|
+
if is_async:
|
|
40
|
+
client.__aenter__.return_value = client
|
|
41
|
+
client.__aexit__.return_value = False
|
|
42
|
+
else:
|
|
43
|
+
client.__enter__.return_value = client
|
|
44
|
+
client.__exit__.return_value = False
|
|
45
|
+
with patch(f"{MODULE}.httpx.{name}", return_value=client) as constructor:
|
|
46
|
+
yield client, constructor
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestHuggingFaceAPISparseDocumentEmbedder:
|
|
50
|
+
@pytest.mark.parametrize("api_base_url", ["not-a-url", "file:///path", "localhost:8080"])
|
|
51
|
+
def test_init_rejects_invalid_api_base_url(self, api_base_url: str) -> None:
|
|
52
|
+
with pytest.raises(ValueError, match="api_base_url must be a valid HTTP URL"):
|
|
53
|
+
HuggingFaceAPISparseDocumentEmbedder(api_base_url=api_base_url)
|
|
54
|
+
|
|
55
|
+
@pytest.mark.parametrize(("parameter", "value"), [("batch_size", 0), ("batch_size", -2), ("concurrency_limit", 0)])
|
|
56
|
+
def test_init_rejects_non_positive_numeric_options(self, parameter: str, value: int) -> None:
|
|
57
|
+
with pytest.raises(ValueError, match=f"{parameter} must be > 0"):
|
|
58
|
+
HuggingFaceAPISparseDocumentEmbedder(**{parameter: value})
|
|
59
|
+
|
|
60
|
+
def test_init_defaults(self) -> None:
|
|
61
|
+
embedder = HuggingFaceAPISparseDocumentEmbedder()
|
|
62
|
+
|
|
63
|
+
assert embedder.api_base_url == "http://localhost:8080"
|
|
64
|
+
assert embedder.batch_size == 32
|
|
65
|
+
assert embedder.progress_bar is True
|
|
66
|
+
assert embedder.meta_fields_to_embed == []
|
|
67
|
+
assert embedder.embedding_separator == "\n"
|
|
68
|
+
assert embedder.timeout == 30.0
|
|
69
|
+
assert embedder.headers == {}
|
|
70
|
+
assert embedder.concurrency_limit == 4
|
|
71
|
+
|
|
72
|
+
def test_to_dict_and_from_dict_preserve_configuration_and_secret(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
73
|
+
monkeypatch.setenv("CUSTOM_HF_TOKEN", "secret")
|
|
74
|
+
embedder = HuggingFaceAPISparseDocumentEmbedder(
|
|
75
|
+
api_base_url="https://tei.example.test",
|
|
76
|
+
token=Secret.from_env_var("CUSTOM_HF_TOKEN"),
|
|
77
|
+
prefix="passage: ",
|
|
78
|
+
suffix="!",
|
|
79
|
+
batch_size=2,
|
|
80
|
+
progress_bar=False,
|
|
81
|
+
meta_fields_to_embed=["title"],
|
|
82
|
+
embedding_separator=" | ",
|
|
83
|
+
timeout=None,
|
|
84
|
+
headers={"X-Test": "yes"},
|
|
85
|
+
concurrency_limit=3,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
data = embedder.to_dict()
|
|
89
|
+
|
|
90
|
+
assert data["init_parameters"]["token"] == {
|
|
91
|
+
"type": "env_var",
|
|
92
|
+
"env_vars": ["CUSTOM_HF_TOKEN"],
|
|
93
|
+
"strict": True,
|
|
94
|
+
}
|
|
95
|
+
assert data["init_parameters"] == {
|
|
96
|
+
"api_base_url": "https://tei.example.test",
|
|
97
|
+
"token": {"type": "env_var", "env_vars": ["CUSTOM_HF_TOKEN"], "strict": True},
|
|
98
|
+
"prefix": "passage: ",
|
|
99
|
+
"suffix": "!",
|
|
100
|
+
"batch_size": 2,
|
|
101
|
+
"progress_bar": False,
|
|
102
|
+
"meta_fields_to_embed": ["title"],
|
|
103
|
+
"embedding_separator": " | ",
|
|
104
|
+
"timeout": None,
|
|
105
|
+
"headers": {"X-Test": "yes"},
|
|
106
|
+
"concurrency_limit": 3,
|
|
107
|
+
}
|
|
108
|
+
restored = HuggingFaceAPISparseDocumentEmbedder.from_dict(data)
|
|
109
|
+
|
|
110
|
+
assert restored.api_base_url == embedder.api_base_url
|
|
111
|
+
assert restored.prefix == "passage: "
|
|
112
|
+
assert restored.suffix == "!"
|
|
113
|
+
assert restored.batch_size == 2
|
|
114
|
+
assert restored.progress_bar is False
|
|
115
|
+
assert restored.meta_fields_to_embed == ["title"]
|
|
116
|
+
assert restored.embedding_separator == " | "
|
|
117
|
+
assert restored.timeout is None
|
|
118
|
+
assert restored.headers == {"X-Test": "yes"}
|
|
119
|
+
assert restored.concurrency_limit == 3
|
|
120
|
+
assert restored.token is not None and restored.token.resolve_value() == "secret"
|
|
121
|
+
|
|
122
|
+
def test_token_secret_cannot_be_serialized(self) -> None:
|
|
123
|
+
embedder = HuggingFaceAPISparseDocumentEmbedder(token=Secret.from_token("do-not-serialize"))
|
|
124
|
+
|
|
125
|
+
with pytest.raises(ValueError, match="Cannot serialize token-based secret"):
|
|
126
|
+
embedder.to_dict()
|
|
127
|
+
|
|
128
|
+
def test_prepare_texts_handles_metadata_prefix_suffix_and_missing_content(self) -> None:
|
|
129
|
+
documents = [
|
|
130
|
+
Document(content="body", meta={"title": "Title", "priority": 3, "ignored": "no"}),
|
|
131
|
+
Document(content=None, meta={"title": None, "priority": 0}),
|
|
132
|
+
Document(content="only body", meta={}),
|
|
133
|
+
]
|
|
134
|
+
embedder = HuggingFaceAPISparseDocumentEmbedder(
|
|
135
|
+
prefix="<p>", suffix="</p>", meta_fields_to_embed=["title", "priority"], embedding_separator=" | "
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
assert embedder._prepare_texts_to_embed(documents) == [
|
|
139
|
+
"<p>Title | 3 | body</p>",
|
|
140
|
+
"<p>0 | </p>",
|
|
141
|
+
"<p>only body</p>",
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
@pytest.mark.parametrize("documents", [None, "document", [1, 2], [Document(content="valid"), "invalid"]])
|
|
145
|
+
def test_run_rejects_invalid_and_mixed_inputs(self, documents: Any) -> None:
|
|
146
|
+
with pytest.raises(TypeError, match="expects a list of Documents"):
|
|
147
|
+
HuggingFaceAPISparseDocumentEmbedder(progress_bar=False).run(documents)
|
|
148
|
+
|
|
149
|
+
@pytest.mark.asyncio
|
|
150
|
+
async def test_run_async_rejects_mixed_inputs(self) -> None:
|
|
151
|
+
with pytest.raises(TypeError, match="expects a list of Documents"):
|
|
152
|
+
await HuggingFaceAPISparseDocumentEmbedder(progress_bar=False).run_async([Document(content="ok"), None])
|
|
153
|
+
|
|
154
|
+
def test_empty_list_returns_without_http_request(self) -> None:
|
|
155
|
+
embedder = HuggingFaceAPISparseDocumentEmbedder(progress_bar=False)
|
|
156
|
+
|
|
157
|
+
with patched_client() as (client, _):
|
|
158
|
+
result = embedder.run([])
|
|
159
|
+
|
|
160
|
+
assert result == {"documents": []}
|
|
161
|
+
client.post.assert_not_called()
|
|
162
|
+
|
|
163
|
+
@pytest.mark.asyncio
|
|
164
|
+
async def test_empty_list_async_returns_without_http_request(self) -> None:
|
|
165
|
+
embedder = HuggingFaceAPISparseDocumentEmbedder(progress_bar=False)
|
|
166
|
+
|
|
167
|
+
with patched_client(is_async=True) as (client, _):
|
|
168
|
+
result = await embedder.run_async([])
|
|
169
|
+
|
|
170
|
+
assert result == {"documents": []}
|
|
171
|
+
client.post.assert_not_called()
|
|
172
|
+
|
|
173
|
+
def test_run_batches_requests_preserves_order_and_copies_documents(self) -> None:
|
|
174
|
+
documents = [Document(content=f"doc {number}", meta={"number": number}) for number in range(5)]
|
|
175
|
+
responses = [
|
|
176
|
+
sparse_response([[{"index": 10, "value": 1}], [{"index": 11, "value": 2}]]),
|
|
177
|
+
sparse_response([[{"index": 12, "value": 3}], [{"index": 13, "value": 4}]]),
|
|
178
|
+
sparse_response([[{"index": 14, "value": 5}]]),
|
|
179
|
+
]
|
|
180
|
+
embedder = HuggingFaceAPISparseDocumentEmbedder(
|
|
181
|
+
api_base_url="http://tei:80/root/",
|
|
182
|
+
token=Secret.from_token("token"),
|
|
183
|
+
prefix="passage: ",
|
|
184
|
+
batch_size=2,
|
|
185
|
+
progress_bar=False,
|
|
186
|
+
timeout=6,
|
|
187
|
+
headers={"X-Tenant": "tenant"},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
with patched_client() as (client, constructor):
|
|
191
|
+
client.post.side_effect = responses
|
|
192
|
+
result = embedder.run(documents)
|
|
193
|
+
|
|
194
|
+
constructor.assert_called_once_with(
|
|
195
|
+
base_url="http://tei:80/root/",
|
|
196
|
+
timeout=6,
|
|
197
|
+
headers={"Authorization": "Bearer token", "X-Tenant": "tenant"},
|
|
198
|
+
)
|
|
199
|
+
assert client.post.call_args_list == [
|
|
200
|
+
call("embed_sparse", json={"inputs": ["passage: doc 0", "passage: doc 1"]}),
|
|
201
|
+
call("embed_sparse", json={"inputs": ["passage: doc 2", "passage: doc 3"]}),
|
|
202
|
+
call("embed_sparse", json={"inputs": ["passage: doc 4"]}),
|
|
203
|
+
]
|
|
204
|
+
output = result["documents"]
|
|
205
|
+
assert [document.sparse_embedding.indices for document in output] == [[10], [11], [12], [13], [14]]
|
|
206
|
+
assert [document.sparse_embedding.values for document in output] == [[1.0], [2.0], [3.0], [4.0], [5.0]]
|
|
207
|
+
assert all(new is not original for original, new in zip(documents, output, strict=True))
|
|
208
|
+
assert all(original.sparse_embedding is None for original in documents)
|
|
209
|
+
assert [document.meta for document in output] == [document.meta for document in documents]
|
|
210
|
+
|
|
211
|
+
@pytest.mark.asyncio
|
|
212
|
+
async def test_run_async_batches_with_one_client_and_preserves_batch_order(self) -> None:
|
|
213
|
+
async def post(_url: str, *, json: dict[str, list[str]]) -> MagicMock:
|
|
214
|
+
inputs = json["inputs"]
|
|
215
|
+
# Let the second batch finish first; gather must still preserve input order.
|
|
216
|
+
await asyncio.sleep(0.01 if inputs[0] == "doc 0" else 0)
|
|
217
|
+
offset = int(inputs[0].split()[-1])
|
|
218
|
+
return sparse_response([[{"index": offset + position, "value": 1}] for position, _ in enumerate(inputs)])
|
|
219
|
+
|
|
220
|
+
documents = [Document(content=f"doc {number}") for number in range(4)]
|
|
221
|
+
embedder = HuggingFaceAPISparseDocumentEmbedder(
|
|
222
|
+
api_base_url="https://tei.test/", batch_size=2, progress_bar=False, timeout=None, headers={"X-Test": "yes"}
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
with patched_client(is_async=True) as (client, constructor):
|
|
226
|
+
client.post.side_effect = post
|
|
227
|
+
result = await embedder.run_async(documents)
|
|
228
|
+
|
|
229
|
+
# Both batches share the single client opened for this call.
|
|
230
|
+
constructor.assert_called_once_with(base_url="https://tei.test/", timeout=None, headers={"X-Test": "yes"})
|
|
231
|
+
assert client.post.await_args_list == [
|
|
232
|
+
call("embed_sparse", json={"inputs": ["doc 0", "doc 1"]}),
|
|
233
|
+
call("embed_sparse", json={"inputs": ["doc 2", "doc 3"]}),
|
|
234
|
+
]
|
|
235
|
+
assert [document.sparse_embedding.indices for document in result["documents"]] == [[0], [1], [2], [3]]
|
|
236
|
+
|
|
237
|
+
@pytest.mark.asyncio
|
|
238
|
+
async def test_run_async_builds_and_closes_a_client_per_call(self) -> None:
|
|
239
|
+
"""
|
|
240
|
+
The component must not hold on to an `httpx.AsyncClient`.
|
|
241
|
+
|
|
242
|
+
A cached client binds its keep-alive connection pool to the first event loop that used it, so a component
|
|
243
|
+
reused under a second `asyncio.run` would fail with `RuntimeError: Event loop is closed`.
|
|
244
|
+
"""
|
|
245
|
+
embedder = HuggingFaceAPISparseDocumentEmbedder(progress_bar=False)
|
|
246
|
+
|
|
247
|
+
with patched_client(is_async=True) as (client, constructor):
|
|
248
|
+
client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
|
|
249
|
+
await embedder.run_async([Document(content="one")])
|
|
250
|
+
await embedder.run_async([Document(content="two")])
|
|
251
|
+
|
|
252
|
+
assert constructor.call_count == 2
|
|
253
|
+
assert client.__aexit__.await_count == 2
|
|
254
|
+
assert not hasattr(embedder, "_async_client")
|
|
255
|
+
|
|
256
|
+
def test_run_builds_and_closes_a_client_per_call(self) -> None:
|
|
257
|
+
embedder = HuggingFaceAPISparseDocumentEmbedder(progress_bar=False)
|
|
258
|
+
|
|
259
|
+
with patched_client() as (client, constructor):
|
|
260
|
+
client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
|
|
261
|
+
embedder.run([Document(content="one")])
|
|
262
|
+
embedder.run([Document(content="two")])
|
|
263
|
+
|
|
264
|
+
assert constructor.call_count == 2
|
|
265
|
+
assert client.__exit__.call_count == 2
|
|
266
|
+
assert not hasattr(embedder, "_client")
|
|
267
|
+
|
|
268
|
+
def test_explicit_authorization_header_wins_over_token(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
269
|
+
"""An explicit header must not be replaced by a token that only happens to be set in the environment."""
|
|
270
|
+
monkeypatch.delenv("HF_API_TOKEN", raising=False)
|
|
271
|
+
monkeypatch.setenv("HF_TOKEN", "env-token")
|
|
272
|
+
embedder = HuggingFaceAPISparseDocumentEmbedder(progress_bar=False, headers={"Authorization": "Basic test-key"})
|
|
273
|
+
|
|
274
|
+
with patched_client() as (client, constructor):
|
|
275
|
+
client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
|
|
276
|
+
embedder.run([Document(content="one")])
|
|
277
|
+
|
|
278
|
+
assert constructor.call_args.kwargs["headers"] == {"Authorization": "Basic test-key"}
|
|
279
|
+
|
|
280
|
+
@pytest.mark.asyncio
|
|
281
|
+
async def test_async_concurrency_limit_is_respected(self) -> None:
|
|
282
|
+
active = 0
|
|
283
|
+
maximum_active = 0
|
|
284
|
+
|
|
285
|
+
async def embed_batch(**kwargs: Any) -> list[SparseEmbedding]:
|
|
286
|
+
nonlocal active, maximum_active
|
|
287
|
+
active += 1
|
|
288
|
+
maximum_active = max(maximum_active, active)
|
|
289
|
+
await asyncio.sleep(0.01)
|
|
290
|
+
active -= 1
|
|
291
|
+
return [SparseEmbedding(indices=[int(text)], values=[1.0]) for text in kwargs["inputs"]]
|
|
292
|
+
|
|
293
|
+
embedder = HuggingFaceAPISparseDocumentEmbedder(batch_size=1, concurrency_limit=2, progress_bar=False)
|
|
294
|
+
client = MagicMock(spec=httpx.AsyncClient)
|
|
295
|
+
with patch(f"{MODULE}._embed_sparse_async", side_effect=embed_batch):
|
|
296
|
+
embeddings = await embedder._embed_batches_async(client, ["0", "1", "2", "3"])
|
|
297
|
+
|
|
298
|
+
assert maximum_active == 2
|
|
299
|
+
assert [embedding.indices for embedding in embeddings] == [[0], [1], [2], [3]]
|
|
300
|
+
|
|
301
|
+
def test_run_rejects_response_with_wrong_embedding_count(self) -> None:
|
|
302
|
+
embedder = HuggingFaceAPISparseDocumentEmbedder(progress_bar=False)
|
|
303
|
+
|
|
304
|
+
with (
|
|
305
|
+
patched_client() as (client, _),
|
|
306
|
+
pytest.raises(ValueError, match="Expected one sparse embedding per input"),
|
|
307
|
+
):
|
|
308
|
+
client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
|
|
309
|
+
embedder.run([Document(content="one"), Document(content="two")])
|
|
310
|
+
|
|
311
|
+
def test_run_propagates_http_error(self) -> None:
|
|
312
|
+
request = httpx.Request("POST", "http://localhost:8080/embed_sparse")
|
|
313
|
+
embedder = HuggingFaceAPISparseDocumentEmbedder(progress_bar=False)
|
|
314
|
+
|
|
315
|
+
with patched_client() as (client, _), pytest.raises(httpx.HTTPStatusError):
|
|
316
|
+
client.post.return_value = httpx.Response(500, request=request)
|
|
317
|
+
embedder.run([Document(content="text")])
|
|
318
|
+
|
|
319
|
+
@pytest.mark.integration
|
|
320
|
+
def test_live_run_tei(self) -> None:
|
|
321
|
+
documents = [Document(content="sparse retrieval"), Document(content="dense retrieval")]
|
|
322
|
+
result = HuggingFaceAPISparseDocumentEmbedder(api_base_url=API_BASE_URL, progress_bar=False).run(documents)
|
|
323
|
+
|
|
324
|
+
documents_with_embeddings = result["documents"]
|
|
325
|
+
assert len(documents_with_embeddings) == len(documents)
|
|
326
|
+
for document in documents_with_embeddings:
|
|
327
|
+
assert isinstance(document.sparse_embedding, SparseEmbedding)
|
|
328
|
+
assert document.sparse_embedding.indices
|
|
329
|
+
|
|
330
|
+
@pytest.mark.integration
|
|
331
|
+
@pytest.mark.asyncio
|
|
332
|
+
async def test_live_run_async_tei(self) -> None:
|
|
333
|
+
documents = [Document(content="sparse retrieval"), Document(content="dense retrieval")]
|
|
334
|
+
result = await HuggingFaceAPISparseDocumentEmbedder(api_base_url=API_BASE_URL, progress_bar=False).run_async(
|
|
335
|
+
documents
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
documents_with_embeddings = result["documents"]
|
|
339
|
+
assert len(documents_with_embeddings) == len(documents)
|
|
340
|
+
for document in documents_with_embeddings:
|
|
341
|
+
assert isinstance(document.sparse_embedding, SparseEmbedding)
|
|
342
|
+
assert document.sparse_embedding.indices
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import Any
|
|
8
|
+
from unittest.mock import MagicMock, patch
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
from haystack.dataclasses import SparseEmbedding
|
|
13
|
+
from haystack.utils import Secret
|
|
14
|
+
|
|
15
|
+
from haystack_integrations.components.embedders.huggingface_api import HuggingFaceAPISparseTextEmbedder
|
|
16
|
+
|
|
17
|
+
API_BASE_URL = "http://localhost:8080"
|
|
18
|
+
MODULE = "haystack_integrations.components.embedders.huggingface_api.sparse_text_embedder"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def sparse_response(data: Any) -> MagicMock:
|
|
22
|
+
response = MagicMock(spec=httpx.Response)
|
|
23
|
+
response.json.return_value = data
|
|
24
|
+
return response
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@contextmanager
|
|
28
|
+
def patched_client(*, is_async: bool = False) -> Iterator[tuple[MagicMock, MagicMock]]:
|
|
29
|
+
"""
|
|
30
|
+
Patch the `httpx` client constructor and yield the (client, constructor) mocks.
|
|
31
|
+
|
|
32
|
+
The component builds a client per call, so tests reach the client through the constructor rather than through
|
|
33
|
+
an attribute. `__enter__`/`__aenter__` yield the same mock, so `client.post` assertions read naturally.
|
|
34
|
+
"""
|
|
35
|
+
name = "AsyncClient" if is_async else "Client"
|
|
36
|
+
client = MagicMock(spec=getattr(httpx, name))
|
|
37
|
+
if is_async:
|
|
38
|
+
client.__aenter__.return_value = client
|
|
39
|
+
client.__aexit__.return_value = False
|
|
40
|
+
else:
|
|
41
|
+
client.__enter__.return_value = client
|
|
42
|
+
client.__exit__.return_value = False
|
|
43
|
+
with patch(f"{MODULE}.httpx.{name}", return_value=client) as constructor:
|
|
44
|
+
yield client, constructor
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestHuggingFaceAPISparseTextEmbedder:
|
|
48
|
+
@pytest.mark.parametrize("api_base_url", ["not-a-url", "ftp://localhost/path", "localhost:8080"])
|
|
49
|
+
def test_init_rejects_invalid_api_base_url(self, api_base_url: str) -> None:
|
|
50
|
+
with pytest.raises(ValueError, match="api_base_url must be a valid HTTP URL"):
|
|
51
|
+
HuggingFaceAPISparseTextEmbedder(api_base_url=api_base_url)
|
|
52
|
+
|
|
53
|
+
def test_init_defaults(self) -> None:
|
|
54
|
+
embedder = HuggingFaceAPISparseTextEmbedder()
|
|
55
|
+
|
|
56
|
+
assert embedder.api_base_url == "http://localhost:8080"
|
|
57
|
+
assert embedder.prefix == ""
|
|
58
|
+
assert embedder.suffix == ""
|
|
59
|
+
assert embedder.timeout == 30.0
|
|
60
|
+
assert embedder.headers == {}
|
|
61
|
+
|
|
62
|
+
def test_to_dict_and_from_dict_preserve_env_secret(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
63
|
+
monkeypatch.setenv("CUSTOM_HF_TOKEN", "resolved-token")
|
|
64
|
+
embedder = HuggingFaceAPISparseTextEmbedder(
|
|
65
|
+
api_base_url="https://tei.example.test/base/",
|
|
66
|
+
token=Secret.from_env_var("CUSTOM_HF_TOKEN"),
|
|
67
|
+
prefix="query: ",
|
|
68
|
+
suffix="!",
|
|
69
|
+
timeout=None,
|
|
70
|
+
headers={"X-Tenant": "test"},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
data = embedder.to_dict()
|
|
74
|
+
|
|
75
|
+
assert data == {
|
|
76
|
+
"type": "haystack_integrations.components.embedders.huggingface_api.sparse_text_embedder."
|
|
77
|
+
"HuggingFaceAPISparseTextEmbedder",
|
|
78
|
+
"init_parameters": {
|
|
79
|
+
"api_base_url": "https://tei.example.test/base/",
|
|
80
|
+
"token": {"type": "env_var", "env_vars": ["CUSTOM_HF_TOKEN"], "strict": True},
|
|
81
|
+
"prefix": "query: ",
|
|
82
|
+
"suffix": "!",
|
|
83
|
+
"timeout": None,
|
|
84
|
+
"headers": {"X-Tenant": "test"},
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
restored = HuggingFaceAPISparseTextEmbedder.from_dict(data)
|
|
88
|
+
assert restored.api_base_url == embedder.api_base_url
|
|
89
|
+
assert restored.prefix == "query: "
|
|
90
|
+
assert restored.suffix == "!"
|
|
91
|
+
assert restored.timeout is None
|
|
92
|
+
assert restored.headers == {"X-Tenant": "test"}
|
|
93
|
+
assert restored.token is not None
|
|
94
|
+
assert restored.token.resolve_value() == "resolved-token"
|
|
95
|
+
|
|
96
|
+
def test_token_secret_cannot_be_serialized(self) -> None:
|
|
97
|
+
embedder = HuggingFaceAPISparseTextEmbedder(token=Secret.from_token("do-not-serialize"))
|
|
98
|
+
|
|
99
|
+
with pytest.raises(ValueError, match="Cannot serialize token-based secret"):
|
|
100
|
+
embedder.to_dict()
|
|
101
|
+
|
|
102
|
+
@pytest.mark.parametrize("invalid_text", [None, 42, ["text"]])
|
|
103
|
+
def test_run_rejects_non_string_input(self, invalid_text: Any) -> None:
|
|
104
|
+
embedder = HuggingFaceAPISparseTextEmbedder()
|
|
105
|
+
|
|
106
|
+
with pytest.raises(TypeError, match="expects a string"):
|
|
107
|
+
embedder.run(invalid_text)
|
|
108
|
+
|
|
109
|
+
def test_run_posts_tei_request_and_converts_response(self) -> None:
|
|
110
|
+
embedder = HuggingFaceAPISparseTextEmbedder(
|
|
111
|
+
api_base_url="https://tei.example.test/api/",
|
|
112
|
+
token=Secret.from_token("secret"),
|
|
113
|
+
prefix="query: ",
|
|
114
|
+
suffix=" </s>",
|
|
115
|
+
timeout=4.5,
|
|
116
|
+
headers={"X-Tenant": "one"},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
with patched_client() as (client, constructor):
|
|
120
|
+
response = sparse_response([[{"index": 12, "value": 1}, {"index": 99, "value": 0.25}]])
|
|
121
|
+
client.post.return_value = response
|
|
122
|
+
result = embedder.run("cheese")
|
|
123
|
+
|
|
124
|
+
constructor.assert_called_once_with(
|
|
125
|
+
base_url="https://tei.example.test/api/",
|
|
126
|
+
timeout=4.5,
|
|
127
|
+
headers={"Authorization": "Bearer secret", "X-Tenant": "one"},
|
|
128
|
+
)
|
|
129
|
+
client.post.assert_called_once_with("embed_sparse", json={"inputs": "query: cheese </s>"})
|
|
130
|
+
response.raise_for_status.assert_called_once_with()
|
|
131
|
+
assert result == {"sparse_embedding": SparseEmbedding(indices=[12, 99], values=[1.0, 0.25])}
|
|
132
|
+
|
|
133
|
+
def test_token_is_sent_as_bearer_authorization(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
134
|
+
monkeypatch.delenv("HF_API_TOKEN", raising=False)
|
|
135
|
+
monkeypatch.setenv("HF_TOKEN", "env-token")
|
|
136
|
+
embedder = HuggingFaceAPISparseTextEmbedder()
|
|
137
|
+
|
|
138
|
+
with patched_client() as (client, constructor):
|
|
139
|
+
client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
|
|
140
|
+
embedder.run("text")
|
|
141
|
+
|
|
142
|
+
assert constructor.call_args.kwargs["headers"] == {"Authorization": "Bearer env-token"}
|
|
143
|
+
|
|
144
|
+
def test_explicit_authorization_header_wins_over_token(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
145
|
+
"""An explicit header must not be replaced by a token that only happens to be set in the environment."""
|
|
146
|
+
monkeypatch.delenv("HF_API_TOKEN", raising=False)
|
|
147
|
+
monkeypatch.setenv("HF_TOKEN", "env-token")
|
|
148
|
+
embedder = HuggingFaceAPISparseTextEmbedder(headers={"Authorization": "Basic test-key"})
|
|
149
|
+
|
|
150
|
+
with patched_client() as (client, constructor):
|
|
151
|
+
client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
|
|
152
|
+
embedder.run("text")
|
|
153
|
+
|
|
154
|
+
assert constructor.call_args.kwargs["headers"] == {"Authorization": "Basic test-key"}
|
|
155
|
+
|
|
156
|
+
def test_token_is_resolved_per_call(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
157
|
+
"""The token is read at request time, so a rotated environment variable is picked up."""
|
|
158
|
+
monkeypatch.delenv("HF_API_TOKEN", raising=False)
|
|
159
|
+
monkeypatch.setenv("HF_TOKEN", "first-token")
|
|
160
|
+
embedder = HuggingFaceAPISparseTextEmbedder()
|
|
161
|
+
|
|
162
|
+
with patched_client() as (client, constructor):
|
|
163
|
+
client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
|
|
164
|
+
embedder.run("text")
|
|
165
|
+
monkeypatch.setenv("HF_TOKEN", "second-token")
|
|
166
|
+
embedder.run("text")
|
|
167
|
+
|
|
168
|
+
assert [each.kwargs["headers"]["Authorization"] for each in constructor.call_args_list] == [
|
|
169
|
+
"Bearer first-token",
|
|
170
|
+
"Bearer second-token",
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
@pytest.mark.asyncio
|
|
174
|
+
async def test_run_async_uses_async_client_and_request_options(self) -> None:
|
|
175
|
+
embedder = HuggingFaceAPISparseTextEmbedder(
|
|
176
|
+
api_base_url="http://tei:8080/", token=Secret.from_token("token"), timeout=9, headers={"X-Test": "yes"}
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
with patched_client(is_async=True) as (client, constructor):
|
|
180
|
+
response = sparse_response([[{"index": 7, "value": 2.5}]])
|
|
181
|
+
client.post.return_value = response
|
|
182
|
+
result = await embedder.run_async("input")
|
|
183
|
+
|
|
184
|
+
constructor.assert_called_once_with(
|
|
185
|
+
base_url="http://tei:8080/", timeout=9, headers={"Authorization": "Bearer token", "X-Test": "yes"}
|
|
186
|
+
)
|
|
187
|
+
client.post.assert_awaited_once_with("embed_sparse", json={"inputs": "input"})
|
|
188
|
+
response.raise_for_status.assert_called_once_with()
|
|
189
|
+
assert result["sparse_embedding"] == SparseEmbedding(indices=[7], values=[2.5])
|
|
190
|
+
|
|
191
|
+
@pytest.mark.asyncio
|
|
192
|
+
async def test_run_async_builds_and_closes_a_client_per_call(self) -> None:
|
|
193
|
+
"""
|
|
194
|
+
The component must not hold on to an `httpx.AsyncClient`.
|
|
195
|
+
|
|
196
|
+
A cached client binds its keep-alive connection pool to the first event loop that used it, so a component
|
|
197
|
+
reused under a second `asyncio.run` would fail with `RuntimeError: Event loop is closed`.
|
|
198
|
+
"""
|
|
199
|
+
embedder = HuggingFaceAPISparseTextEmbedder()
|
|
200
|
+
|
|
201
|
+
with patched_client(is_async=True) as (client, constructor):
|
|
202
|
+
client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
|
|
203
|
+
await embedder.run_async("one")
|
|
204
|
+
await embedder.run_async("two")
|
|
205
|
+
|
|
206
|
+
assert constructor.call_count == 2
|
|
207
|
+
assert client.__aexit__.await_count == 2
|
|
208
|
+
assert not hasattr(embedder, "_async_client")
|
|
209
|
+
|
|
210
|
+
def test_run_builds_and_closes_a_client_per_call(self) -> None:
|
|
211
|
+
embedder = HuggingFaceAPISparseTextEmbedder()
|
|
212
|
+
|
|
213
|
+
with patched_client() as (client, constructor):
|
|
214
|
+
client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
|
|
215
|
+
embedder.run("one")
|
|
216
|
+
embedder.run("two")
|
|
217
|
+
|
|
218
|
+
assert constructor.call_count == 2
|
|
219
|
+
assert client.__exit__.call_count == 2
|
|
220
|
+
assert not hasattr(embedder, "_client")
|
|
221
|
+
|
|
222
|
+
@pytest.mark.parametrize(
|
|
223
|
+
"payload",
|
|
224
|
+
[
|
|
225
|
+
{"index": 1, "value": 0.5},
|
|
226
|
+
[],
|
|
227
|
+
["not-a-list"],
|
|
228
|
+
[[{"index": 1}]],
|
|
229
|
+
[[{"index": "1", "value": 0.5}]],
|
|
230
|
+
[[{"index": 1, "value": True}]],
|
|
231
|
+
],
|
|
232
|
+
)
|
|
233
|
+
def test_run_rejects_malformed_tei_responses(self, payload: Any) -> None:
|
|
234
|
+
embedder = HuggingFaceAPISparseTextEmbedder()
|
|
235
|
+
|
|
236
|
+
with patched_client() as (client, _), pytest.raises(ValueError):
|
|
237
|
+
client.post.return_value = sparse_response(payload)
|
|
238
|
+
embedder.run("text")
|
|
239
|
+
|
|
240
|
+
def test_run_propagates_http_error(self) -> None:
|
|
241
|
+
request = httpx.Request("POST", "http://localhost:8080/embed_sparse")
|
|
242
|
+
embedder = HuggingFaceAPISparseTextEmbedder()
|
|
243
|
+
|
|
244
|
+
with patched_client() as (client, _), pytest.raises(httpx.HTTPStatusError) as exc_info:
|
|
245
|
+
client.post.return_value = httpx.Response(503, request=request)
|
|
246
|
+
embedder.run("text")
|
|
247
|
+
|
|
248
|
+
assert exc_info.value.response.status_code == 503
|
|
249
|
+
|
|
250
|
+
@pytest.mark.integration
|
|
251
|
+
def test_live_run_tei(self) -> None:
|
|
252
|
+
result = HuggingFaceAPISparseTextEmbedder(api_base_url=API_BASE_URL).run("sparse retrieval")
|
|
253
|
+
|
|
254
|
+
assert isinstance(result["sparse_embedding"], SparseEmbedding)
|
|
255
|
+
assert result["sparse_embedding"].indices
|
|
256
|
+
|
|
257
|
+
@pytest.mark.integration
|
|
258
|
+
@pytest.mark.asyncio
|
|
259
|
+
async def test_live_run_async_tei(self) -> None:
|
|
260
|
+
result = await HuggingFaceAPISparseTextEmbedder(api_base_url=API_BASE_URL).run_async("sparse retrieval")
|
|
261
|
+
|
|
262
|
+
assert isinstance(result["sparse_embedding"], SparseEmbedding)
|
|
263
|
+
assert result["sparse_embedding"].indices
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
2
|
-
#
|
|
3
|
-
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
-
from .document_embedder import HuggingFaceAPIDocumentEmbedder
|
|
5
|
-
from .text_embedder import HuggingFaceAPITextEmbedder
|
|
6
|
-
|
|
7
|
-
__all__ = ["HuggingFaceAPIDocumentEmbedder", "HuggingFaceAPITextEmbedder"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/tests/test_document_embedder.py
RENAMED
|
File without changes
|
{huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/tests/test_files/apple.jpg
RENAMED
|
File without changes
|
{huggingface_api_haystack-0.4.0 → huggingface_api_haystack-0.6.0}/tests/test_text_embedder.py
RENAMED
|
File without changes
|
|
File without changes
|