diff --git a/.github/workflows/pgvector.yml b/.github/workflows/pgvector.yml index 0fe20e037..ab5c984ed 100644 --- a/.github/workflows/pgvector.yml +++ b/.github/workflows/pgvector.yml @@ -33,7 +33,7 @@ jobs: python-version: ["3.9", "3.10", "3.11"] services: pgvector: - image: ankane/pgvector:latest + image: pgvector/pgvector:pg17 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres diff --git a/README.md b/README.md index af83d045d..43a2610ac 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Please check out our [Contribution Guidelines](CONTRIBUTING.md) for all the deta | [google-ai-haystack](integrations/google_ai/) | Generator | [![PyPI - Version](https://img.shields.io/pypi/v/google-ai-haystack.svg)](https://pypi.org/project/google-ai-haystack) | [![Test / google-ai](https://github.com/deepset-ai/haystack-core-integrations/actions/workflows/google_ai.yml/badge.svg)](https://github.com/deepset-ai/haystack-core-integrations/actions/workflows/google_ai.yml) | | [google-vertex-haystack](integrations/google_vertex/) | Generator | [![PyPI - Version](https://img.shields.io/pypi/v/google-vertex-haystack.svg)](https://pypi.org/project/google-vertex-haystack) | [![Test / google-vertex](https://github.com/deepset-ai/haystack-core-integrations/actions/workflows/google_vertex.yml/badge.svg)](https://github.com/deepset-ai/haystack-core-integrations/actions/workflows/google_vertex.yml) | | [instructor-embedders-haystack](integrations/instructor_embedders/) | Embedder | [![PyPI - Version](https://img.shields.io/pypi/v/instructor-embedders-haystack.svg)](https://pypi.org/project/instructor-embedders-haystack) | [![Test / instructor-embedders](https://github.com/deepset-ai/haystack-core-integrations/actions/workflows/instructor_embedders.yml/badge.svg)](https://github.com/deepset-ai/haystack-core-integrations/actions/workflows/instructor_embedders.yml) | -| [jina-haystack](integrations/jina/) | Embedder, Ranker | [![PyPI - Version](https://img.shields.io/pypi/v/jina-haystack.svg)](https://pypi.org/project/jina-haystack) | [![Test / jina](https://github.com/deepset-ai/haystack-core-integrations/actions/workflows/jina.yml/badge.svg)](https://github.com/deepset-ai/haystack-core-integrations/actions/workflows/jina.yml) | +| [jina-haystack](integrations/jina/) | Connector, Embedder, Ranker | [![PyPI - Version](https://img.shields.io/pypi/v/jina-haystack.svg)](https://pypi.org/project/jina-haystack) | [![Test / jina](https://github.com/deepset-ai/haystack-core-integrations/actions/workflows/jina.yml/badge.svg)](https://github.com/deepset-ai/haystack-core-integrations/actions/workflows/jina.yml) | | [langfuse-haystack](integrations/langfuse/) | Tracer | [![PyPI - Version](https://img.shields.io/pypi/v/langfuse-haystack.svg?color=orange)](https://pypi.org/project/langfuse-haystack) | [![Test / langfuse](https://github.com/deepset-ai/haystack-core-integrations/actions/workflows/langfuse.yml/badge.svg)](https://github.com/deepset-ai/haystack-core-integrations/actions/workflows/langfuse.yml) | | [llama-cpp-haystack](integrations/llama_cpp/) | Generator | [![PyPI - Version](https://img.shields.io/pypi/v/llama-cpp-haystack.svg?color=orange)](https://pypi.org/project/llama-cpp-haystack) | [![Test / llama-cpp](https://github.com/deepset-ai/haystack-core-integrations/actions/workflows/llama_cpp.yml/badge.svg)](https://github.com/deepset-ai/haystack-core-integrations/actions/workflows/llama_cpp.yml) | | [mistral-haystack](integrations/mistral/) | Embedder, Generator | [![PyPI - Version](https://img.shields.io/pypi/v/mistral-haystack.svg)](https://pypi.org/project/mistral-haystack) | [![Test / mistral](https://github.com/deepset-ai/haystack-core-integrations/actions/workflows/mistral.yml/badge.svg)](https://github.com/deepset-ai/haystack-core-integrations/actions/workflows/mistral.yml) | @@ -85,3 +85,8 @@ GitHub. The GitHub Actions workflow will take care of the rest. git push --tags origin ``` 3. Wait for the CI to do its magic + +> [!IMPORTANT] +> When releasing a new integration version, always tag a commit that includes the changes for that integration +> (usually the PR merge commit). If you tag a commit that doesn't include changes for the integration being released, +> the generated changelog will be incorrect. diff --git a/integrations/amazon_bedrock/src/haystack_integrations/components/embedders/amazon_bedrock/__init__.py b/integrations/amazon_bedrock/src/haystack_integrations/components/embedders/amazon_bedrock/__init__.py index b2efefdc8..2ebd35979 100644 --- a/integrations/amazon_bedrock/src/haystack_integrations/components/embedders/amazon_bedrock/__init__.py +++ b/integrations/amazon_bedrock/src/haystack_integrations/components/embedders/amazon_bedrock/__init__.py @@ -4,4 +4,4 @@ from .document_embedder import AmazonBedrockDocumentEmbedder from .text_embedder import AmazonBedrockTextEmbedder -__all__ = ["AmazonBedrockTextEmbedder", "AmazonBedrockDocumentEmbedder"] +__all__ = ["AmazonBedrockDocumentEmbedder", "AmazonBedrockTextEmbedder"] diff --git a/integrations/amazon_bedrock/src/haystack_integrations/components/embedders/amazon_bedrock/document_embedder.py b/integrations/amazon_bedrock/src/haystack_integrations/components/embedders/amazon_bedrock/document_embedder.py index 1b8fde124..f2906c00d 100755 --- a/integrations/amazon_bedrock/src/haystack_integrations/components/embedders/amazon_bedrock/document_embedder.py +++ b/integrations/amazon_bedrock/src/haystack_integrations/components/embedders/amazon_bedrock/document_embedder.py @@ -236,7 +236,7 @@ def run(self, documents: List[Document]): - `documents`: The `Document`s with the `embedding` field populated. :raises AmazonBedrockInferenceError: If the inference fails. """ - if not isinstance(documents, list) or documents and not isinstance(documents[0], Document): + if not isinstance(documents, list) or (documents and not isinstance(documents[0], Document)): msg = ( "AmazonBedrockDocumentEmbedder expects a list of Documents as input." "In case you want to embed a string, please use the AmazonBedrockTextEmbedder." diff --git a/integrations/amazon_bedrock/src/haystack_integrations/components/generators/amazon_bedrock/__init__.py b/integrations/amazon_bedrock/src/haystack_integrations/components/generators/amazon_bedrock/__init__.py index 2d33beb42..ab3f0dfd5 100644 --- a/integrations/amazon_bedrock/src/haystack_integrations/components/generators/amazon_bedrock/__init__.py +++ b/integrations/amazon_bedrock/src/haystack_integrations/components/generators/amazon_bedrock/__init__.py @@ -4,4 +4,4 @@ from .chat.chat_generator import AmazonBedrockChatGenerator from .generator import AmazonBedrockGenerator -__all__ = ["AmazonBedrockGenerator", "AmazonBedrockChatGenerator"] +__all__ = ["AmazonBedrockChatGenerator", "AmazonBedrockGenerator"] diff --git a/integrations/amazon_bedrock/src/haystack_integrations/components/generators/amazon_bedrock/chat/adapters.py b/integrations/amazon_bedrock/src/haystack_integrations/components/generators/amazon_bedrock/chat/adapters.py index f5e8f8181..cbb5ee370 100644 --- a/integrations/amazon_bedrock/src/haystack_integrations/components/generators/amazon_bedrock/chat/adapters.py +++ b/integrations/amazon_bedrock/src/haystack_integrations/components/generators/amazon_bedrock/chat/adapters.py @@ -212,6 +212,8 @@ def prepare_body(self, messages: List[ChatMessage], **inference_kwargs) -> Dict[ stop_sequences = inference_kwargs.get("stop_sequences", []) + inference_kwargs.pop("stop_words", []) if stop_sequences: inference_kwargs["stop_sequences"] = stop_sequences + # pop stream kwarg from inference_kwargs as Anthropic does not support it (if provided) + inference_kwargs.pop("stream", None) params = self._get_params(inference_kwargs, default_params, self.ALLOWED_PARAMS) body = {**self.prepare_chat_messages(messages=messages), **params} return body @@ -384,6 +386,10 @@ def prepare_body(self, messages: List[ChatMessage], **inference_kwargs) -> Dict[ stop_words = inference_kwargs.pop("stop_words", []) if stop_words: inference_kwargs["stop"] = stop_words + + # pop stream kwarg from inference_kwargs as Mistral does not support it (if provided) + inference_kwargs.pop("stream", None) + params = self._get_params(inference_kwargs, default_params, self.ALLOWED_PARAMS) body = {"prompt": self.prepare_chat_messages(messages=messages), **params} return body diff --git a/integrations/amazon_bedrock/tests/test_chat_generator.py b/integrations/amazon_bedrock/tests/test_chat_generator.py index 571e03eb2..185a34c8a 100644 --- a/integrations/amazon_bedrock/tests/test_chat_generator.py +++ b/integrations/amazon_bedrock/tests/test_chat_generator.py @@ -17,7 +17,7 @@ ) KLASS = "haystack_integrations.components.generators.amazon_bedrock.chat.chat_generator.AmazonBedrockChatGenerator" -MODELS_TO_TEST = ["anthropic.claude-3-sonnet-20240229-v1:0", "anthropic.claude-v2:1", "meta.llama2-13b-chat-v1"] +MODELS_TO_TEST = ["anthropic.claude-3-sonnet-20240229-v1:0", "anthropic.claude-v2:1"] MODELS_TO_TEST_WITH_TOOLS = ["anthropic.claude-3-haiku-20240307-v1:0"] MISTRAL_MODELS = [ "mistral.mistral-7b-instruct-v0:2", diff --git a/integrations/anthropic/CHANGELOG.md b/integrations/anthropic/CHANGELOG.md index a219da6a5..a7cdc7d09 100644 --- a/integrations/anthropic/CHANGELOG.md +++ b/integrations/anthropic/CHANGELOG.md @@ -1,11 +1,29 @@ # Changelog +## [unreleased] + +### โš™๏ธ CI + +- Adopt uv as installer (#1142) + +### ๐Ÿงน Chores + +- Update ruff linting scripts and settings (#1105) + +### ๐ŸŒ€ Miscellaneous + +- Add AnthropicVertexChatGenerator component (#1192) + ## [integrations/anthropic-v1.1.0] - 2024-09-20 ### ๐Ÿš€ Features - Add Anthropic prompt caching support, add example (#1006) +### ๐ŸŒ€ Miscellaneous + +- Chore: Update Anthropic example, use ChatPromptBuilder properly (#978) + ## [integrations/anthropic-v1.0.0] - 2024-08-12 ### ๐Ÿ› Bug Fixes @@ -20,12 +38,18 @@ - Do not retry tests in `hatch run test` command (#954) + ## [integrations/anthropic-v0.4.1] - 2024-07-17 -### โš™๏ธ Miscellaneous Tasks +### ๐Ÿงน Chores - Update ruff invocation to include check parameter (#853) +### ๐ŸŒ€ Miscellaneous + +- Ci: install `pytest-rerunfailures` where needed; add retry config to `test-cov` script (#845) +- Add meta deprecration warning (#910) + ## [integrations/anthropic-v0.4.0] - 2024-06-21 ### ๐Ÿš€ Features @@ -33,12 +57,24 @@ - Update Anthropic/Cohere for tools use (#790) - Update Anthropic default models, pydocs (#839) -### โš™๏ธ Miscellaneous Tasks +### โš™๏ธ CI - Retry tests to reduce flakyness (#836) +### ๐ŸŒ€ Miscellaneous + +- Remove references to Python 3.7 (#601) +- Chore: add license classifiers (#680) +- Chore: change the pydoc renderer class (#718) +- Docs: add missing api references (#728) + ## [integrations/anthropic-v0.2.0] - 2024-03-15 +### ๐ŸŒ€ Miscellaneous + +- Docs: Replace amazon-bedrock with anthropic in readme (#584) +- Chore: Use the correct sonnet model name (#587) + ## [integrations/anthropic-v0.1.0] - 2024-03-15 ### ๐Ÿš€ Features diff --git a/integrations/anthropic/src/haystack_integrations/components/generators/anthropic/__init__.py b/integrations/anthropic/src/haystack_integrations/components/generators/anthropic/__init__.py index 0bd29898e..12c588dc4 100644 --- a/integrations/anthropic/src/haystack_integrations/components/generators/anthropic/__init__.py +++ b/integrations/anthropic/src/haystack_integrations/components/generators/anthropic/__init__.py @@ -5,4 +5,4 @@ from .chat.vertex_chat_generator import AnthropicVertexChatGenerator from .generator import AnthropicGenerator -__all__ = ["AnthropicGenerator", "AnthropicChatGenerator", "AnthropicVertexChatGenerator"] +__all__ = ["AnthropicChatGenerator", "AnthropicGenerator", "AnthropicVertexChatGenerator"] diff --git a/integrations/astra/CHANGELOG.md b/integrations/astra/CHANGELOG.md index fff6cb65f..6ad660a0e 100644 --- a/integrations/astra/CHANGELOG.md +++ b/integrations/astra/CHANGELOG.md @@ -1,16 +1,29 @@ # Changelog +## [integrations/astra-v0.9.4] - 2024-11-25 + +### ๐ŸŒ€ Miscellaneous + +- Fix: Astra - fix embedding retrieval top-k limit (#1210) + ## [integrations/astra-v0.10.0] - 2024-10-22 ### ๐Ÿš€ Features - Update astradb integration for latest client library (#1145) -### โš™๏ธ Miscellaneous Tasks +### โš™๏ธ CI -- Update ruff linting scripts and settings (#1105) - Adopt uv as installer (#1142) +### ๐Ÿงน Chores + +- Update ruff linting scripts and settings (#1105) + +### ๐ŸŒ€ Miscellaneous + +- Fix: #1047 Remove count_documents from delete_documents (#1049) + ## [integrations/astra-v0.9.3] - 2024-09-12 ### ๐Ÿ› Bug Fixes @@ -22,8 +35,13 @@ - Do not retry tests in `hatch run test` command (#954) + ## [integrations/astra-v0.9.2] - 2024-07-22 +### ๐ŸŒ€ Miscellaneous + +- Normalize logical filter conditions (#874) + ## [integrations/astra-v0.9.1] - 2024-07-15 ### ๐Ÿš€ Features @@ -37,27 +55,48 @@ - Fix typing checks - `Astra` - Fallback to default filter policy when deserializing retrievers without the init parameter (#896) -### โš™๏ธ Miscellaneous Tasks +### โš™๏ธ CI - Retry tests to reduce flakyness (#836) +### ๐ŸŒ€ Miscellaneous + +- Ci: install `pytest-rerunfailures` where needed; add retry config to `test-cov` script (#845) +- Fix: Incorrect astra not equal operator (#868) +- Chore: Minor retriever pydoc fix (#884) + ## [integrations/astra-v0.7.0] - 2024-05-15 ### ๐Ÿ› Bug Fixes - Make unit tests pass (#720) +### ๐ŸŒ€ Miscellaneous + +- Chore: change the pydoc renderer class (#718) +- [Astra DB] Explicit projection when reading from Astra DB (#733) + ## [integrations/astra-v0.6.0] - 2024-04-24 ### ๐Ÿ› Bug Fixes - Pass namespace in the docstore init (#683) +### ๐ŸŒ€ Miscellaneous + +- Chore: add license classifiers (#680) +- Bug fix for document_store.py (#618) + ## [integrations/astra-v0.5.1] - 2024-04-09 ### ๐Ÿ› Bug Fixes -- Fix haystack-ai pin (#649) +- Fix `haystack-ai` pins (#649) + +### ๐ŸŒ€ Miscellaneous + +- Remove references to Python 3.7 (#601) +- Make Document Stores initially skip `SparseEmbedding` (#606) ## [integrations/astra-v0.5.0] - 2024-03-18 @@ -67,9 +106,15 @@ - Small consistency improvements (#536) - Disable-class-def (#556) +### ๐ŸŒ€ Miscellaneous + +- Fix example code for Astra DB pipeline (#481) +- Make tests show coverage (#566) +- Astra DB: Add integration usage tracking (#568) + ## [integrations/astra-v0.4.2] - 2024-02-21 -### FIX +### ๐ŸŒ€ Miscellaneous - Proper name for the sort param (#454) @@ -78,9 +123,7 @@ ### ๐Ÿ› Bug Fixes - Fix order of API docs (#447) - -This PR will also push the docs to Readme -- Fix integration tests (#450) +- Astra: fix integration tests (#450) ## [integrations/astra-v0.4.0] - 2024-02-20 @@ -88,20 +131,35 @@ This PR will also push the docs to Readme - Update category slug (#442) +### ๐ŸŒ€ Miscellaneous + +- Update the Astra DB Integration to fit latest conventions (#428) + ## [integrations/astra-v0.3.0] - 2024-02-15 -## [integrations/astra-v0.2.0] - 2024-02-13 +### ๐ŸŒ€ Miscellaneous -### Astra +- Model_name_or_path > model (#418) +- [Astra] Change authentication parameters (#423) -- Generate api docs (#327) +## [integrations/astra-v0.2.0] - 2024-02-13 -### Refact +### ๐ŸŒ€ Miscellaneous - [**breaking**] Change import paths (#277) +- Generate api docs (#327) +- Astra: rename retriever (#399) ## [integrations/astra-v0.1.1] - 2024-01-18 +### ๐ŸŒ€ Miscellaneous + +- Update the import paths for beta5 (#235) + ## [integrations/astra-v0.1.0] - 2024-01-11 +### ๐ŸŒ€ Miscellaneous + +- Adding AstraDB as a DocumentStore (#144) + diff --git a/integrations/astra/src/haystack_integrations/document_stores/astra/astra_client.py b/integrations/astra/src/haystack_integrations/document_stores/astra/astra_client.py index 6f2289786..1a3481e0c 100644 --- a/integrations/astra/src/haystack_integrations/document_stores/astra/astra_client.py +++ b/integrations/astra/src/haystack_integrations/document_stores/astra/astra_client.py @@ -202,7 +202,7 @@ def _format_query_response(responses, include_metadata, include_values): return QueryResponse(final_res) def _query(self, vector, top_k, filters=None): - query = {"sort": {"$vector": vector}, "options": {"limit": top_k, "includeSimilarity": True}} + query = {"sort": {"$vector": vector}, "limit": top_k, "includeSimilarity": True} if filters is not None: query["filter"] = filters @@ -222,6 +222,7 @@ def find_documents(self, find_query): filter=find_query.get("filter"), sort=find_query.get("sort"), limit=find_query.get("limit"), + include_similarity=find_query.get("includeSimilarity"), projection={"*": 1}, ) diff --git a/integrations/astra/tests/test_embedding_retrieval.py b/integrations/astra/tests/test_embedding_retrieval.py new file mode 100644 index 000000000..bf23fe9f5 --- /dev/null +++ b/integrations/astra/tests/test_embedding_retrieval.py @@ -0,0 +1,48 @@ +import os + +import pytest +from haystack import Document +from haystack.document_stores.types import DuplicatePolicy + +from haystack_integrations.document_stores.astra import AstraDocumentStore + + +@pytest.mark.integration +@pytest.mark.skipif( + os.environ.get("ASTRA_DB_APPLICATION_TOKEN", "") == "", reason="ASTRA_DB_APPLICATION_TOKEN env var not set" +) +@pytest.mark.skipif(os.environ.get("ASTRA_DB_API_ENDPOINT", "") == "", reason="ASTRA_DB_API_ENDPOINT env var not set") +class TestEmbeddingRetrieval: + + @pytest.fixture + def document_store(self) -> AstraDocumentStore: + return AstraDocumentStore( + collection_name="haystack_integration", + duplicates_policy=DuplicatePolicy.OVERWRITE, + embedding_dimension=768, + ) + + @pytest.fixture(autouse=True) + def run_before_and_after_tests(self, document_store: AstraDocumentStore): + """ + Cleaning up document store + """ + document_store.delete_documents(delete_all=True) + assert document_store.count_documents() == 0 + + def test_search_with_top_k(self, document_store): + query_embedding = [0.1] * 768 + common_embedding = [0.8] * 768 + + documents = [Document(content=f"This is document number {i}", embedding=common_embedding) for i in range(0, 3)] + + document_store.write_documents(documents) + + top_k = 2 + + result = document_store.search(query_embedding, top_k) + + assert top_k == len(result) + + for document in result: + assert document.score is not None diff --git a/integrations/azure_ai_search/CHANGELOG.md b/integrations/azure_ai_search/CHANGELOG.md new file mode 100644 index 000000000..6a8d26c9d --- /dev/null +++ b/integrations/azure_ai_search/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +## [integrations/azure_ai_search-v0.1.1] - 2024-11-22 + +### ๐Ÿ› Bug Fixes + +- Fix error in README file (#1207) + + +## [integrations/azure_ai_search-v0.1.0] - 2024-11-21 + +### ๐Ÿš€ Features + +- Add Azure AI Search integration (#1122) +- Add BM25 and Hybrid Search Retrievers to Azure AI Search Integration (#1175) + +### ๐ŸŒ€ Miscellaneous + +- Enable kwargs in SearchIndex and Embedding Retriever (#1185) +- Fix: Fix tag name for version release (#1206) + + diff --git a/integrations/azure_ai_search/README.md b/integrations/azure_ai_search/README.md index 915a23b63..51cc7720c 100644 --- a/integrations/azure_ai_search/README.md +++ b/integrations/azure_ai_search/README.md @@ -19,7 +19,7 @@ pip install azure-ai-search-haystack ``` ## Examples -You can find a code example showing how to use the Document Store and the Retriever in the documentation or in [this Colab](https://colab.research.google.com/drive/1YpDetI8BRbObPDEVdfqUcwhEX9UUXP-m?usp=sharing). +Refer to the documentation for code examples on utilizing the Document Store and its associated Retrievers. For more usage scenarios, check out the [examples](https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/azure_ai_search/example). ## License diff --git a/integrations/azure_ai_search/pyproject.toml b/integrations/azure_ai_search/pyproject.toml index 49ca623e7..cb967b1e0 100644 --- a/integrations/azure_ai_search/pyproject.toml +++ b/integrations/azure_ai_search/pyproject.toml @@ -33,11 +33,11 @@ packages = ["src/haystack_integrations"] [tool.hatch.version] source = "vcs" -tag-pattern = 'integrations\/azure-ai-search-v(?P.*)' +tag-pattern = 'integrations\/azure_ai_search-v(?P.*)' [tool.hatch.version.raw-options] root = "../.." -git_describe_command = 'git describe --tags --match="integrations/azure-ai-search-v[0-9]*"' +git_describe_command = 'git describe --tags --match="integrations/azure_ai_search-v[0-9]*"' [tool.hatch.envs.default] dependencies = [ diff --git a/integrations/azure_ai_search/src/haystack_integrations/components/retrievers/azure_ai_search/__init__.py b/integrations/azure_ai_search/src/haystack_integrations/components/retrievers/azure_ai_search/__init__.py index eb75ffa6c..56dc30db4 100644 --- a/integrations/azure_ai_search/src/haystack_integrations/components/retrievers/azure_ai_search/__init__.py +++ b/integrations/azure_ai_search/src/haystack_integrations/components/retrievers/azure_ai_search/__init__.py @@ -1,3 +1,5 @@ +from .bm25_retriever import AzureAISearchBM25Retriever from .embedding_retriever import AzureAISearchEmbeddingRetriever +from .hybrid_retriever import AzureAISearchHybridRetriever -__all__ = ["AzureAISearchEmbeddingRetriever"] +__all__ = ["AzureAISearchBM25Retriever", "AzureAISearchEmbeddingRetriever", "AzureAISearchHybridRetriever"] diff --git a/integrations/azure_ai_search/src/haystack_integrations/components/retrievers/azure_ai_search/bm25_retriever.py b/integrations/azure_ai_search/src/haystack_integrations/components/retrievers/azure_ai_search/bm25_retriever.py new file mode 100644 index 000000000..4a1c7f98c --- /dev/null +++ b/integrations/azure_ai_search/src/haystack_integrations/components/retrievers/azure_ai_search/bm25_retriever.py @@ -0,0 +1,135 @@ +import logging +from typing import Any, Dict, List, Optional, Union + +from haystack import Document, component, default_from_dict, default_to_dict +from haystack.document_stores.types import FilterPolicy +from haystack.document_stores.types.filter_policy import apply_filter_policy + +from haystack_integrations.document_stores.azure_ai_search import AzureAISearchDocumentStore, _normalize_filters + +logger = logging.getLogger(__name__) + + +@component +class AzureAISearchBM25Retriever: + """ + Retrieves documents from the AzureAISearchDocumentStore using BM25 retrieval. + Must be connected to the AzureAISearchDocumentStore to run. + + """ + + def __init__( + self, + *, + document_store: AzureAISearchDocumentStore, + filters: Optional[Dict[str, Any]] = None, + top_k: int = 10, + filter_policy: Union[str, FilterPolicy] = FilterPolicy.REPLACE, + **kwargs, + ): + """ + Create the AzureAISearchBM25Retriever component. + + :param document_store: An instance of AzureAISearchDocumentStore to use with the Retriever. + :param filters: Filters applied when fetching documents from the Document Store. + Filters are applied during the BM25 search to ensure the Retriever returns + `top_k` matching documents. + :param top_k: Maximum number of documents to return. + :param filter_policy: Policy to determine how filters are applied. + :param kwargs: Additional keyword arguments to pass to the Azure AI's search endpoint. + Some of the supported parameters: + - `query_type`: A string indicating the type of query to perform. Possible values are + 'simple','full' and 'semantic'. + - `semantic_configuration_name`: The name of semantic configuration to be used when + processing semantic queries. + For more information on parameters, see the + [official Azure AI Search documentation](https://learn.microsoft.com/en-us/azure/search/). + :raises TypeError: If the document store is not an instance of AzureAISearchDocumentStore. + :raises RuntimeError: If the query is not valid, or if the document store is not correctly configured. + + """ + self._filters = filters or {} + self._top_k = top_k + self._document_store = document_store + self._filter_policy = ( + filter_policy if isinstance(filter_policy, FilterPolicy) else FilterPolicy.from_str(filter_policy) + ) + self._kwargs = kwargs + if not isinstance(document_store, AzureAISearchDocumentStore): + message = "document_store must be an instance of AzureAISearchDocumentStore" + raise TypeError(message) + + def to_dict(self) -> Dict[str, Any]: + """ + Serializes the component to a dictionary. + + :returns: + Dictionary with serialized data. + """ + return default_to_dict( + self, + filters=self._filters, + top_k=self._top_k, + document_store=self._document_store.to_dict(), + filter_policy=self._filter_policy.value, + **self._kwargs, + ) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "AzureAISearchBM25Retriever": + """ + Deserializes the component from a dictionary. + + :param data: + Dictionary to deserialize from. + + :returns: + Deserialized component. + """ + data["init_parameters"]["document_store"] = AzureAISearchDocumentStore.from_dict( + data["init_parameters"]["document_store"] + ) + + # Pipelines serialized with old versions of the component might not + # have the filter_policy field. + if "filter_policy" in data["init_parameters"]: + data["init_parameters"]["filter_policy"] = FilterPolicy.from_str(data["init_parameters"]["filter_policy"]) + return default_from_dict(cls, data) + + @component.output_types(documents=List[Document]) + def run(self, query: str, filters: Optional[Dict[str, Any]] = None, top_k: Optional[int] = None): + """Retrieve documents from the AzureAISearchDocumentStore. + + :param query: Text of the query. + :param filters: Filters applied to the retrieved Documents. The way runtime filters are applied depends on + the `filter_policy` chosen at retriever initialization. See init method docstring for more + details. + :param top_k: the maximum number of documents to retrieve. + :raises RuntimeError: If an error occurs during the BM25 retrieval process. + :returns: a dictionary with the following keys: + - `documents`: A list of documents retrieved from the AzureAISearchDocumentStore. + """ + + top_k = top_k or self._top_k + filters = filters or self._filters + if filters: + applied_filters = apply_filter_policy(self._filter_policy, self._filters, filters) + normalized_filters = _normalize_filters(applied_filters) + else: + normalized_filters = "" + + try: + docs = self._document_store._bm25_retrieval( + query=query, + filters=normalized_filters, + top_k=top_k, + **self._kwargs, + ) + except Exception as e: + msg = ( + "An error occurred during the bm25 retrieval process from the AzureAISearchDocumentStore. " + "Ensure that the query is valid and the document store is correctly configured." + ) + raise RuntimeError(msg) from e + + return {"documents": docs} diff --git a/integrations/azure_ai_search/src/haystack_integrations/components/retrievers/azure_ai_search/embedding_retriever.py b/integrations/azure_ai_search/src/haystack_integrations/components/retrievers/azure_ai_search/embedding_retriever.py index af48b74fb..69fad7208 100644 --- a/integrations/azure_ai_search/src/haystack_integrations/components/retrievers/azure_ai_search/embedding_retriever.py +++ b/integrations/azure_ai_search/src/haystack_integrations/components/retrievers/azure_ai_search/embedding_retriever.py @@ -107,7 +107,8 @@ def run(self, query_embedding: List[float], filters: Optional[Dict[str, Any]] = """ top_k = top_k or self._top_k - if filters is not None: + filters = filters or self._filters + if filters: applied_filters = apply_filter_policy(self._filter_policy, self._filters, filters) normalized_filters = _normalize_filters(applied_filters) else: diff --git a/integrations/azure_ai_search/src/haystack_integrations/components/retrievers/azure_ai_search/hybrid_retriever.py b/integrations/azure_ai_search/src/haystack_integrations/components/retrievers/azure_ai_search/hybrid_retriever.py new file mode 100644 index 000000000..79282933f --- /dev/null +++ b/integrations/azure_ai_search/src/haystack_integrations/components/retrievers/azure_ai_search/hybrid_retriever.py @@ -0,0 +1,139 @@ +import logging +from typing import Any, Dict, List, Optional, Union + +from haystack import Document, component, default_from_dict, default_to_dict +from haystack.document_stores.types import FilterPolicy +from haystack.document_stores.types.filter_policy import apply_filter_policy + +from haystack_integrations.document_stores.azure_ai_search import AzureAISearchDocumentStore, _normalize_filters + +logger = logging.getLogger(__name__) + + +@component +class AzureAISearchHybridRetriever: + """ + Retrieves documents from the AzureAISearchDocumentStore using a hybrid (vector + BM25) retrieval. + Must be connected to the AzureAISearchDocumentStore to run. + + """ + + def __init__( + self, + *, + document_store: AzureAISearchDocumentStore, + filters: Optional[Dict[str, Any]] = None, + top_k: int = 10, + filter_policy: Union[str, FilterPolicy] = FilterPolicy.REPLACE, + **kwargs, + ): + """ + Create the AzureAISearchHybridRetriever component. + + :param document_store: An instance of AzureAISearchDocumentStore to use with the Retriever. + :param filters: Filters applied when fetching documents from the Document Store. + Filters are applied during the hybrid search to ensure the Retriever returns + `top_k` matching documents. + :param top_k: Maximum number of documents to return. + :param filter_policy: Policy to determine how filters are applied. + :param kwargs: Additional keyword arguments to pass to the Azure AI's search endpoint. + Some of the supported parameters: + - `query_type`: A string indicating the type of query to perform. Possible values are + 'simple','full' and 'semantic'. + - `semantic_configuration_name`: The name of semantic configuration to be used when + processing semantic queries. + For more information on parameters, see the + [official Azure AI Search documentation](https://learn.microsoft.com/en-us/azure/search/). + :raises TypeError: If the document store is not an instance of AzureAISearchDocumentStore. + :raises RuntimeError: If query or query_embedding are invalid, or if document store is not correctly configured. + """ + self._filters = filters or {} + self._top_k = top_k + self._document_store = document_store + self._filter_policy = ( + filter_policy if isinstance(filter_policy, FilterPolicy) else FilterPolicy.from_str(filter_policy) + ) + self._kwargs = kwargs + + if not isinstance(document_store, AzureAISearchDocumentStore): + message = "document_store must be an instance of AzureAISearchDocumentStore" + raise TypeError(message) + + def to_dict(self) -> Dict[str, Any]: + """ + Serializes the component to a dictionary. + + :returns: + Dictionary with serialized data. + """ + return default_to_dict( + self, + filters=self._filters, + top_k=self._top_k, + document_store=self._document_store.to_dict(), + filter_policy=self._filter_policy.value, + **self._kwargs, + ) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "AzureAISearchHybridRetriever": + """ + Deserializes the component from a dictionary. + + :param data: + Dictionary to deserialize from. + + :returns: + Deserialized component. + """ + data["init_parameters"]["document_store"] = AzureAISearchDocumentStore.from_dict( + data["init_parameters"]["document_store"] + ) + + # Pipelines serialized with old versions of the component might not + # have the filter_policy field. + if "filter_policy" in data["init_parameters"]: + data["init_parameters"]["filter_policy"] = FilterPolicy.from_str(data["init_parameters"]["filter_policy"]) + return default_from_dict(cls, data) + + @component.output_types(documents=List[Document]) + def run( + self, + query: str, + query_embedding: List[float], + filters: Optional[Dict[str, Any]] = None, + top_k: Optional[int] = None, + ): + """Retrieve documents from the AzureAISearchDocumentStore. + + :param query: Text of the query. + :param query_embedding: A list of floats representing the query embedding + :param filters: Filters applied to the retrieved Documents. The way runtime filters are applied depends on + the `filter_policy` chosen at retriever initialization. See `__init__` method docstring for more + details. + :param top_k: The maximum number of documents to retrieve. + :raises RuntimeError: If an error occurs during the hybrid retrieval process. + :returns: A dictionary with the following keys: + - `documents`: A list of documents retrieved from the AzureAISearchDocumentStore. + """ + + top_k = top_k or self._top_k + filters = filters or self._filters + if filters: + applied_filters = apply_filter_policy(self._filter_policy, self._filters, filters) + normalized_filters = _normalize_filters(applied_filters) + else: + normalized_filters = "" + + try: + docs = self._document_store._hybrid_retrieval( + query=query, query_embedding=query_embedding, filters=normalized_filters, top_k=top_k, **self._kwargs + ) + except Exception as e: + msg = ( + "An error occurred during the hybrid retrieval process from the AzureAISearchDocumentStore. " + "Ensure that the query and query_embedding are valid and the document store is correctly configured." + ) + raise RuntimeError(msg) from e + + return {"documents": docs} diff --git a/integrations/azure_ai_search/src/haystack_integrations/document_stores/azure_ai_search/__init__.py b/integrations/azure_ai_search/src/haystack_integrations/document_stores/azure_ai_search/__init__.py index ca0ea7554..dcee0e622 100644 --- a/integrations/azure_ai_search/src/haystack_integrations/document_stores/azure_ai_search/__init__.py +++ b/integrations/azure_ai_search/src/haystack_integrations/document_stores/azure_ai_search/__init__.py @@ -4,4 +4,4 @@ from .document_store import DEFAULT_VECTOR_SEARCH, AzureAISearchDocumentStore from .filters import _normalize_filters -__all__ = ["AzureAISearchDocumentStore", "DEFAULT_VECTOR_SEARCH", "_normalize_filters"] +__all__ = ["DEFAULT_VECTOR_SEARCH", "AzureAISearchDocumentStore", "_normalize_filters"] diff --git a/integrations/azure_ai_search/src/haystack_integrations/document_stores/azure_ai_search/document_store.py b/integrations/azure_ai_search/src/haystack_integrations/document_stores/azure_ai_search/document_store.py index 74260b4fa..137ff621c 100644 --- a/integrations/azure_ai_search/src/haystack_integrations/document_stores/azure_ai_search/document_store.py +++ b/integrations/azure_ai_search/src/haystack_integrations/document_stores/azure_ai_search/document_store.py @@ -240,6 +240,9 @@ def write_documents(self, documents: List[Document], policy: DuplicatePolicy = D Writes the provided documents to search index. :param documents: documents to write to the index. + :param policy: Policy to determine how duplicates are handled. + :raises ValueError: If the documents are not of type Document. + :raises TypeError: If the document ids are not strings. :return: the number of documents added to index. """ @@ -247,7 +250,7 @@ def _convert_input_document(documents: Document): document_dict = asdict(documents) if not isinstance(document_dict["id"], str): msg = f"Document id {document_dict['id']} is not a string, " - raise Exception(msg) + raise TypeError(msg) index_document = self._convert_haystack_documents_to_azure(document_dict) return index_document @@ -421,7 +424,7 @@ def _embedding_retrieval( ) -> List[Document]: """ Retrieves documents that are most similar to the query embedding using a vector similarity metric. - It uses the vector configuration of the document store. By default it uses the HNSW algorithm + It uses the vector configuration specified in the document store. By default, it uses the HNSW algorithm with cosine similarity. This method is not meant to be part of the public interface of @@ -429,13 +432,12 @@ def _embedding_retrieval( `AzureAISearchEmbeddingRetriever` uses this method directly and is the public interface for it. :param query_embedding: Embedding of the query. - :param top_k: Maximum number of Documents to return, defaults to 10. - :param filters: Filters applied to the retrieved Documents. Defaults to None. - Filters are applied during the approximate kNN search to ensure that top_k matching documents are returned. + :param top_k: Maximum number of Documents to return. + :param filters: Filters applied to the retrieved Documents. :param kwargs: Optional keyword arguments to pass to the Azure AI's search endpoint. - :raises ValueError: If `query_embedding` is an empty list - :returns: List of Document that are most similar to `query_embedding` + :raises ValueError: If `query_embedding` is an empty list. + :returns: List of Document that are most similar to `query_embedding`. """ if not query_embedding: @@ -446,3 +448,80 @@ def _embedding_retrieval( result = self.client.search(vector_queries=[vector_query], filter=filters, **kwargs) azure_docs = list(result) return self._convert_search_result_to_documents(azure_docs) + + def _bm25_retrieval( + self, + query: str, + top_k: int = 10, + filters: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> List[Document]: + """ + Retrieves documents that are most similar to `query`, using the BM25 algorithm. + + This method is not meant to be part of the public interface of + `AzureAISearchDocumentStore` nor called directly. + `AzureAISearchBM25Retriever` uses this method directly and is the public interface for it. + + :param query: Text of the query. + :param filters: Filters applied to the retrieved Documents. + :param top_k: Maximum number of Documents to return. + :param kwargs: Optional keyword arguments to pass to the Azure AI's search endpoint. + + + :raises ValueError: If `query` is an empty string. + :returns: List of Document that are most similar to `query`. + """ + + if query is None: + msg = "query must not be None" + raise ValueError(msg) + + result = self.client.search(search_text=query, filter=filters, top=top_k, query_type="simple", **kwargs) + azure_docs = list(result) + return self._convert_search_result_to_documents(azure_docs) + + def _hybrid_retrieval( + self, + query: str, + query_embedding: List[float], + top_k: int = 10, + filters: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> List[Document]: + """ + Retrieves documents similar to query using the vector configuration in the document store and + the BM25 algorithm. This method combines vector similarity and BM25 for improved retrieval. + + This method is not meant to be part of the public interface of + `AzureAISearchDocumentStore` nor called directly. + `AzureAISearchHybridRetriever` uses this method directly and is the public interface for it. + + :param query: Text of the query. + :param query_embedding: Embedding of the query. + :param filters: Filters applied to the retrieved Documents. + :param top_k: Maximum number of Documents to return. + :param kwargs: Optional keyword arguments to pass to the Azure AI's search endpoint. + + :raises ValueError: If `query` or `query_embedding` is empty. + :returns: List of Document that are most similar to `query`. + """ + + if query is None: + msg = "query must not be None" + raise ValueError(msg) + if not query_embedding: + msg = "query_embedding must be a non-empty list of floats" + raise ValueError(msg) + + vector_query = VectorizedQuery(vector=query_embedding, k_nearest_neighbors=top_k, fields="embedding") + result = self.client.search( + search_text=query, + vector_queries=[vector_query], + filter=filters, + top=top_k, + query_type="simple", + **kwargs, + ) + azure_docs = list(result) + return self._convert_search_result_to_documents(azure_docs) diff --git a/integrations/azure_ai_search/tests/test_bm25_retriever.py b/integrations/azure_ai_search/tests/test_bm25_retriever.py new file mode 100644 index 000000000..6ebb20949 --- /dev/null +++ b/integrations/azure_ai_search/tests/test_bm25_retriever.py @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: 2023-present deepset GmbH +# +# SPDX-License-Identifier: Apache-2.0 +import os +from unittest.mock import Mock + +import pytest +from haystack.dataclasses import Document +from haystack.document_stores.types import FilterPolicy + +from haystack_integrations.components.retrievers.azure_ai_search import AzureAISearchBM25Retriever +from haystack_integrations.document_stores.azure_ai_search import AzureAISearchDocumentStore + + +def test_init_default(): + mock_store = Mock(spec=AzureAISearchDocumentStore) + retriever = AzureAISearchBM25Retriever(document_store=mock_store) + assert retriever._document_store == mock_store + assert retriever._filters == {} + assert retriever._top_k == 10 + assert retriever._filter_policy == FilterPolicy.REPLACE + + retriever = AzureAISearchBM25Retriever(document_store=mock_store, filter_policy="replace") + assert retriever._filter_policy == FilterPolicy.REPLACE + + with pytest.raises(ValueError): + AzureAISearchBM25Retriever(document_store=mock_store, filter_policy="unknown") + + +def test_to_dict(): + document_store = AzureAISearchDocumentStore(hosts="some fake host") + retriever = AzureAISearchBM25Retriever(document_store=document_store) + res = retriever.to_dict() + assert res == { + "type": "haystack_integrations.components.retrievers.azure_ai_search.bm25_retriever.AzureAISearchBM25Retriever", + "init_parameters": { + "filters": {}, + "top_k": 10, + "document_store": { + "type": "haystack_integrations.document_stores.azure_ai_search.document_store.AzureAISearchDocumentStore", # noqa: E501 + "init_parameters": { + "azure_endpoint": { + "type": "env_var", + "env_vars": ["AZURE_SEARCH_SERVICE_ENDPOINT"], + "strict": True, + }, + "api_key": {"type": "env_var", "env_vars": ["AZURE_SEARCH_API_KEY"], "strict": False}, + "index_name": "default", + "embedding_dimension": 768, + "metadata_fields": None, + "vector_search_configuration": { + "profiles": [ + {"name": "default-vector-config", "algorithm_configuration_name": "cosine-algorithm-config"} + ], + "algorithms": [ + { + "name": "cosine-algorithm-config", + "kind": "hnsw", + "parameters": {"m": 4, "ef_construction": 400, "ef_search": 500, "metric": "cosine"}, + } + ], + }, + "hosts": "some fake host", + }, + }, + "filter_policy": "replace", + }, + } + + +def test_from_dict(): + data = { + "type": "haystack_integrations.components.retrievers.azure_ai_search.bm25_retriever.AzureAISearchBM25Retriever", + "init_parameters": { + "filters": {}, + "top_k": 10, + "document_store": { + "type": "haystack_integrations.document_stores.azure_ai_search.document_store.AzureAISearchDocumentStore", # noqa: E501 + "init_parameters": { + "azure_endpoint": { + "type": "env_var", + "env_vars": ["AZURE_SEARCH_SERVICE_ENDPOINT"], + "strict": True, + }, + "api_key": {"type": "env_var", "env_vars": ["AZURE_SEARCH_API_KEY"], "strict": False}, + "index_name": "default", + "metadata_fields": None, + "hosts": "some fake host", + }, + }, + "filter_policy": "replace", + }, + } + retriever = AzureAISearchBM25Retriever.from_dict(data) + assert isinstance(retriever._document_store, AzureAISearchDocumentStore) + assert retriever._filters == {} + assert retriever._top_k == 10 + assert retriever._filter_policy == FilterPolicy.REPLACE + + +def test_run(): + mock_store = Mock(spec=AzureAISearchDocumentStore) + mock_store._bm25_retrieval.return_value = [Document(content="Test doc")] + retriever = AzureAISearchBM25Retriever(document_store=mock_store) + res = retriever.run(query="Test query") + mock_store._bm25_retrieval.assert_called_once_with( + query="Test query", + filters="", + top_k=10, + ) + assert len(res) == 1 + assert len(res["documents"]) == 1 + assert res["documents"][0].content == "Test doc" + + +def test_run_init_params(): + mock_store = Mock(spec=AzureAISearchDocumentStore) + mock_store._bm25_retrieval.return_value = [Document(content="Test doc")] + retriever = AzureAISearchBM25Retriever( + document_store=mock_store, filters={"field": "type", "operator": "==", "value": "article"}, top_k=11 + ) + res = retriever.run(query="Test query") + mock_store._bm25_retrieval.assert_called_once_with( + query="Test query", + filters="type eq 'article'", + top_k=11, + ) + assert len(res) == 1 + assert len(res["documents"]) == 1 + assert res["documents"][0].content == "Test doc" + + +def test_run_time_params(): + mock_store = Mock(spec=AzureAISearchDocumentStore) + mock_store._bm25_retrieval.return_value = [Document(content="Test doc")] + retriever = AzureAISearchBM25Retriever( + document_store=mock_store, + filters={"field": "type", "operator": "==", "value": "article"}, + top_k=11, + select="name", + ) + res = retriever.run(query="Test query", filters={"field": "type", "operator": "==", "value": "book"}, top_k=5) + mock_store._bm25_retrieval.assert_called_once_with( + query="Test query", filters="type eq 'book'", top_k=5, select="name" + ) + assert len(res) == 1 + assert len(res["documents"]) == 1 + assert res["documents"][0].content == "Test doc" + + +@pytest.mark.skipif( + not os.environ.get("AZURE_SEARCH_SERVICE_ENDPOINT", None) and not os.environ.get("AZURE_SEARCH_API_KEY", None), + reason="Missing AZURE_SEARCH_SERVICE_ENDPOINT or AZURE_SEARCH_API_KEY.", +) +@pytest.mark.integration +class TestRetriever: + + def test_run(self, document_store: AzureAISearchDocumentStore): + docs = [Document(id="1", content="Test document")] + document_store.write_documents(docs) + retriever = AzureAISearchBM25Retriever(document_store=document_store) + res = retriever.run(query="Test document") + assert res["documents"] == docs + + def test_document_retrieval(self, document_store: AzureAISearchDocumentStore): + docs = [ + Document(content="This is first document"), + Document(content="This is second document"), + Document(content="This is third document"), + ] + + document_store.write_documents(docs) + retriever = AzureAISearchBM25Retriever(document_store=document_store) + results = retriever.run(query="This is first document") + assert results["documents"][0].content == "This is first document" diff --git a/integrations/azure_ai_search/tests/test_embedding_retriever.py b/integrations/azure_ai_search/tests/test_embedding_retriever.py index d4615ec44..576ecda08 100644 --- a/integrations/azure_ai_search/tests/test_embedding_retriever.py +++ b/integrations/azure_ai_search/tests/test_embedding_retriever.py @@ -103,6 +103,66 @@ def test_from_dict(): assert retriever._filter_policy == FilterPolicy.REPLACE +def test_run(): + mock_store = Mock(spec=AzureAISearchDocumentStore) + mock_store._embedding_retrieval.return_value = [Document(content="Test doc", embedding=[0.1, 0.2])] + retriever = AzureAISearchEmbeddingRetriever(document_store=mock_store) + res = retriever.run(query_embedding=[0.5, 0.7]) + mock_store._embedding_retrieval.assert_called_once_with( + query_embedding=[0.5, 0.7], + filters="", + top_k=10, + ) + assert len(res) == 1 + assert len(res["documents"]) == 1 + assert res["documents"][0].content == "Test doc" + assert res["documents"][0].embedding == [0.1, 0.2] + + +def test_run_init_params(): + mock_store = Mock(spec=AzureAISearchDocumentStore) + mock_store._embedding_retrieval.return_value = [Document(content="Test doc", embedding=[0.1, 0.2])] + retriever = AzureAISearchEmbeddingRetriever( + document_store=mock_store, + filters={"field": "type", "operator": "==", "value": "article"}, + top_k=11, + ) + res = retriever.run(query_embedding=[0.5, 0.7]) + mock_store._embedding_retrieval.assert_called_once_with( + query_embedding=[0.5, 0.7], + filters="type eq 'article'", + top_k=11, + ) + assert len(res) == 1 + assert len(res["documents"]) == 1 + assert res["documents"][0].content == "Test doc" + assert res["documents"][0].embedding == [0.1, 0.2] + + +def test_run_time_params(): + mock_store = Mock(spec=AzureAISearchDocumentStore) + mock_store._embedding_retrieval.return_value = [Document(content="Test doc", embedding=[0.1, 0.2])] + retriever = AzureAISearchEmbeddingRetriever( + document_store=mock_store, + filters={"field": "type", "operator": "==", "value": "article"}, + top_k=11, + select="name", + ) + res = retriever.run( + query_embedding=[0.5, 0.7], filters={"field": "type", "operator": "==", "value": "book"}, top_k=9 + ) + mock_store._embedding_retrieval.assert_called_once_with( + query_embedding=[0.5, 0.7], + filters="type eq 'book'", + top_k=9, + select="name", + ) + assert len(res) == 1 + assert len(res["documents"]) == 1 + assert res["documents"][0].content == "Test doc" + assert res["documents"][0].embedding == [0.1, 0.2] + + @pytest.mark.skipif( not os.environ.get("AZURE_SEARCH_SERVICE_ENDPOINT", None) and not os.environ.get("AZURE_SEARCH_API_KEY", None), reason="Missing AZURE_SEARCH_SERVICE_ENDPOINT or AZURE_SEARCH_API_KEY.", diff --git a/integrations/azure_ai_search/tests/test_hybrid_retriever.py b/integrations/azure_ai_search/tests/test_hybrid_retriever.py new file mode 100644 index 000000000..bf305c4fe --- /dev/null +++ b/integrations/azure_ai_search/tests/test_hybrid_retriever.py @@ -0,0 +1,211 @@ +# SPDX-FileCopyrightText: 2023-present deepset GmbH +# +# SPDX-License-Identifier: Apache-2.0 +import os +from typing import List +from unittest.mock import Mock + +import pytest +from azure.core.exceptions import HttpResponseError +from haystack.dataclasses import Document +from haystack.document_stores.types import FilterPolicy +from numpy.random import rand # type: ignore + +from haystack_integrations.components.retrievers.azure_ai_search import AzureAISearchHybridRetriever +from haystack_integrations.document_stores.azure_ai_search import DEFAULT_VECTOR_SEARCH, AzureAISearchDocumentStore + + +def test_init_default(): + mock_store = Mock(spec=AzureAISearchDocumentStore) + retriever = AzureAISearchHybridRetriever(document_store=mock_store) + assert retriever._document_store == mock_store + assert retriever._filters == {} + assert retriever._top_k == 10 + assert retriever._filter_policy == FilterPolicy.REPLACE + + retriever = AzureAISearchHybridRetriever(document_store=mock_store, filter_policy="replace") + assert retriever._filter_policy == FilterPolicy.REPLACE + + with pytest.raises(ValueError): + AzureAISearchHybridRetriever(document_store=mock_store, filter_policy="unknown") + + +def test_to_dict(): + document_store = AzureAISearchDocumentStore(hosts="some fake host") + retriever = AzureAISearchHybridRetriever(document_store=document_store) + res = retriever.to_dict() + assert res == { + "type": "haystack_integrations.components.retrievers.azure_ai_search.hybrid_retriever.AzureAISearchHybridRetriever", # noqa: E501 + "init_parameters": { + "filters": {}, + "top_k": 10, + "document_store": { + "type": "haystack_integrations.document_stores.azure_ai_search.document_store.AzureAISearchDocumentStore", # noqa: E501 + "init_parameters": { + "azure_endpoint": { + "type": "env_var", + "env_vars": ["AZURE_SEARCH_SERVICE_ENDPOINT"], + "strict": True, + }, + "api_key": {"type": "env_var", "env_vars": ["AZURE_SEARCH_API_KEY"], "strict": False}, + "index_name": "default", + "embedding_dimension": 768, + "metadata_fields": None, + "vector_search_configuration": { + "profiles": [ + {"name": "default-vector-config", "algorithm_configuration_name": "cosine-algorithm-config"} + ], + "algorithms": [ + { + "name": "cosine-algorithm-config", + "kind": "hnsw", + "parameters": {"m": 4, "ef_construction": 400, "ef_search": 500, "metric": "cosine"}, + } + ], + }, + "hosts": "some fake host", + }, + }, + "filter_policy": "replace", + }, + } + + +def test_from_dict(): + data = { + "type": "haystack_integrations.components.retrievers.azure_ai_search.hybrid_retriever.AzureAISearchHybridRetriever", # noqa: E501 + "init_parameters": { + "filters": {}, + "top_k": 10, + "document_store": { + "type": "haystack_integrations.document_stores.azure_ai_search.document_store.AzureAISearchDocumentStore", # noqa: E501 + "init_parameters": { + "azure_endpoint": { + "type": "env_var", + "env_vars": ["AZURE_SEARCH_SERVICE_ENDPOINT"], + "strict": True, + }, + "api_key": {"type": "env_var", "env_vars": ["AZURE_SEARCH_API_KEY"], "strict": False}, + "index_name": "default", + "embedding_dimension": 768, + "metadata_fields": None, + "vector_search_configuration": DEFAULT_VECTOR_SEARCH, + "hosts": "some fake host", + }, + }, + "filter_policy": "replace", + }, + } + retriever = AzureAISearchHybridRetriever.from_dict(data) + assert isinstance(retriever._document_store, AzureAISearchDocumentStore) + assert retriever._filters == {} + assert retriever._top_k == 10 + assert retriever._filter_policy == FilterPolicy.REPLACE + + +def test_run(): + mock_store = Mock(spec=AzureAISearchDocumentStore) + mock_store._hybrid_retrieval.return_value = [Document(content="Test doc", embedding=[0.1, 0.2])] + retriever = AzureAISearchHybridRetriever(document_store=mock_store) + res = retriever.run(query_embedding=[0.5, 0.7], query="Test query") + mock_store._hybrid_retrieval.assert_called_once_with( + query="Test query", + query_embedding=[0.5, 0.7], + filters="", + top_k=10, + ) + assert len(res) == 1 + assert len(res["documents"]) == 1 + assert res["documents"][0].content == "Test doc" + assert res["documents"][0].embedding == [0.1, 0.2] + + +def test_run_init_params(): + mock_store = Mock(spec=AzureAISearchDocumentStore) + mock_store._hybrid_retrieval.return_value = [Document(content="Test doc", embedding=[0.1, 0.2])] + retriever = AzureAISearchHybridRetriever( + document_store=mock_store, + filters={"field": "type", "operator": "==", "value": "article"}, + top_k=11, + ) + res = retriever.run(query_embedding=[0.5, 0.7], query="Test query") + mock_store._hybrid_retrieval.assert_called_once_with( + query="Test query", + query_embedding=[0.5, 0.7], + filters="type eq 'article'", + top_k=11, + ) + assert len(res) == 1 + assert len(res["documents"]) == 1 + assert res["documents"][0].content == "Test doc" + assert res["documents"][0].embedding == [0.1, 0.2] + + +def test_run_time_params(): + mock_store = Mock(spec=AzureAISearchDocumentStore) + mock_store._hybrid_retrieval.return_value = [Document(content="Test doc", embedding=[0.1, 0.2])] + retriever = AzureAISearchHybridRetriever( + document_store=mock_store, + filters={"field": "type", "operator": "==", "value": "article"}, + top_k=11, + select="name", + ) + res = retriever.run( + query_embedding=[0.5, 0.7], + query="Test query", + filters={"field": "type", "operator": "==", "value": "book"}, + top_k=9, + ) + mock_store._hybrid_retrieval.assert_called_once_with( + query="Test query", + query_embedding=[0.5, 0.7], + filters="type eq 'book'", + top_k=9, + select="name", + ) + assert len(res) == 1 + assert len(res["documents"]) == 1 + assert res["documents"][0].content == "Test doc" + assert res["documents"][0].embedding == [0.1, 0.2] + + +@pytest.mark.skipif( + not os.environ.get("AZURE_SEARCH_SERVICE_ENDPOINT", None) and not os.environ.get("AZURE_SEARCH_API_KEY", None), + reason="Missing AZURE_SEARCH_SERVICE_ENDPOINT or AZURE_SEARCH_API_KEY.", +) +@pytest.mark.integration +class TestRetriever: + + def test_run(self, document_store: AzureAISearchDocumentStore): + docs = [Document(id="1")] + document_store.write_documents(docs) + retriever = AzureAISearchHybridRetriever(document_store=document_store) + res = retriever.run(query="Test document", query_embedding=[0.1] * 768) + assert res["documents"] == docs + + def test_hybrid_retrieval(self, document_store: AzureAISearchDocumentStore): + query_embedding = [0.1] * 768 + most_similar_embedding = [0.8] * 768 + second_best_embedding = [0.8] * 200 + [0.1] * 300 + [0.2] * 268 + another_embedding = rand(768).tolist() + + docs = [ + Document(content="This is first document", embedding=most_similar_embedding), + Document(content="This is second document", embedding=second_best_embedding), + Document(content="This is third document", embedding=another_embedding), + ] + + document_store.write_documents(docs) + retriever = AzureAISearchHybridRetriever(document_store=document_store) + results = retriever.run(query="This is first document", query_embedding=query_embedding) + assert results["documents"][0].content == "This is first document" + + def test_empty_query_embedding(self, document_store: AzureAISearchDocumentStore): + query_embedding: List[float] = [] + with pytest.raises(ValueError): + document_store._hybrid_retrieval(query="", query_embedding=query_embedding) + + def test_query_embedding_wrong_dimension(self, document_store: AzureAISearchDocumentStore): + query_embedding = [0.1] * 4 + with pytest.raises(HttpResponseError): + document_store._hybrid_retrieval(query="", query_embedding=query_embedding) diff --git a/integrations/chroma/src/haystack_integrations/components/retrievers/chroma/__init__.py b/integrations/chroma/src/haystack_integrations/components/retrievers/chroma/__init__.py index 53120c97c..e240ba136 100644 --- a/integrations/chroma/src/haystack_integrations/components/retrievers/chroma/__init__.py +++ b/integrations/chroma/src/haystack_integrations/components/retrievers/chroma/__init__.py @@ -1,3 +1,3 @@ from .retriever import ChromaEmbeddingRetriever, ChromaQueryTextRetriever -__all__ = ["ChromaQueryTextRetriever", "ChromaEmbeddingRetriever"] +__all__ = ["ChromaEmbeddingRetriever", "ChromaQueryTextRetriever"] diff --git a/integrations/cohere/src/haystack_integrations/components/embedders/cohere/document_embedder.py b/integrations/cohere/src/haystack_integrations/components/embedders/cohere/document_embedder.py index 3201168a8..d311662fe 100644 --- a/integrations/cohere/src/haystack_integrations/components/embedders/cohere/document_embedder.py +++ b/integrations/cohere/src/haystack_integrations/components/embedders/cohere/document_embedder.py @@ -146,7 +146,7 @@ def run(self, documents: List[Document]): - `meta`: metadata about the embedding process. :raises TypeError: if the input is not a list of `Documents`. """ - if not isinstance(documents, list) or documents and not isinstance(documents[0], Document): + if not isinstance(documents, list) or (documents and not isinstance(documents[0], Document)): msg = ( "CohereDocumentEmbedder expects a list of Documents as input." "In case you want to embed a string, please use the CohereTextEmbedder." diff --git a/integrations/cohere/src/haystack_integrations/components/generators/cohere/__init__.py b/integrations/cohere/src/haystack_integrations/components/generators/cohere/__init__.py index 93c0947e4..7d50682e8 100644 --- a/integrations/cohere/src/haystack_integrations/components/generators/cohere/__init__.py +++ b/integrations/cohere/src/haystack_integrations/components/generators/cohere/__init__.py @@ -4,4 +4,4 @@ from .chat.chat_generator import CohereChatGenerator from .generator import CohereGenerator -__all__ = ["CohereGenerator", "CohereChatGenerator"] +__all__ = ["CohereChatGenerator", "CohereGenerator"] diff --git a/integrations/fastembed/src/haystack_integrations/components/embedders/fastembed/__init__.py b/integrations/fastembed/src/haystack_integrations/components/embedders/fastembed/__init__.py index e943a8ca1..d73c29766 100644 --- a/integrations/fastembed/src/haystack_integrations/components/embedders/fastembed/__init__.py +++ b/integrations/fastembed/src/haystack_integrations/components/embedders/fastembed/__init__.py @@ -8,7 +8,7 @@ __all__ = [ "FastembedDocumentEmbedder", - "FastembedTextEmbedder", "FastembedSparseDocumentEmbedder", "FastembedSparseTextEmbedder", + "FastembedTextEmbedder", ] diff --git a/integrations/fastembed/src/haystack_integrations/components/embedders/fastembed/fastembed_document_embedder.py b/integrations/fastembed/src/haystack_integrations/components/embedders/fastembed/fastembed_document_embedder.py index 8b63582c5..b064173fe 100644 --- a/integrations/fastembed/src/haystack_integrations/components/embedders/fastembed/fastembed_document_embedder.py +++ b/integrations/fastembed/src/haystack_integrations/components/embedders/fastembed/fastembed_document_embedder.py @@ -158,7 +158,7 @@ def run(self, documents: List[Document]): :returns: A dictionary with the following keys: - `documents`: List of Documents with each Document's `embedding` field set to the computed embeddings. """ - if not isinstance(documents, list) or documents and not isinstance(documents[0], Document): + if not isinstance(documents, list) or (documents and not isinstance(documents[0], Document)): msg = ( "FastembedDocumentEmbedder expects a list of Documents as input. " "In case you want to embed a list of strings, please use the FastembedTextEmbedder." diff --git a/integrations/fastembed/src/haystack_integrations/components/embedders/fastembed/fastembed_sparse_document_embedder.py b/integrations/fastembed/src/haystack_integrations/components/embedders/fastembed/fastembed_sparse_document_embedder.py index a30d43cf4..fb3df9162 100644 --- a/integrations/fastembed/src/haystack_integrations/components/embedders/fastembed/fastembed_sparse_document_embedder.py +++ b/integrations/fastembed/src/haystack_integrations/components/embedders/fastembed/fastembed_sparse_document_embedder.py @@ -150,7 +150,7 @@ def run(self, documents: List[Document]): - `documents`: List of Documents with each Document's `sparse_embedding` field set to the computed embeddings. """ - if not isinstance(documents, list) or documents and not isinstance(documents[0], Document): + if not isinstance(documents, list) or (documents and not isinstance(documents[0], Document)): msg = ( "FastembedSparseDocumentEmbedder expects a list of Documents as input. " "In case you want to embed a list of strings, please use the FastembedTextEmbedder." diff --git a/integrations/fastembed/src/haystack_integrations/components/rankers/fastembed/ranker.py b/integrations/fastembed/src/haystack_integrations/components/rankers/fastembed/ranker.py index 8f077a30c..370344df5 100644 --- a/integrations/fastembed/src/haystack_integrations/components/rankers/fastembed/ranker.py +++ b/integrations/fastembed/src/haystack_integrations/components/rankers/fastembed/ranker.py @@ -157,7 +157,7 @@ def run(self, query: str, documents: List[Document], top_k: Optional[int] = None :raises ValueError: If `top_k` is not > 0. """ - if not isinstance(documents, list) or documents and not isinstance(documents[0], Document): + if not isinstance(documents, list) or (documents and not isinstance(documents[0], Document)): msg = "FastembedRanker expects a list of Documents as input. " raise TypeError(msg) if query == "": diff --git a/integrations/google_ai/src/haystack_integrations/components/generators/google_ai/__init__.py b/integrations/google_ai/src/haystack_integrations/components/generators/google_ai/__init__.py index 2b77c813f..c62129f9d 100644 --- a/integrations/google_ai/src/haystack_integrations/components/generators/google_ai/__init__.py +++ b/integrations/google_ai/src/haystack_integrations/components/generators/google_ai/__init__.py @@ -4,4 +4,4 @@ from .chat.gemini import GoogleAIGeminiChatGenerator from .gemini import GoogleAIGeminiGenerator -__all__ = ["GoogleAIGeminiGenerator", "GoogleAIGeminiChatGenerator"] +__all__ = ["GoogleAIGeminiChatGenerator", "GoogleAIGeminiGenerator"] diff --git a/integrations/google_vertex/src/haystack_integrations/components/generators/google_vertex/__init__.py b/integrations/google_vertex/src/haystack_integrations/components/generators/google_vertex/__init__.py index 07c2a5260..e5f556637 100644 --- a/integrations/google_vertex/src/haystack_integrations/components/generators/google_vertex/__init__.py +++ b/integrations/google_vertex/src/haystack_integrations/components/generators/google_vertex/__init__.py @@ -11,8 +11,8 @@ __all__ = [ "VertexAICodeGenerator", - "VertexAIGeminiGenerator", "VertexAIGeminiChatGenerator", + "VertexAIGeminiGenerator", "VertexAIImageCaptioner", "VertexAIImageGenerator", "VertexAIImageQA", diff --git a/integrations/google_vertex/tests/chat/test_gemini.py b/integrations/google_vertex/tests/chat/test_gemini.py index 73c99fe2f..614b83909 100644 --- a/integrations/google_vertex/tests/chat/test_gemini.py +++ b/integrations/google_vertex/tests/chat/test_gemini.py @@ -161,13 +161,13 @@ def test_to_dict_with_params(_mock_vertexai_init, _mock_generative_model): "name": "get_current_weather", "description": "Get the current weather in a given location", "parameters": { - "type_": "OBJECT", + "type": "OBJECT", "properties": { "location": { - "type_": "STRING", + "type": "STRING", "description": "The city and state, e.g. San Francisco, CA", }, - "unit": {"type_": "STRING", "enum": ["celsius", "fahrenheit"]}, + "unit": {"type": "STRING", "enum": ["celsius", "fahrenheit"]}, }, "required": ["location"], "property_ordering": ["location", "unit"], diff --git a/integrations/instructor_embedders/src/haystack_integrations/components/embedders/instructor_embedders/instructor_document_embedder.py b/integrations/instructor_embedders/src/haystack_integrations/components/embedders/instructor_embedders/instructor_document_embedder.py index 734798f46..c05c37733 100644 --- a/integrations/instructor_embedders/src/haystack_integrations/components/embedders/instructor_embedders/instructor_document_embedder.py +++ b/integrations/instructor_embedders/src/haystack_integrations/components/embedders/instructor_embedders/instructor_document_embedder.py @@ -158,7 +158,7 @@ def run(self, documents: List[Document]): param documents: A list of Documents to embed. """ - if not isinstance(documents, list) or documents and not isinstance(documents[0], Document): + if not isinstance(documents, list) or (documents and not isinstance(documents[0], Document)): msg = ( "InstructorDocumentEmbedder expects a list of Documents as input. " "In case you want to embed a list of strings, please use the InstructorTextEmbedder." diff --git a/integrations/jina/CHANGELOG.md b/integrations/jina/CHANGELOG.md index 918a764f0..f65853d31 100644 --- a/integrations/jina/CHANGELOG.md +++ b/integrations/jina/CHANGELOG.md @@ -1,17 +1,49 @@ # Changelog +## [integrations/jina-v0.5.0] - 2024-11-21 + +### ๐Ÿš€ Features + +- Add `JinaReaderConnector` (#1150) + +### ๐Ÿ“š Documentation + +- Update docstrings of JinaDocumentEmbedder and JinaTextEmbedder (#1092) + +### โš™๏ธ CI + +- Adopt uv as installer (#1142) + +### ๐Ÿงน Chores + +- Update ruff linting scripts and settings (#1105) + + ## [integrations/jina-v0.4.0] - 2024-09-18 ### ๐Ÿงช Testing - Do not retry tests in `hatch run test` command (#954) -### โš™๏ธ Miscellaneous Tasks +### โš™๏ธ CI - Retry tests to reduce flakyness (#836) + +### ๐Ÿงน Chores + - Update ruff invocation to include check parameter (#853) - Update Jina Embedder usage for V3 release (#1077) +### ๐ŸŒ€ Miscellaneous + +- Remove references to Python 3.7 (#601) +- Jina - add missing ranker to API reference (#610) +- Jina ranker: fix wrong URL in docstring (#628) +- Chore: add license classifiers (#680) +- Chore: change the pydoc renderer class (#718) +- Ci: install `pytest-rerunfailures` where needed; add retry config to `test-cov` script (#845) +- Chore: Jina - ruff update, don't ruff tests (#982) + ## [integrations/jina-v0.3.0] - 2024-03-19 ### ๐Ÿš€ Features @@ -22,13 +54,17 @@ - Fix order of API docs (#447) -This PR will also push the docs to Readme - ### ๐Ÿ“š Documentation - Update category slug (#442) - Disable-class-def (#556) +### ๐ŸŒ€ Miscellaneous + +- Jina - remove dead code (#422) +- Jina - review docstrings (#504) +- Make tests show coverage (#566) + ## [integrations/jina-v0.2.0] - 2024-02-14 ### ๐Ÿš€ Features @@ -39,7 +75,7 @@ This PR will also push the docs to Readme - Update paths and titles (#397) -### Jina +### ๐ŸŒ€ Miscellaneous - Update secrets management (#411) @@ -47,18 +83,22 @@ This PR will also push the docs to Readme ### ๐Ÿ› Bug Fixes -- Fix project urls (#96) - - +- Fix project URLs (#96) ### ๐Ÿšœ Refactor - Use `hatch_vcs` to manage integrations versioning (#103) -### โš™๏ธ Miscellaneous Tasks +### ๐Ÿงน Chores - [**breaking**] Rename model_name to model in the Jina integration (#230) +### ๐ŸŒ€ Miscellaneous + +- Change metadata to meta (#152) +- Optimize API key reading (#162) +- Refact!:change import paths (#254) + ## [integrations/jina-v0.0.1] - 2023-12-11 ### ๐Ÿš€ Features diff --git a/integrations/jina/examples/jina_reader_connector.py b/integrations/jina/examples/jina_reader_connector.py new file mode 100644 index 000000000..24b6f5db3 --- /dev/null +++ b/integrations/jina/examples/jina_reader_connector.py @@ -0,0 +1,47 @@ +# to make use of the JinaReaderConnector, we first need to install the Haystack integration +# pip install jina-haystack + +# then we must set the JINA_API_KEY environment variable +# export JINA_API_KEY= + + +from haystack_integrations.components.connectors.jina import JinaReaderConnector + +# we can use the JinaReaderConnector to process a URL and return the textual content of the page +reader = JinaReaderConnector(mode="read") +query = "https://example.com" +result = reader.run(query=query) + +print(result) +# {'documents': [Document(id=fa3e51e4ca91828086dca4f359b6e1ea2881e358f83b41b53c84616cb0b2f7cf, +# content: 'This domain is for use in illustrative examples in documents. You may use this domain in literature ...', +# meta: {'title': 'Example Domain', 'description': '', 'url': 'https://example.com/', 'usage': {'tokens': 42}})]} + + +# we can perform a web search by setting the mode to "search" +reader = JinaReaderConnector(mode="search") +query = "UEFA Champions League 2024" +result = reader.run(query=query) + +print(result) +# {'documents': Document(id=6a71abf9955594232037321a476d39a835c0cb7bc575d886ee0087c973c95940, +# content: '2024/25 UEFA Champions League: Matches, draw, final, key dates | UEFA Champions League | UEFA.com...', +# meta: {'title': '2024/25 UEFA Champions League: Matches, draw, final, key dates', +# 'description': 'What are the match dates? Where is the 2025 final? How will the competition work?', +# 'url': 'https://www.uefa.com/uefachampionsleague/news/...', +# 'usage': {'tokens': 5581}}), ...]} + + +# finally, we can perform fact-checking by setting the mode to "ground" (experimental) +reader = JinaReaderConnector(mode="ground") +query = "ChatGPT was launched in 2017" +result = reader.run(query=query) + +print(result) +# {'documents': [Document(id=f0c964dbc1ebb2d6584c8032b657150b9aa6e421f714cc1b9f8093a159127f0c, +# content: 'The statement that ChatGPT was launched in 2017 is incorrect. Multiple references confirm that ChatG...', +# meta: {'factuality': 0, 'result': False, 'references': [ +# {'url': 'https://en.wikipedia.org/wiki/ChatGPT', +# 'keyQuote': 'ChatGPT is a generative artificial intelligence (AI) chatbot developed by OpenAI and launched in 2022.', +# 'isSupportive': False}, ...], +# 'usage': {'tokens': 10188}})]} diff --git a/integrations/jina/pydoc/config.yml b/integrations/jina/pydoc/config.yml index 8c7a241f6..2d0ef4f87 100644 --- a/integrations/jina/pydoc/config.yml +++ b/integrations/jina/pydoc/config.yml @@ -6,6 +6,7 @@ loaders: "haystack_integrations.components.embedders.jina.document_embedder", "haystack_integrations.components.embedders.jina.text_embedder", "haystack_integrations.components.rankers.jina.ranker", + "haystack_integrations.components.connectors.jina.reader", ] ignore_when_discovered: ["__init__"] processors: diff --git a/integrations/jina/pyproject.toml b/integrations/jina/pyproject.toml index c89eeacb4..e3af086d0 100644 --- a/integrations/jina/pyproject.toml +++ b/integrations/jina/pyproject.toml @@ -132,18 +132,23 @@ ban-relative-imports = "parents" [tool.ruff.lint.per-file-ignores] # Tests can use magic values, assertions, and relative imports "tests/**/*" = ["PLR2004", "S101", "TID252"] +# examples can contain "print" commands +"examples/**/*" = ["T201"] [tool.coverage.run] source = ["haystack_integrations"] branch = true parallel = false - [tool.coverage.report] omit = ["*/tests/*", "*/__init__.py"] show_missing = true exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"] +[tool.pytest.ini_options] +minversion = "6.0" +markers = ["unit: unit tests", "integration: integration tests"] + [[tool.mypy.overrides]] module = ["haystack.*", "haystack_integrations.*", "pytest.*"] ignore_missing_imports = true diff --git a/integrations/jina/src/haystack_integrations/components/connectors/jina/__init__.py b/integrations/jina/src/haystack_integrations/components/connectors/jina/__init__.py new file mode 100644 index 000000000..95368df21 --- /dev/null +++ b/integrations/jina/src/haystack_integrations/components/connectors/jina/__init__.py @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2023-present deepset GmbH +# +# SPDX-License-Identifier: Apache-2.0 +from .reader import JinaReaderConnector +from .reader_mode import JinaReaderMode + +__all__ = ["JinaReaderConnector", "JinaReaderMode"] diff --git a/integrations/jina/src/haystack_integrations/components/connectors/jina/reader.py b/integrations/jina/src/haystack_integrations/components/connectors/jina/reader.py new file mode 100644 index 000000000..eb53329f7 --- /dev/null +++ b/integrations/jina/src/haystack_integrations/components/connectors/jina/reader.py @@ -0,0 +1,141 @@ +# SPDX-FileCopyrightText: 2023-present deepset GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +import json +from typing import Any, Dict, List, Optional, Union +from urllib.parse import quote + +import requests +from haystack import Document, component, default_from_dict, default_to_dict +from haystack.utils import Secret, deserialize_secrets_inplace + +from .reader_mode import JinaReaderMode + +READER_ENDPOINT_URL_BY_MODE = { + JinaReaderMode.READ: "https://r.jina.ai/", + JinaReaderMode.SEARCH: "https://s.jina.ai/", + JinaReaderMode.GROUND: "https://g.jina.ai/", +} + + +@component +class JinaReaderConnector: + """ + A component that interacts with Jina AI's reader service to process queries and return documents. + + This component supports different modes of operation: `read`, `search`, and `ground`. + + Usage example: + ```python + from haystack_integrations.components.connectors.jina import JinaReaderConnector + + reader = JinaReaderConnector(mode="read") + query = "https://example.com" + result = reader.run(query=query) + document = result["documents"][0] + print(document.content) + + >>> "This domain is for use in illustrative examples..." + ``` + """ + + def __init__( + self, + mode: Union[JinaReaderMode, str], + api_key: Secret = Secret.from_env_var("JINA_API_KEY"), # noqa: B008 + json_response: bool = True, + ): + """ + Initialize a JinaReader instance. + + :param mode: The operation mode for the reader (`read`, `search` or `ground`). + - `read`: process a URL and return the textual content of the page. + - `search`: search the web and return textual content of the most relevant pages. + - `ground`: call the grounding engine to perform fact checking. + For more information on the modes, see the [Jina Reader documentation](https://jina.ai/reader/). + :param api_key: The Jina API key. It can be explicitly provided or automatically read from the + environment variable JINA_API_KEY (recommended). + :param json_response: Controls the response format from the Jina Reader API. + If `True`, requests a JSON response, resulting in Documents with rich structured metadata. + If `False`, requests a raw response, resulting in one Document with minimal metadata. + """ + self.api_key = api_key + self.json_response = json_response + + if isinstance(mode, str): + mode = JinaReaderMode.from_str(mode) + self.mode = mode + + def to_dict(self) -> Dict[str, Any]: + """ + Serializes the component to a dictionary. + :returns: + Dictionary with serialized data. + """ + return default_to_dict( + self, + api_key=self.api_key.to_dict(), + mode=str(self.mode), + json_response=self.json_response, + ) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "JinaReaderConnector": + """ + Deserializes the component from a dictionary. + :param data: + Dictionary to deserialize from. + :returns: + Deserialized component. + """ + deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"]) + return default_from_dict(cls, data) + + def _json_to_document(self, data: dict) -> Document: + """ + Convert a JSON response/record to a Document, depending on the reader mode. + """ + if self.mode == JinaReaderMode.GROUND: + content = data.pop("reason") + else: + content = data.pop("content") + document = Document(content=content, meta=data) + return document + + @component.output_types(document=List[Document]) + def run(self, query: str, headers: Optional[Dict[str, str]] = None): + """ + Process the query/URL using the Jina AI reader service. + + :param query: The query string or URL to process. + :param headers: Optional headers to include in the request for customization. Refer to the + [Jina Reader documentation](https://jina.ai/reader/) for more information. + + :returns: + A dictionary with the following keys: + - `documents`: A list of `Document` objects. + """ + headers = headers or {} + headers["Authorization"] = f"Bearer {self.api_key.resolve_value()}" + + if self.json_response: + headers["Accept"] = "application/json" + + endpoint_url = READER_ENDPOINT_URL_BY_MODE[self.mode] + encoded_target = quote(query, safe="") + url = f"{endpoint_url}{encoded_target}" + + response = requests.get(url, headers=headers, timeout=60) + + # raw response: we just return a single Document with text + if not self.json_response: + meta = {"content_type": response.headers["Content-Type"], "query": query} + return {"documents": [Document(content=response.content, meta=meta)]} + + response_json = json.loads(response.content).get("data", {}) + if self.mode == JinaReaderMode.SEARCH: + documents = [self._json_to_document(record) for record in response_json] + return {"documents": documents} + + return {"documents": [self._json_to_document(response_json)]} diff --git a/integrations/jina/src/haystack_integrations/components/connectors/jina/reader_mode.py b/integrations/jina/src/haystack_integrations/components/connectors/jina/reader_mode.py new file mode 100644 index 000000000..2ccf7250b --- /dev/null +++ b/integrations/jina/src/haystack_integrations/components/connectors/jina/reader_mode.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: 2023-present deepset GmbH +# +# SPDX-License-Identifier: Apache-2.0 +from enum import Enum + + +class JinaReaderMode(Enum): + """ + Enum representing modes for the Jina Reader. + + Modes: + READ: Process a URL and return the textual content of the page. + SEARCH: Search the web and return the textual content of the most relevant pages. + GROUND: Call the grounding engine to perform fact checking. + + """ + + READ = "read" + SEARCH = "search" + GROUND = "ground" + + def __str__(self): + return self.value + + @classmethod + def from_str(cls, string: str) -> "JinaReaderMode": + """ + Create the reader mode from a string. + + :param string: + String to convert. + :returns: + Reader mode. + """ + enum_map = {e.value: e for e in JinaReaderMode} + reader_mode = enum_map.get(string) + if reader_mode is None: + msg = f"Unknown reader mode '{string}'. Supported modes are: {list(enum_map.keys())}" + raise ValueError(msg) + return reader_mode diff --git a/integrations/jina/src/haystack_integrations/components/embedders/jina/document_embedder.py b/integrations/jina/src/haystack_integrations/components/embedders/jina/document_embedder.py index 715092b8a..103132faf 100644 --- a/integrations/jina/src/haystack_integrations/components/embedders/jina/document_embedder.py +++ b/integrations/jina/src/haystack_integrations/components/embedders/jina/document_embedder.py @@ -200,7 +200,7 @@ def run(self, documents: List[Document]): - `meta`: A dictionary with metadata including the model name and usage statistics. :raises TypeError: If the input is not a list of Documents. """ - if not isinstance(documents, list) or documents and not isinstance(documents[0], Document): + if not isinstance(documents, list) or (documents and not isinstance(documents[0], Document)): msg = ( "JinaDocumentEmbedder expects a list of Documents as input." "In case you want to embed a string, please use the JinaTextEmbedder." diff --git a/integrations/jina/tests/test_reader_connector.py b/integrations/jina/tests/test_reader_connector.py new file mode 100644 index 000000000..449f73df8 --- /dev/null +++ b/integrations/jina/tests/test_reader_connector.py @@ -0,0 +1,141 @@ +import json +import os +from unittest.mock import patch + +import pytest +from haystack import Document +from haystack.utils import Secret + +from haystack_integrations.components.connectors.jina import JinaReaderConnector, JinaReaderMode + + +class TestJinaReaderConnector: + def test_init_with_custom_parameters(self, monkeypatch): + monkeypatch.setenv("TEST_KEY", "test-api-key") + reader = JinaReaderConnector(mode="read", api_key=Secret.from_env_var("TEST_KEY"), json_response=False) + + assert reader.mode == JinaReaderMode.READ + assert reader.api_key.resolve_value() == "test-api-key" + assert reader.json_response is False + + def test_init_with_invalid_mode(self): + with pytest.raises(ValueError): + JinaReaderConnector(mode="INVALID") + + def test_to_dict(self, monkeypatch): + monkeypatch.setenv("TEST_KEY", "test-api-key") + reader = JinaReaderConnector(mode="search", api_key=Secret.from_env_var("TEST_KEY"), json_response=True) + + serialized = reader.to_dict() + + assert serialized["type"] == "haystack_integrations.components.connectors.jina.reader.JinaReaderConnector" + assert "init_parameters" in serialized + + init_params = serialized["init_parameters"] + assert init_params["mode"] == "search" + assert init_params["json_response"] is True + assert "api_key" in init_params + assert init_params["api_key"]["type"] == "env_var" + + def test_from_dict(self, monkeypatch): + monkeypatch.setenv("JINA_API_KEY", "test-api-key") + component_dict = { + "type": "haystack_integrations.components.connectors.jina.reader.JinaReaderConnector", + "init_parameters": { + "api_key": {"type": "env_var", "env_vars": ["JINA_API_KEY"], "strict": True}, + "mode": "read", + "json_response": True, + }, + } + + reader = JinaReaderConnector.from_dict(component_dict) + + assert isinstance(reader, JinaReaderConnector) + assert reader.mode == JinaReaderMode.READ + assert reader.json_response is True + assert reader.api_key.resolve_value() == "test-api-key" + + def test_json_to_document_read_mode(self, monkeypatch): + monkeypatch.setenv("TEST_KEY", "test-api-key") + reader = JinaReaderConnector(mode="read") + + data = {"content": "Mocked content", "title": "Mocked Title", "url": "https://example.com"} + document = reader._json_to_document(data) + + assert isinstance(document, Document) + assert document.content == "Mocked content" + assert document.meta["title"] == "Mocked Title" + assert document.meta["url"] == "https://example.com" + + def test_json_to_document_ground_mode(self, monkeypatch): + monkeypatch.setenv("TEST_KEY", "test-api-key") + reader = JinaReaderConnector(mode="ground") + + data = { + "factuality": 0, + "result": False, + "reason": "The statement is contradicted by...", + "references": [{"url": "https://example.com", "keyQuote": "Mocked key quote", "isSupportive": False}], + } + + document = reader._json_to_document(data) + assert isinstance(document, Document) + assert document.content == "The statement is contradicted by..." + assert document.meta["factuality"] == 0 + assert document.meta["result"] is False + assert document.meta["references"] == [ + {"url": "https://example.com", "keyQuote": "Mocked key quote", "isSupportive": False} + ] + + @patch("requests.get") + def test_run_with_mocked_response(self, mock_get, monkeypatch): + monkeypatch.setenv("JINA_API_KEY", "test-api-key") + mock_json_response = { + "data": {"content": "Mocked content", "title": "Mocked Title", "url": "https://example.com"} + } + mock_get.return_value.content = json.dumps(mock_json_response).encode("utf-8") + mock_get.return_value.headers = {"Content-Type": "application/json"} + + reader = JinaReaderConnector(mode="read") + result = reader.run(query="https://example.com") + + assert mock_get.call_count == 1 + assert mock_get.call_args[0][0] == "https://r.jina.ai/https%3A%2F%2Fexample.com" + assert mock_get.call_args[1]["headers"] == { + "Authorization": "Bearer test-api-key", + "Accept": "application/json", + } + + assert len(result) == 1 + document = result["documents"][0] + assert isinstance(document, Document) + assert document.content == "Mocked content" + assert document.meta["title"] == "Mocked Title" + assert document.meta["url"] == "https://example.com" + + @pytest.mark.skipif(not os.environ.get("JINA_API_KEY", None), reason="JINA_API_KEY env var not set") + @pytest.mark.integration + def test_run_reader_mode(self): + reader = JinaReaderConnector(mode="read") + result = reader.run(query="https://example.com") + + assert len(result) == 1 + document = result["documents"][0] + assert isinstance(document, Document) + assert "This domain is for use in illustrative examples" in document.content + assert document.meta["title"] == "Example Domain" + assert document.meta["url"] == "https://example.com/" + + @pytest.mark.skipif(not os.environ.get("JINA_API_KEY", None), reason="JINA_API_KEY env var not set") + @pytest.mark.integration + def test_run_search_mode(self): + reader = JinaReaderConnector(mode="search") + result = reader.run(query="When was Jina AI founded?") + + assert len(result) >= 1 + for doc in result["documents"]: + assert isinstance(doc, Document) + assert doc.content + assert "title" in doc.meta + assert "url" in doc.meta + assert "description" in doc.meta diff --git a/integrations/llama_cpp/src/haystack_integrations/components/generators/llama_cpp/__init__.py b/integrations/llama_cpp/src/haystack_integrations/components/generators/llama_cpp/__init__.py index 10b20d363..a85dbfd88 100644 --- a/integrations/llama_cpp/src/haystack_integrations/components/generators/llama_cpp/__init__.py +++ b/integrations/llama_cpp/src/haystack_integrations/components/generators/llama_cpp/__init__.py @@ -5,4 +5,4 @@ from .chat.chat_generator import LlamaCppChatGenerator from .generator import LlamaCppGenerator -__all__ = ["LlamaCppGenerator", "LlamaCppChatGenerator"] +__all__ = ["LlamaCppChatGenerator", "LlamaCppGenerator"] diff --git a/integrations/nvidia/README.md b/integrations/nvidia/README.md index e28f0ede9..558c34d28 100644 --- a/integrations/nvidia/README.md +++ b/integrations/nvidia/README.md @@ -38,7 +38,7 @@ hatch run test To only run unit tests: ``` -hatch run test -m"not integration" +hatch run test -m "not integration" ``` To run the linters `ruff` and `mypy`: diff --git a/integrations/nvidia/src/haystack_integrations/components/embedders/nvidia/__init__.py b/integrations/nvidia/src/haystack_integrations/components/embedders/nvidia/__init__.py index 827ad7dc6..c6ecea7b1 100644 --- a/integrations/nvidia/src/haystack_integrations/components/embedders/nvidia/__init__.py +++ b/integrations/nvidia/src/haystack_integrations/components/embedders/nvidia/__init__.py @@ -6,4 +6,4 @@ from .text_embedder import NvidiaTextEmbedder from .truncate import EmbeddingTruncateMode -__all__ = ["NvidiaDocumentEmbedder", "NvidiaTextEmbedder", "EmbeddingTruncateMode"] +__all__ = ["EmbeddingTruncateMode", "NvidiaDocumentEmbedder", "NvidiaTextEmbedder"] diff --git a/integrations/nvidia/src/haystack_integrations/components/embedders/nvidia/document_embedder.py b/integrations/nvidia/src/haystack_integrations/components/embedders/nvidia/document_embedder.py index 606ec78fd..b417fa737 100644 --- a/integrations/nvidia/src/haystack_integrations/components/embedders/nvidia/document_embedder.py +++ b/integrations/nvidia/src/haystack_integrations/components/embedders/nvidia/document_embedder.py @@ -2,16 +2,19 @@ # # SPDX-License-Identifier: Apache-2.0 +import os import warnings from typing import Any, Dict, List, Optional, Tuple, Union -from haystack import Document, component, default_from_dict, default_to_dict +from haystack import Document, component, default_from_dict, default_to_dict, logging from haystack.utils import Secret, deserialize_secrets_inplace from tqdm import tqdm from haystack_integrations.components.embedders.nvidia.truncate import EmbeddingTruncateMode from haystack_integrations.utils.nvidia import NimBackend, is_hosted, url_validation +logger = logging.getLogger(__name__) + _DEFAULT_API_URL = "https://ai.api.nvidia.com/v1/retrieval/nvidia" @@ -47,6 +50,7 @@ def __init__( meta_fields_to_embed: Optional[List[str]] = None, embedding_separator: str = "\n", truncate: Optional[Union[EmbeddingTruncateMode, str]] = None, + timeout: Optional[float] = None, ): """ Create a NvidiaTextEmbedder component. @@ -74,8 +78,11 @@ def __init__( :param embedding_separator: Separator used to concatenate the meta fields to the Document text. :param truncate: - Specifies how inputs longer that the maximum token length should be truncated. + Specifies how inputs longer than the maximum token length should be truncated. If None the behavior is model-dependent, see the official documentation for more information. + :param timeout: + Timeout for request calls, if not set it is inferred from the `NVIDIA_TIMEOUT` environment variable + or set to 60 by default. """ self.api_key = api_key @@ -98,6 +105,10 @@ def __init__( if is_hosted(api_url) and not self.model: # manually set default model self.model = "nvidia/nv-embedqa-e5-v5" + if timeout is None: + timeout = float(os.environ.get("NVIDIA_TIMEOUT", 60.0)) + self.timeout = timeout + def default_model(self): """Set default model in local NIM mode.""" valid_models = [ @@ -128,10 +139,11 @@ def warm_up(self): if self.truncate is not None: model_kwargs["truncate"] = str(self.truncate) self.backend = NimBackend( - self.model, + model=self.model, api_url=self.api_url, api_key=self.api_key, model_kwargs=model_kwargs, + timeout=self.timeout, ) self._initialized = True @@ -158,6 +170,7 @@ def to_dict(self) -> Dict[str, Any]: meta_fields_to_embed=self.meta_fields_to_embed, embedding_separator=self.embedding_separator, truncate=str(self.truncate) if self.truncate is not None else None, + timeout=self.timeout, ) @classmethod @@ -229,7 +242,7 @@ def run(self, documents: List[Document]): if not self._initialized: msg = "The embedding model has not been loaded. Please call warm_up() before running." raise RuntimeError(msg) - elif not isinstance(documents, list) or documents and not isinstance(documents[0], Document): + elif not isinstance(documents, list) or (documents and not isinstance(documents[0], Document)): msg = ( "NvidiaDocumentEmbedder expects a list of Documents as input." "In case you want to embed a string, please use the NvidiaTextEmbedder." @@ -238,8 +251,7 @@ def run(self, documents: List[Document]): for doc in documents: if not doc.content: - msg = f"Document '{doc.id}' has no content to embed." - raise ValueError(msg) + logger.warning(f"Document '{doc.id}' has no content to embed.") texts_to_embed = self._prepare_texts_to_embed(documents) embeddings, metadata = self._embed_batch(texts_to_embed, self.batch_size) diff --git a/integrations/nvidia/src/haystack_integrations/components/embedders/nvidia/text_embedder.py b/integrations/nvidia/src/haystack_integrations/components/embedders/nvidia/text_embedder.py index 4b7072f33..a93aa8caa 100644 --- a/integrations/nvidia/src/haystack_integrations/components/embedders/nvidia/text_embedder.py +++ b/integrations/nvidia/src/haystack_integrations/components/embedders/nvidia/text_embedder.py @@ -2,15 +2,18 @@ # # SPDX-License-Identifier: Apache-2.0 +import os import warnings from typing import Any, Dict, List, Optional, Union -from haystack import component, default_from_dict, default_to_dict +from haystack import component, default_from_dict, default_to_dict, logging from haystack.utils import Secret, deserialize_secrets_inplace from haystack_integrations.components.embedders.nvidia.truncate import EmbeddingTruncateMode from haystack_integrations.utils.nvidia import NimBackend, is_hosted, url_validation +logger = logging.getLogger(__name__) + _DEFAULT_API_URL = "https://ai.api.nvidia.com/v1/retrieval/nvidia" @@ -44,6 +47,7 @@ def __init__( prefix: str = "", suffix: str = "", truncate: Optional[Union[EmbeddingTruncateMode, str]] = None, + timeout: Optional[float] = None, ): """ Create a NvidiaTextEmbedder component. @@ -64,6 +68,9 @@ def __init__( :param truncate: Specifies how inputs longer that the maximum token length should be truncated. If None the behavior is model-dependent, see the official documentation for more information. + :param timeout: + Timeout for request calls, if not set it is inferred from the `NVIDIA_TIMEOUT` environment variable + or set to 60 by default. """ self.api_key = api_key @@ -82,6 +89,10 @@ def __init__( if is_hosted(api_url) and not self.model: # manually set default model self.model = "nvidia/nv-embedqa-e5-v5" + if timeout is None: + timeout = float(os.environ.get("NVIDIA_TIMEOUT", 60.0)) + self.timeout = timeout + def default_model(self): """Set default model in local NIM mode.""" valid_models = [ @@ -89,6 +100,12 @@ def default_model(self): ] name = next(iter(valid_models), None) if name: + logger.warning( + "Default model is set as: {model_name}. \n" + "Set model using model parameter. \n" + "To get available models use available_models property.", + model_name=name, + ) warnings.warn( f"Default model is set as: {name}. \n" "Set model using model parameter. \n" @@ -112,10 +129,11 @@ def warm_up(self): if self.truncate is not None: model_kwargs["truncate"] = str(self.truncate) self.backend = NimBackend( - self.model, + model=self.model, api_url=self.api_url, api_key=self.api_key, model_kwargs=model_kwargs, + timeout=self.timeout, ) self._initialized = True @@ -138,6 +156,7 @@ def to_dict(self) -> Dict[str, Any]: prefix=self.prefix, suffix=self.suffix, truncate=str(self.truncate) if self.truncate is not None else None, + timeout=self.timeout, ) @classmethod @@ -150,7 +169,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "NvidiaTextEmbedder": :returns: The deserialized component. """ - deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"]) + init_parameters = data.get("init_parameters", {}) + if init_parameters: + deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"]) return default_from_dict(cls, data) @component.output_types(embedding=List[float], meta=Dict[str, Any]) @@ -162,7 +183,7 @@ def run(self, text: str): The text to embed. :returns: A dictionary with the following keys and values: - - `embedding` - Embeddng of the text. + - `embedding` - Embedding of the text. - `meta` - Metadata on usage statistics, etc. :raises RuntimeError: If the component was not initialized. diff --git a/integrations/nvidia/src/haystack_integrations/components/generators/nvidia/generator.py b/integrations/nvidia/src/haystack_integrations/components/generators/nvidia/generator.py index 5bf71a9e1..5047d0682 100644 --- a/integrations/nvidia/src/haystack_integrations/components/generators/nvidia/generator.py +++ b/integrations/nvidia/src/haystack_integrations/components/generators/nvidia/generator.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 +import os import warnings from typing import Any, Dict, List, Optional @@ -49,6 +50,7 @@ def __init__( api_url: str = _DEFAULT_API_URL, api_key: Optional[Secret] = Secret.from_env_var("NVIDIA_API_KEY"), model_arguments: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, ): """ Create a NvidiaGenerator component. @@ -70,6 +72,9 @@ def __init__( specific to a model. Search your model in the [NVIDIA NIM](https://ai.nvidia.com) to find the arguments it accepts. + :param timeout: + Timeout for request calls, if not set it is inferred from the `NVIDIA_TIMEOUT` environment variable + or set to 60 by default. """ self._model = model self._api_url = url_validation(api_url, _DEFAULT_API_URL, ["v1/chat/completions"]) @@ -79,6 +84,9 @@ def __init__( self._backend: Optional[Any] = None self.is_hosted = is_hosted(api_url) + if timeout is None: + timeout = float(os.environ.get("NVIDIA_TIMEOUT", 60.0)) + self.timeout = timeout def default_model(self): """Set default model in local NIM mode.""" @@ -110,10 +118,11 @@ def warm_up(self): msg = "API key is required for hosted NVIDIA NIMs." raise ValueError(msg) self._backend = NimBackend( - self._model, + model=self._model, api_url=self._api_url, api_key=self._api_key, model_kwargs=self._model_arguments, + timeout=self.timeout, ) if not self.is_hosted and not self._model: diff --git a/integrations/nvidia/src/haystack_integrations/components/rankers/nvidia/ranker.py b/integrations/nvidia/src/haystack_integrations/components/rankers/nvidia/ranker.py index 9938b37d1..66203a490 100644 --- a/integrations/nvidia/src/haystack_integrations/components/rankers/nvidia/ranker.py +++ b/integrations/nvidia/src/haystack_integrations/components/rankers/nvidia/ranker.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 +import os import warnings from typing import Any, Dict, List, Optional, Union @@ -58,6 +59,11 @@ def __init__( api_url: Optional[str] = None, api_key: Optional[Secret] = Secret.from_env_var("NVIDIA_API_KEY"), top_k: int = 5, + query_prefix: str = "", + document_prefix: str = "", + meta_fields_to_embed: Optional[List[str]] = None, + embedding_separator: str = "\n", + timeout: Optional[float] = None, ): """ Create a NvidiaRanker component. @@ -72,6 +78,19 @@ def __init__( Custom API URL for the NVIDIA NIM. :param top_k: Number of documents to return. + :param query_prefix: + A string to add at the beginning of the query text before ranking. + Use it to prepend the text with an instruction, as required by reranking models like `bge`. + :param document_prefix: + A string to add at the beginning of each document before ranking. You can use it to prepend the document + with an instruction, as required by embedding models like `bge`. + :param meta_fields_to_embed: + List of metadata fields to embed with the document. + :param embedding_separator: + Separator to concatenate metadata fields to the document. + :param timeout: + Timeout for request calls, if not set it is inferred from the `NVIDIA_TIMEOUT` environment variable + or set to 60 by default. """ if model is not None and not isinstance(model, str): msg = "Ranker expects the `model` parameter to be a string." @@ -86,27 +105,35 @@ def __init__( raise TypeError(msg) # todo: detect default in non-hosted case (when api_url is provided) - self._model = model or _DEFAULT_MODEL - self._truncate = truncate - self._api_key = api_key + self.model = model or _DEFAULT_MODEL + self.truncate = truncate + self.api_key = api_key # if no api_url is provided, we're using a hosted model and can # - assume the default url will work, because there's only one model # - assume we won't call backend.models() if api_url is not None: - self._api_url = url_validation(api_url, None, ["v1/ranking"]) - self._endpoint = None # we let backend.rank() handle the endpoint + self.api_url = url_validation(api_url, None, ["v1/ranking"]) + self.endpoint = None # we let backend.rank() handle the endpoint else: - if self._model not in _MODEL_ENDPOINT_MAP: + if self.model not in _MODEL_ENDPOINT_MAP: msg = f"Model '{model}' is unknown. Please provide an api_url to access it." raise ValueError(msg) - self._api_url = None # we handle the endpoint - self._endpoint = _MODEL_ENDPOINT_MAP[self._model] + self.api_url = None # we handle the endpoint + self.endpoint = _MODEL_ENDPOINT_MAP[self.model] if api_key is None: self._api_key = Secret.from_env_var("NVIDIA_API_KEY") - self._top_k = top_k + self.top_k = top_k self._initialized = False self._backend: Optional[Any] = None + self.query_prefix = query_prefix + self.document_prefix = document_prefix + self.meta_fields_to_embed = meta_fields_to_embed or [] + self.embedding_separator = embedding_separator + if timeout is None: + timeout = float(os.environ.get("NVIDIA_TIMEOUT", 60.0)) + self.timeout = timeout + def to_dict(self) -> Dict[str, Any]: """ Serialize the ranker to a dictionary. @@ -115,11 +142,16 @@ def to_dict(self) -> Dict[str, Any]: """ return default_to_dict( self, - model=self._model, - top_k=self._top_k, - truncate=self._truncate, - api_url=self._api_url, - api_key=self._api_key.to_dict() if self._api_key else None, + model=self.model, + top_k=self.top_k, + truncate=self.truncate, + api_url=self.api_url, + api_key=self.api_key.to_dict() if self.api_key else None, + query_prefix=self.query_prefix, + document_prefix=self.document_prefix, + meta_fields_to_embed=self.meta_fields_to_embed, + embedding_separator=self.embedding_separator, + timeout=self.timeout, ) @classmethod @@ -143,18 +175,31 @@ def warm_up(self): """ if not self._initialized: model_kwargs = {} - if self._truncate is not None: - model_kwargs.update(truncate=str(self._truncate)) + if self.truncate is not None: + model_kwargs.update(truncate=str(self.truncate)) self._backend = NimBackend( - self._model, - api_url=self._api_url, - api_key=self._api_key, + model=self.model, + api_url=self.api_url, + api_key=self.api_key, model_kwargs=model_kwargs, + timeout=self.timeout, ) - if not self._model: - self._model = _DEFAULT_MODEL + if not self.model: + self.model = _DEFAULT_MODEL self._initialized = True + def _prepare_documents_to_embed(self, documents: List[Document]) -> List[str]: + document_texts = [] + for doc in documents: + meta_values_to_embed = [ + str(doc.meta[key]) + for key in self.meta_fields_to_embed + if key in doc.meta and doc.meta[key] # noqa: RUF019 + ] + text_to_embed = self.embedding_separator.join([*meta_values_to_embed, doc.content or ""]) + document_texts.append(self.document_prefix + text_to_embed) + return document_texts + @component.output_types(documents=List[Document]) def run( self, @@ -193,18 +238,22 @@ def run( if len(documents) == 0: return {"documents": []} - top_k = top_k if top_k is not None else self._top_k + top_k = top_k if top_k is not None else self.top_k if top_k < 1: logger.warning("top_k should be at least 1, returning nothing") warnings.warn("top_k should be at least 1, returning nothing", stacklevel=2) return {"documents": []} assert self._backend is not None + + query_text = self.query_prefix + query + document_texts = self._prepare_documents_to_embed(documents=documents) + # rank result is list[{index: int, logit: float}] sorted by logit sorted_indexes_and_scores = self._backend.rank( - query, - documents, - endpoint=self._endpoint, + query_text=query_text, + document_texts=document_texts, + endpoint=self.endpoint, ) sorted_documents = [] for item in sorted_indexes_and_scores[:top_k]: diff --git a/integrations/nvidia/src/haystack_integrations/utils/nvidia/__init__.py b/integrations/nvidia/src/haystack_integrations/utils/nvidia/__init__.py index f08cda6cd..0b69c8d24 100644 --- a/integrations/nvidia/src/haystack_integrations/utils/nvidia/__init__.py +++ b/integrations/nvidia/src/haystack_integrations/utils/nvidia/__init__.py @@ -5,4 +5,4 @@ from .nim_backend import Model, NimBackend from .utils import is_hosted, url_validation -__all__ = ["NimBackend", "Model", "is_hosted", "url_validation"] +__all__ = ["Model", "NimBackend", "is_hosted", "url_validation"] diff --git a/integrations/nvidia/src/haystack_integrations/utils/nvidia/nim_backend.py b/integrations/nvidia/src/haystack_integrations/utils/nvidia/nim_backend.py index 0279cf608..15b35e4b2 100644 --- a/integrations/nvidia/src/haystack_integrations/utils/nvidia/nim_backend.py +++ b/integrations/nvidia/src/haystack_integrations/utils/nvidia/nim_backend.py @@ -2,14 +2,17 @@ # # SPDX-License-Identifier: Apache-2.0 +import os from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple import requests -from haystack import Document +from haystack import logging from haystack.utils import Secret -REQUEST_TIMEOUT = 60 +logger = logging.getLogger(__name__) + +REQUEST_TIMEOUT = 60.0 @dataclass @@ -35,6 +38,7 @@ def __init__( api_url: str, api_key: Optional[Secret] = Secret.from_env_var("NVIDIA_API_KEY"), model_kwargs: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, ): headers = { "Content-Type": "application/json", @@ -50,6 +54,9 @@ def __init__( self.model = model self.api_url = api_url self.model_kwargs = model_kwargs or {} + if timeout is None: + timeout = float(os.environ.get("NVIDIA_TIMEOUT", REQUEST_TIMEOUT)) + self.timeout = timeout def embed(self, texts: List[str]) -> Tuple[List[List[float]], Dict[str, Any]]: url = f"{self.api_url}/embeddings" @@ -62,10 +69,11 @@ def embed(self, texts: List[str]) -> Tuple[List[List[float]], Dict[str, Any]]: "input": texts, **self.model_kwargs, }, - timeout=REQUEST_TIMEOUT, + timeout=self.timeout, ) res.raise_for_status() except requests.HTTPError as e: + logger.error("Error when calling NIM embedding endpoint: Error - {error}", error=e.response.text) msg = f"Failed to query embedding endpoint: Error - {e.response.text}" raise ValueError(msg) from e @@ -94,10 +102,11 @@ def generate(self, prompt: str) -> Tuple[List[str], List[Dict[str, Any]]]: ], **self.model_kwargs, }, - timeout=REQUEST_TIMEOUT, + timeout=self.timeout, ) res.raise_for_status() except requests.HTTPError as e: + logger.error("Error when calling NIM chat completion endpoint: Error - {error}", error=e.response.text) msg = f"Failed to query chat completion endpoint: Error - {e.response.text}" raise ValueError(msg) from e @@ -132,21 +141,22 @@ def models(self) -> List[Model]: res = self.session.get( url, - timeout=REQUEST_TIMEOUT, + timeout=self.timeout, ) res.raise_for_status() data = res.json()["data"] models = [Model(element["id"]) for element in data if "id" in element] if not models: + logger.error("No hosted model were found at URL '{u}'.", u=url) msg = f"No hosted model were found at URL '{url}'." raise ValueError(msg) return models def rank( self, - query: str, - documents: List[Document], + query_text: str, + document_texts: List[str], endpoint: Optional[str] = None, ) -> List[Dict[str, Any]]: url = endpoint or f"{self.api_url}/ranking" @@ -156,18 +166,22 @@ def rank( url, json={ "model": self.model, - "query": {"text": query}, - "passages": [{"text": doc.content} for doc in documents], + "query": {"text": query_text}, + "passages": [{"text": text} for text in document_texts], **self.model_kwargs, }, - timeout=REQUEST_TIMEOUT, + timeout=self.timeout, ) res.raise_for_status() except requests.HTTPError as e: + logger.error("Error when calling NIM ranking endpoint: Error - {error}", error=e.response.text) msg = f"Failed to rank endpoint: Error - {e.response.text}" raise ValueError(msg) from e data = res.json() - assert "rankings" in data, f"Expected 'rankings' in response, got {data}" + if "rankings" not in data: + logger.error("Expected 'rankings' in response, got {d}", d=data) + msg = f"Expected 'rankings' in response, got {data}" + raise ValueError(msg) return data["rankings"] diff --git a/integrations/nvidia/tests/test_document_embedder.py b/integrations/nvidia/tests/test_document_embedder.py index 7e0e02f3d..8c01f0759 100644 --- a/integrations/nvidia/tests/test_document_embedder.py +++ b/integrations/nvidia/tests/test_document_embedder.py @@ -75,6 +75,7 @@ def test_to_dict(self, monkeypatch): "meta_fields_to_embed": [], "embedding_separator": "\n", "truncate": None, + "timeout": 60.0, }, } @@ -90,6 +91,7 @@ def test_to_dict_with_custom_init_parameters(self, monkeypatch): meta_fields_to_embed=["test_field"], embedding_separator=" | ", truncate=EmbeddingTruncateMode.END, + timeout=45.0, ) data = component.to_dict() assert data == { @@ -105,6 +107,7 @@ def test_to_dict_with_custom_init_parameters(self, monkeypatch): "meta_fields_to_embed": ["test_field"], "embedding_separator": " | ", "truncate": "END", + "timeout": 45.0, }, } @@ -123,6 +126,7 @@ def test_from_dict(self, monkeypatch): "meta_fields_to_embed": ["test_field"], "embedding_separator": " | ", "truncate": "START", + "timeout": 45.0, }, } component = NvidiaDocumentEmbedder.from_dict(data) @@ -135,6 +139,7 @@ def test_from_dict(self, monkeypatch): assert component.meta_fields_to_embed == ["test_field"] assert component.embedding_separator == " | " assert component.truncate == EmbeddingTruncateMode.START + assert component.timeout == 45.0 def test_from_dict_defaults(self, monkeypatch): monkeypatch.setenv("NVIDIA_API_KEY", "fake-api-key") @@ -152,6 +157,7 @@ def test_from_dict_defaults(self, monkeypatch): assert component.meta_fields_to_embed == [] assert component.embedding_separator == "\n" assert component.truncate is None + assert component.timeout == 60.0 def test_prepare_texts_to_embed_w_metadata(self): documents = [ @@ -347,7 +353,7 @@ def test_run_wrong_input_format(self): with pytest.raises(TypeError, match="NvidiaDocumentEmbedder expects a list of Documents as input"): embedder.run(documents=list_integers_input) - def test_run_empty_document(self): + def test_run_empty_document(self, caplog): model = "playground_nvolveqa_40k" api_key = Secret.from_token("fake-api-key") embedder = NvidiaDocumentEmbedder(model, api_key=api_key) @@ -355,8 +361,10 @@ def test_run_empty_document(self): embedder.warm_up() embedder.backend = MockBackend(model=model, api_key=api_key) - with pytest.raises(ValueError, match="no content to embed"): + # Write check using caplog that a logger.warning is raised + with caplog.at_level("WARNING"): embedder.run(documents=[Document(content="")]) + assert "has no content to embed." in caplog.text def test_run_on_empty_list(self): model = "playground_nvolveqa_40k" @@ -372,6 +380,19 @@ def test_run_on_empty_list(self): assert result["documents"] is not None assert not result["documents"] # empty list + def test_setting_timeout(self, monkeypatch): + monkeypatch.setenv("NVIDIA_API_KEY", "fake-api-key") + embedder = NvidiaDocumentEmbedder(timeout=10.0) + embedder.warm_up() + assert embedder.backend.timeout == 10.0 + + def test_setting_timeout_env(self, monkeypatch): + monkeypatch.setenv("NVIDIA_API_KEY", "fake-api-key") + monkeypatch.setenv("NVIDIA_TIMEOUT", "45") + embedder = NvidiaDocumentEmbedder() + embedder.warm_up() + assert embedder.backend.timeout == 45.0 + @pytest.mark.skipif( not os.environ.get("NVIDIA_API_KEY", None), reason="Export an env var called NVIDIA_API_KEY containing the Nvidia API key to run this test.", diff --git a/integrations/nvidia/tests/test_generator.py b/integrations/nvidia/tests/test_generator.py index 055830ae5..414de4884 100644 --- a/integrations/nvidia/tests/test_generator.py +++ b/integrations/nvidia/tests/test_generator.py @@ -124,6 +124,19 @@ def test_to_dict_with_custom_init_parameters(self, monkeypatch): }, } + def test_setting_timeout(self, monkeypatch): + monkeypatch.setenv("NVIDIA_API_KEY", "fake-api-key") + generator = NvidiaGenerator(timeout=10.0) + generator.warm_up() + assert generator._backend.timeout == 10.0 + + def test_setting_timeout_env(self, monkeypatch): + monkeypatch.setenv("NVIDIA_API_KEY", "fake-api-key") + monkeypatch.setenv("NVIDIA_TIMEOUT", "45") + generator = NvidiaGenerator() + generator.warm_up() + assert generator._backend.timeout == 45.0 + @pytest.mark.skipif( not os.environ.get("NVIDIA_NIM_GENERATOR_MODEL", None) or not os.environ.get("NVIDIA_NIM_ENDPOINT_URL", None), reason="Export an env var called NVIDIA_NIM_GENERATOR_MODEL containing the hosted model name and " diff --git a/integrations/nvidia/tests/test_ranker.py b/integrations/nvidia/tests/test_ranker.py index d66bb0f65..3d93dc028 100644 --- a/integrations/nvidia/tests/test_ranker.py +++ b/integrations/nvidia/tests/test_ranker.py @@ -19,8 +19,8 @@ class TestNvidiaRanker: def test_init_default(self, monkeypatch): monkeypatch.setenv("NVIDIA_API_KEY", "fake-api-key") client = NvidiaRanker() - assert client._model == _DEFAULT_MODEL - assert client._api_key == Secret.from_env_var("NVIDIA_API_KEY") + assert client.model == _DEFAULT_MODEL + assert client.api_key == Secret.from_env_var("NVIDIA_API_KEY") def test_init_with_parameters(self): client = NvidiaRanker( @@ -29,10 +29,10 @@ def test_init_with_parameters(self): top_k=3, truncate="END", ) - assert client._api_key == Secret.from_token("fake-api-key") - assert client._model == _DEFAULT_MODEL - assert client._top_k == 3 - assert client._truncate == RankerTruncateMode.END + assert client.api_key == Secret.from_token("fake-api-key") + assert client.model == _DEFAULT_MODEL + assert client.top_k == 3 + assert client.truncate == RankerTruncateMode.END def test_init_fail_wo_api_key(self, monkeypatch): monkeypatch.delenv("NVIDIA_API_KEY", raising=False) @@ -43,7 +43,7 @@ def test_init_fail_wo_api_key(self, monkeypatch): def test_init_pass_wo_api_key_w_api_url(self): url = "https://url.bogus/v1" client = NvidiaRanker(api_url=url) - assert client._api_url == url + assert client.api_url == url def test_warm_up_required(self): client = NvidiaRanker() @@ -271,6 +271,11 @@ def test_to_dict(self) -> None: "truncate": None, "api_url": None, "api_key": {"type": "env_var", "env_vars": ["NVIDIA_API_KEY"], "strict": True}, + "query_prefix": "", + "document_prefix": "", + "meta_fields_to_embed": [], + "embedding_separator": "\n", + "timeout": 60.0, }, } @@ -284,14 +289,24 @@ def test_from_dict(self) -> None: "truncate": None, "api_url": None, "api_key": {"type": "env_var", "env_vars": ["NVIDIA_API_KEY"], "strict": True}, + "query_prefix": "", + "document_prefix": "", + "meta_fields_to_embed": [], + "embedding_separator": "\n", + "timeout": 45.0, }, } ) - assert client._model == "nvidia/nv-rerankqa-mistral-4b-v3" - assert client._top_k == 5 - assert client._truncate is None - assert client._api_url is None - assert client._api_key == Secret.from_env_var("NVIDIA_API_KEY") + assert client.model == "nvidia/nv-rerankqa-mistral-4b-v3" + assert client.top_k == 5 + assert client.truncate is None + assert client.api_url is None + assert client.api_key == Secret.from_env_var("NVIDIA_API_KEY") + assert client.query_prefix == "" + assert client.document_prefix == "" + assert client.meta_fields_to_embed == [] + assert client.embedding_separator == "\n" + assert client.timeout == 45.0 def test_from_dict_defaults(self) -> None: client = NvidiaRanker.from_dict( @@ -300,8 +315,49 @@ def test_from_dict_defaults(self) -> None: "init_parameters": {}, } ) - assert client._model == "nvidia/nv-rerankqa-mistral-4b-v3" - assert client._top_k == 5 - assert client._truncate is None - assert client._api_url is None - assert client._api_key == Secret.from_env_var("NVIDIA_API_KEY") + assert client.model == "nvidia/nv-rerankqa-mistral-4b-v3" + assert client.top_k == 5 + assert client.truncate is None + assert client.api_url is None + assert client.api_key == Secret.from_env_var("NVIDIA_API_KEY") + assert client.query_prefix == "" + assert client.document_prefix == "" + assert client.meta_fields_to_embed == [] + assert client.embedding_separator == "\n" + assert client.timeout == 60.0 + + def test_setting_timeout(self, monkeypatch): + monkeypatch.setenv("NVIDIA_API_KEY", "fake-api-key") + client = NvidiaRanker(timeout=10.0) + client.warm_up() + assert client._backend.timeout == 10.0 + + def test_setting_timeout_env(self, monkeypatch): + monkeypatch.setenv("NVIDIA_API_KEY", "fake-api-key") + monkeypatch.setenv("NVIDIA_TIMEOUT", "45") + client = NvidiaRanker() + client.warm_up() + assert client._backend.timeout == 45.0 + + def test_prepare_texts_to_embed_w_metadata(self): + documents = [ + Document(content=f"document number {i}:\ncontent", meta={"meta_field": f"meta_value {i}"}) for i in range(5) + ] + + ranker = NvidiaRanker( + model=None, + api_key=Secret.from_token("fake-api-key"), + meta_fields_to_embed=["meta_field"], + embedding_separator=" | ", + ) + + prepared_texts = ranker._prepare_documents_to_embed(documents) + + # note that newline is replaced by space + assert prepared_texts == [ + "meta_value 0 | document number 0:\ncontent", + "meta_value 1 | document number 1:\ncontent", + "meta_value 2 | document number 2:\ncontent", + "meta_value 3 | document number 3:\ncontent", + "meta_value 4 | document number 4:\ncontent", + ] diff --git a/integrations/nvidia/tests/test_text_embedder.py b/integrations/nvidia/tests/test_text_embedder.py index 278fa5191..b572cc046 100644 --- a/integrations/nvidia/tests/test_text_embedder.py +++ b/integrations/nvidia/tests/test_text_embedder.py @@ -56,6 +56,7 @@ def test_to_dict(self, monkeypatch): "prefix": "", "suffix": "", "truncate": None, + "timeout": 60.0, }, } @@ -67,6 +68,7 @@ def test_to_dict_with_custom_init_parameters(self, monkeypatch): prefix="prefix", suffix="suffix", truncate=EmbeddingTruncateMode.START, + timeout=10.0, ) data = component.to_dict() assert data == { @@ -78,6 +80,7 @@ def test_to_dict_with_custom_init_parameters(self, monkeypatch): "prefix": "prefix", "suffix": "suffix", "truncate": "START", + "timeout": 10.0, }, } @@ -92,6 +95,7 @@ def test_from_dict(self, monkeypatch): "prefix": "prefix", "suffix": "suffix", "truncate": "START", + "timeout": 10.0, }, } component = NvidiaTextEmbedder.from_dict(data) @@ -100,6 +104,7 @@ def test_from_dict(self, monkeypatch): assert component.prefix == "prefix" assert component.suffix == "suffix" assert component.truncate == EmbeddingTruncateMode.START + assert component.timeout == 10.0 def test_from_dict_defaults(self, monkeypatch): monkeypatch.setenv("NVIDIA_API_KEY", "fake-api-key") @@ -175,6 +180,19 @@ def test_run_empty_string(self): with pytest.raises(ValueError, match="empty string"): embedder.run(text="") + def test_setting_timeout(self, monkeypatch): + monkeypatch.setenv("NVIDIA_API_KEY", "fake-api-key") + embedder = NvidiaTextEmbedder(timeout=10.0) + embedder.warm_up() + assert embedder.backend.timeout == 10.0 + + def test_setting_timeout_env(self, monkeypatch): + monkeypatch.setenv("NVIDIA_API_KEY", "fake-api-key") + monkeypatch.setenv("NVIDIA_TIMEOUT", "45") + embedder = NvidiaTextEmbedder() + embedder.warm_up() + assert embedder.backend.timeout == 45.0 + @pytest.mark.skipif( not os.environ.get("NVIDIA_NIM_EMBEDDER_MODEL", None) or not os.environ.get("NVIDIA_NIM_ENDPOINT_URL", None), reason="Export an env var called NVIDIA_NIM_EMBEDDER_MODEL containing the hosted model name and " diff --git a/integrations/ollama/CHANGELOG.md b/integrations/ollama/CHANGELOG.md index 55c6aa7b7..29c8dd910 100644 --- a/integrations/ollama/CHANGELOG.md +++ b/integrations/ollama/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [integrations/ollama-v2.0.0] - 2024-11-22 + +### ๐Ÿ› Bug Fixes + +- Adapt to Ollama client 0.4.0 (#1209) + ## [integrations/ollama-v1.1.0] - 2024-10-11 ### ๐Ÿš€ Features diff --git a/integrations/ollama/pyproject.toml b/integrations/ollama/pyproject.toml index 598d1d214..c9fc22f3d 100644 --- a/integrations/ollama/pyproject.toml +++ b/integrations/ollama/pyproject.toml @@ -19,7 +19,6 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Development Status :: 4 - Beta", "Programming Language :: Python", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -27,7 +26,7 @@ classifiers = [ "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] -dependencies = ["haystack-ai", "ollama"] +dependencies = ["haystack-ai", "ollama>=0.4.0"] [project.urls] Documentation = "https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/ollama#readme" @@ -63,7 +62,7 @@ cov-retry = ["test-cov-retry", "cov-report"] docs = ["pydoc-markdown pydoc/config.yml"] [[tool.hatch.envs.all.matrix]] -python = ["3.8", "3.9", "3.10", "3.11", "3.12"] +python = ["3.9", "3.10", "3.11", "3.12"] [tool.hatch.envs.lint] diff --git a/integrations/ollama/src/haystack_integrations/components/embedders/ollama/__init__.py b/integrations/ollama/src/haystack_integrations/components/embedders/ollama/__init__.py index 46042a1c9..822b3d0aa 100644 --- a/integrations/ollama/src/haystack_integrations/components/embedders/ollama/__init__.py +++ b/integrations/ollama/src/haystack_integrations/components/embedders/ollama/__init__.py @@ -1,4 +1,4 @@ from .document_embedder import OllamaDocumentEmbedder from .text_embedder import OllamaTextEmbedder -__all__ = ["OllamaTextEmbedder", "OllamaDocumentEmbedder"] +__all__ = ["OllamaDocumentEmbedder", "OllamaTextEmbedder"] diff --git a/integrations/ollama/src/haystack_integrations/components/embedders/ollama/document_embedder.py b/integrations/ollama/src/haystack_integrations/components/embedders/ollama/document_embedder.py index ac8f38f35..2fab6c72f 100644 --- a/integrations/ollama/src/haystack_integrations/components/embedders/ollama/document_embedder.py +++ b/integrations/ollama/src/haystack_integrations/components/embedders/ollama/document_embedder.py @@ -100,7 +100,7 @@ def _embed_batch( range(0, len(texts_to_embed), batch_size), disable=not self.progress_bar, desc="Calculating embeddings" ): batch = texts_to_embed[i] # Single batch only - result = self._client.embeddings(model=self.model, prompt=batch, options=generation_kwargs) + result = self._client.embeddings(model=self.model, prompt=batch, options=generation_kwargs).model_dump() all_embeddings.append(result["embedding"]) meta["model"] = self.model @@ -122,7 +122,7 @@ def run(self, documents: List[Document], generation_kwargs: Optional[Dict[str, A - `documents`: Documents with embedding information attached - `meta`: The metadata collected during the embedding process """ - if not isinstance(documents, list) or documents and not isinstance(documents[0], Document): + if not isinstance(documents, list) or (documents and not isinstance(documents[0], Document)): msg = ( "OllamaDocumentEmbedder expects a list of Documents as input." "In case you want to embed a list of strings, please use the OllamaTextEmbedder." diff --git a/integrations/ollama/src/haystack_integrations/components/embedders/ollama/text_embedder.py b/integrations/ollama/src/haystack_integrations/components/embedders/ollama/text_embedder.py index 7779c6d6e..b08b8bef3 100644 --- a/integrations/ollama/src/haystack_integrations/components/embedders/ollama/text_embedder.py +++ b/integrations/ollama/src/haystack_integrations/components/embedders/ollama/text_embedder.py @@ -62,7 +62,7 @@ def run(self, text: str, generation_kwargs: Optional[Dict[str, Any]] = None): - `embedding`: The computed embeddings - `meta`: The metadata collected during the embedding process """ - result = self._client.embeddings(model=self.model, prompt=text, options=generation_kwargs) + result = self._client.embeddings(model=self.model, prompt=text, options=generation_kwargs).model_dump() result["meta"] = {"model": self.model} return result diff --git a/integrations/ollama/src/haystack_integrations/components/generators/ollama/__init__.py b/integrations/ollama/src/haystack_integrations/components/generators/ollama/__init__.py index 41a02d0ac..24e4d2edb 100644 --- a/integrations/ollama/src/haystack_integrations/components/generators/ollama/__init__.py +++ b/integrations/ollama/src/haystack_integrations/components/generators/ollama/__init__.py @@ -1,4 +1,4 @@ from .chat.chat_generator import OllamaChatGenerator from .generator import OllamaGenerator -__all__ = ["OllamaGenerator", "OllamaChatGenerator"] +__all__ = ["OllamaChatGenerator", "OllamaGenerator"] diff --git a/integrations/ollama/src/haystack_integrations/components/generators/ollama/chat/chat_generator.py b/integrations/ollama/src/haystack_integrations/components/generators/ollama/chat/chat_generator.py index 558fd593e..b1be7a2db 100644 --- a/integrations/ollama/src/haystack_integrations/components/generators/ollama/chat/chat_generator.py +++ b/integrations/ollama/src/haystack_integrations/components/generators/ollama/chat/chat_generator.py @@ -4,7 +4,7 @@ from haystack.dataclasses import ChatMessage, StreamingChunk from haystack.utils.callable_serialization import deserialize_callable, serialize_callable -from ollama import Client +from ollama import ChatResponse, Client @component @@ -111,12 +111,13 @@ def from_dict(cls, data: Dict[str, Any]) -> "OllamaChatGenerator": def _message_to_dict(self, message: ChatMessage) -> Dict[str, str]: return {"role": message.role.value, "content": message.content} - def _build_message_from_ollama_response(self, ollama_response: Dict[str, Any]) -> ChatMessage: + def _build_message_from_ollama_response(self, ollama_response: ChatResponse) -> ChatMessage: """ Converts the non-streaming response from the Ollama API to a ChatMessage. """ - message = ChatMessage.from_assistant(content=ollama_response["message"]["content"]) - message.meta.update({key: value for key, value in ollama_response.items() if key != "message"}) + response_dict = ollama_response.model_dump() + message = ChatMessage.from_assistant(content=response_dict["message"]["content"]) + message.meta.update({key: value for key, value in response_dict.items() if key != "message"}) return message def _convert_to_streaming_response(self, chunks: List[StreamingChunk]) -> Dict[str, List[Any]]: @@ -133,9 +134,11 @@ def _build_chunk(self, chunk_response: Any) -> StreamingChunk: """ Converts the response from the Ollama API to a StreamingChunk. """ - content = chunk_response["message"]["content"] - meta = {key: value for key, value in chunk_response.items() if key != "message"} - meta["role"] = chunk_response["message"]["role"] + chunk_response_dict = chunk_response.model_dump() + + content = chunk_response_dict["message"]["content"] + meta = {key: value for key, value in chunk_response_dict.items() if key != "message"} + meta["role"] = chunk_response_dict["message"]["role"] chunk_message = StreamingChunk(content, meta) return chunk_message diff --git a/integrations/ollama/src/haystack_integrations/components/generators/ollama/generator.py b/integrations/ollama/src/haystack_integrations/components/generators/ollama/generator.py index 058948e8a..dad671c94 100644 --- a/integrations/ollama/src/haystack_integrations/components/generators/ollama/generator.py +++ b/integrations/ollama/src/haystack_integrations/components/generators/ollama/generator.py @@ -4,7 +4,7 @@ from haystack.dataclasses import StreamingChunk from haystack.utils.callable_serialization import deserialize_callable, serialize_callable -from ollama import Client +from ollama import Client, GenerateResponse @component @@ -118,15 +118,14 @@ def from_dict(cls, data: Dict[str, Any]) -> "OllamaGenerator": data["init_parameters"]["streaming_callback"] = deserialize_callable(serialized_callback_handler) return default_from_dict(cls, data) - def _convert_to_response(self, ollama_response: Dict[str, Any]) -> Dict[str, List[Any]]: + def _convert_to_response(self, ollama_response: GenerateResponse) -> Dict[str, List[Any]]: """ Converts a response from the Ollama API to the required Haystack format. """ + reply = ollama_response.response + meta = {key: value for key, value in ollama_response.model_dump().items() if key != "response"} - replies = [ollama_response["response"]] - meta = {key: value for key, value in ollama_response.items() if key != "response"} - - return {"replies": replies, "meta": [meta]} + return {"replies": [reply], "meta": [meta]} def _convert_to_streaming_response(self, chunks: List[StreamingChunk]) -> Dict[str, List[Any]]: """ @@ -154,8 +153,9 @@ def _build_chunk(self, chunk_response: Any) -> StreamingChunk: """ Converts the response from the Ollama API to a StreamingChunk. """ - content = chunk_response["response"] - meta = {key: value for key, value in chunk_response.items() if key != "response"} + chunk_response_dict = chunk_response.model_dump() + content = chunk_response_dict["response"] + meta = {key: value for key, value in chunk_response_dict.items() if key != "response"} chunk_message = StreamingChunk(content, meta) return chunk_message diff --git a/integrations/ollama/tests/test_chat_generator.py b/integrations/ollama/tests/test_chat_generator.py index 5ac9289aa..b2b3fd927 100644 --- a/integrations/ollama/tests/test_chat_generator.py +++ b/integrations/ollama/tests/test_chat_generator.py @@ -4,7 +4,7 @@ import pytest from haystack.components.generators.utils import print_streaming_chunk from haystack.dataclasses import ChatMessage, ChatRole -from ollama._types import ResponseError +from ollama._types import ChatResponse, ResponseError from haystack_integrations.components.generators.ollama import OllamaChatGenerator @@ -86,18 +86,18 @@ def test_from_dict(self): def test_build_message_from_ollama_response(self): model = "some_model" - ollama_response = { - "model": model, - "created_at": "2023-12-12T14:13:43.416799Z", - "message": {"role": "assistant", "content": "Hello! How are you today?"}, - "done": True, - "total_duration": 5191566416, - "load_duration": 2154458, - "prompt_eval_count": 26, - "prompt_eval_duration": 383809000, - "eval_count": 298, - "eval_duration": 4799921000, - } + ollama_response = ChatResponse( + model=model, + created_at="2023-12-12T14:13:43.416799Z", + message={"role": "assistant", "content": "Hello! How are you today?"}, + done=True, + total_duration=5191566416, + load_duration=2154458, + prompt_eval_count=26, + prompt_eval_duration=383809000, + eval_count=298, + eval_duration=4799921000, + ) observed = OllamaChatGenerator(model=model)._build_message_from_ollama_response(ollama_response) diff --git a/integrations/optimum/src/haystack_integrations/components/embedders/optimum/__init__.py b/integrations/optimum/src/haystack_integrations/components/embedders/optimum/__init__.py index 02e56b34c..ec0ecdef1 100644 --- a/integrations/optimum/src/haystack_integrations/components/embedders/optimum/__init__.py +++ b/integrations/optimum/src/haystack_integrations/components/embedders/optimum/__init__.py @@ -10,10 +10,10 @@ __all__ = [ "OptimumDocumentEmbedder", - "OptimumEmbedderOptimizationMode", "OptimumEmbedderOptimizationConfig", + "OptimumEmbedderOptimizationMode", "OptimumEmbedderPooling", - "OptimumEmbedderQuantizationMode", "OptimumEmbedderQuantizationConfig", + "OptimumEmbedderQuantizationMode", "OptimumTextEmbedder", ] diff --git a/integrations/optimum/src/haystack_integrations/components/embedders/optimum/optimum_document_embedder.py b/integrations/optimum/src/haystack_integrations/components/embedders/optimum/optimum_document_embedder.py index 27f533430..2016f3ffe 100644 --- a/integrations/optimum/src/haystack_integrations/components/embedders/optimum/optimum_document_embedder.py +++ b/integrations/optimum/src/haystack_integrations/components/embedders/optimum/optimum_document_embedder.py @@ -208,7 +208,7 @@ def run(self, documents: List[Document]): if not self._initialized: msg = "The embedding model has not been loaded. Please call warm_up() before running." raise RuntimeError(msg) - if not isinstance(documents, list) or documents and not isinstance(documents[0], Document): + if not isinstance(documents, list) or (documents and not isinstance(documents[0], Document)): msg = ( "OptimumDocumentEmbedder expects a list of Documents as input." " In case you want to embed a string, please use the OptimumTextEmbedder." diff --git a/integrations/pgvector/CHANGELOG.md b/integrations/pgvector/CHANGELOG.md index 0fe5f4fa4..f3821f1d3 100644 --- a/integrations/pgvector/CHANGELOG.md +++ b/integrations/pgvector/CHANGELOG.md @@ -1,24 +1,50 @@ # Changelog -## [integrations/pgvector-v1.0.0] - 2024-09-12 +## [integrations/pgvector-v1.2.0] - 2024-11-22 + +### ๐Ÿš€ Features + +- Add `create_extension` parameter to control vector extension creation (#1213) + + +## [integrations/pgvector-v1.1.0] - 2024-11-21 ### ๐Ÿš€ Features - Add filter_policy to pgvector integration (#820) +- Add schema support to pgvector document store. (#1095) +- Pgvector - recreate the connection if it is no longer valid (#1202) ### ๐Ÿ› Bug Fixes - `PgVector` - Fallback to default filter policy when deserializing retrievers without the init parameter (#900) +### ๐Ÿ“š Documentation + +- Explain different connection string formats in the docstring (#1132) + ### ๐Ÿงช Testing - Do not retry tests in `hatch run test` command (#954) -### โš™๏ธ Miscellaneous Tasks +### โš™๏ธ CI - Retry tests to reduce flakyness (#836) +- Adopt uv as installer (#1142) + +### ๐Ÿงน Chores + - Update ruff invocation to include check parameter (#853) - PgVector - remove legacy filter support (#1068) +- Update changelog after removing legacy filters (#1083) +- Update ruff linting scripts and settings (#1105) + +### ๐ŸŒ€ Miscellaneous + +- Ci: install `pytest-rerunfailures` where needed; add retry config to `test-cov` script (#845) +- Chore: Minor retriever pydoc fix (#884) +- Chore: Update pgvector test for the new `apply_filter_policy` usage (#970) +- Chore: pgvector ruff update, don't ruff tests (#984) ## [integrations/pgvector-v0.4.0] - 2024-06-20 @@ -27,6 +53,11 @@ - Defer the database connection to when it's needed (#773) - Add customizable index names for pgvector (#818) +### ๐ŸŒ€ Miscellaneous + +- Docs: add missing api references (#728) +- [deepset-ai/haystack-core-integrations#727] (#738) + ## [integrations/pgvector-v0.2.0] - 2024-05-08 ### ๐Ÿš€ Features @@ -38,19 +69,35 @@ - Fix order of API docs (#447) -This PR will also push the docs to Readme - ### ๐Ÿ“š Documentation - Update category slug (#442) - Disable-class-def (#556) +### ๐ŸŒ€ Miscellaneous + +- Pgvector - review docstrings and API reference (#502) +- Refactor tests (#574) +- Remove references to Python 3.7 (#601) +- Make Document Stores initially skip `SparseEmbedding` (#606) +- Chore: add license classifiers (#680) +- Type hints in pgvector document store updated for 3.8 compability (#704) +- Chore: change the pydoc renderer class (#718) + ## [integrations/pgvector-v0.1.0] - 2024-02-14 ### ๐Ÿ› Bug Fixes -- Fix linting (#328) +- Pgvector: fix linting (#328) +### ๐ŸŒ€ Miscellaneous +- Pgvector Document Store - minimal implementation (#239) +- Pgvector - filters (#257) +- Pgvector - embedding retrieval (#298) +- Pgvector - Embedding Retriever (#320) +- Pgvector: generate API docs (#325) +- Pgvector: add an example (#334) +- Adopt `Secret` to pgvector (#402) diff --git a/integrations/pgvector/src/haystack_integrations/document_stores/pgvector/document_store.py b/integrations/pgvector/src/haystack_integrations/document_stores/pgvector/document_store.py index 8e9c0f2fc..87655a5ec 100644 --- a/integrations/pgvector/src/haystack_integrations/document_stores/pgvector/document_store.py +++ b/integrations/pgvector/src/haystack_integrations/document_stores/pgvector/document_store.py @@ -78,6 +78,7 @@ def __init__( self, *, connection_string: Secret = Secret.from_env_var("PG_CONN_STR"), + create_extension: bool = True, schema_name: str = "public", table_name: str = "haystack_documents", language: str = "english", @@ -102,6 +103,10 @@ def __init__( e.g.: `PG_CONN_STR="host=HOST port=PORT dbname=DBNAME user=USER password=PASSWORD"` See [PostgreSQL Documentation](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING) for more details. + :param create_extension: Whether to create the pgvector extension if it doesn't exist. + Set this to `True` (default) to automatically create the extension if it is missing. + Creating the extension may require superuser privileges. + If set to `False`, ensure the extension is already installed; otherwise, an error will be raised. :param schema_name: The name of the schema the table is created in. The schema must already exist. :param table_name: The name of the table to use to store Haystack documents. :param language: The language to be used to parse query and document content in keyword retrieval. @@ -138,6 +143,7 @@ def __init__( """ self.connection_string = connection_string + self.create_extension = create_extension self.table_name = table_name self.schema_name = schema_name self.embedding_dimension = embedding_dimension @@ -156,49 +162,86 @@ def __init__( self._connection = None self._cursor = None self._dict_cursor = None + self._table_initialized = False @property def cursor(self): - if self._cursor is None: + if self._cursor is None or not self._connection_is_valid(self._connection): self._create_connection() return self._cursor @property def dict_cursor(self): - if self._dict_cursor is None: + if self._dict_cursor is None or not self._connection_is_valid(self._connection): self._create_connection() return self._dict_cursor @property def connection(self): - if self._connection is None: + if self._connection is None or not self._connection_is_valid(self._connection): self._create_connection() return self._connection def _create_connection(self): + """ + Internal method to create a connection to the PostgreSQL database. + """ + + # close the connection if it already exists + if self._connection: + try: + self._connection.close() + except Error as e: + logger.debug("Failed to close connection: %s", str(e)) + conn_str = self.connection_string.resolve_value() or "" connection = connect(conn_str) connection.autocommit = True - connection.execute("CREATE EXTENSION IF NOT EXISTS vector") + if self.create_extension: + connection.execute("CREATE EXTENSION IF NOT EXISTS vector") register_vector(connection) # Note: this must be called before creating the cursors. self._connection = connection self._cursor = self._connection.cursor() self._dict_cursor = self._connection.cursor(row_factory=dict_row) - # Init schema + if not self._table_initialized: + self._initialize_table() + + return self._connection + + def _initialize_table(self): + """ + Internal method to initialize the table. + """ if self.recreate_table: self.delete_table() + self._create_table_if_not_exists() self._create_keyword_index_if_not_exists() if self.search_strategy == "hnsw": self._handle_hnsw() - return self._connection + self._table_initialized = True + + @staticmethod + def _connection_is_valid(connection): + """ + Internal method to check if the connection is still valid. + """ + + # implementation inspired to psycopg pool + # https://github.com/psycopg/psycopg/blob/d38cf7798b0c602ff43dac9f20bbab96237a9c38/psycopg_pool/psycopg_pool/pool.py#L528 + + try: + connection.execute("") + except Error: + return False + return True def to_dict(self) -> Dict[str, Any]: """ @@ -210,6 +253,7 @@ def to_dict(self) -> Dict[str, Any]: return default_to_dict( self, connection_string=self.connection_string.to_dict(), + create_extension=self.create_extension, schema_name=self.schema_name, table_name=self.table_name, embedding_dimension=self.embedding_dimension, diff --git a/integrations/pgvector/tests/test_document_store.py b/integrations/pgvector/tests/test_document_store.py index 4af4fc8de..baa921137 100644 --- a/integrations/pgvector/tests/test_document_store.py +++ b/integrations/pgvector/tests/test_document_store.py @@ -41,12 +41,32 @@ def test_write_dataframe(self, document_store: PgvectorDocumentStore): retrieved_docs = document_store.filter_documents() assert retrieved_docs == docs + def test_connection_check_and_recreation(self, document_store: PgvectorDocumentStore): + original_connection = document_store.connection + + with patch.object(PgvectorDocumentStore, "_connection_is_valid", return_value=False): + new_connection = document_store.connection + + # verify that a new connection is created + assert new_connection is not original_connection + assert document_store._connection == new_connection + assert original_connection.closed + + assert document_store._cursor is not None + assert document_store._dict_cursor is not None + + # test with new connection + with patch.object(PgvectorDocumentStore, "_connection_is_valid", return_value=True): + same_connection = document_store.connection + assert same_connection is document_store._connection + @pytest.mark.usefixtures("patches_for_unit_tests") def test_init(monkeypatch): monkeypatch.setenv("PG_CONN_STR", "some_connection_string") document_store = PgvectorDocumentStore( + create_extension=True, schema_name="my_schema", table_name="my_table", embedding_dimension=512, @@ -60,6 +80,7 @@ def test_init(monkeypatch): keyword_index_name="my_keyword_index", ) + assert document_store.create_extension assert document_store.schema_name == "my_schema" assert document_store.table_name == "my_table" assert document_store.embedding_dimension == 512 @@ -78,6 +99,7 @@ def test_to_dict(monkeypatch): monkeypatch.setenv("PG_CONN_STR", "some_connection_string") document_store = PgvectorDocumentStore( + create_extension=False, table_name="my_table", embedding_dimension=512, vector_function="l2_distance", @@ -94,6 +116,7 @@ def test_to_dict(monkeypatch): "type": "haystack_integrations.document_stores.pgvector.document_store.PgvectorDocumentStore", "init_parameters": { "connection_string": {"env_vars": ["PG_CONN_STR"], "strict": True, "type": "env_var"}, + "create_extension": False, "table_name": "my_table", "schema_name": "public", "embedding_dimension": 512, diff --git a/integrations/pgvector/tests/test_retrievers.py b/integrations/pgvector/tests/test_retrievers.py index 4125c3e3a..11be71ab1 100644 --- a/integrations/pgvector/tests/test_retrievers.py +++ b/integrations/pgvector/tests/test_retrievers.py @@ -50,6 +50,7 @@ def test_to_dict(self, mock_store): "type": "haystack_integrations.document_stores.pgvector.document_store.PgvectorDocumentStore", "init_parameters": { "connection_string": {"env_vars": ["PG_CONN_STR"], "strict": True, "type": "env_var"}, + "create_extension": True, "schema_name": "public", "table_name": "haystack", "embedding_dimension": 768, @@ -82,6 +83,7 @@ def test_from_dict(self, monkeypatch): "type": "haystack_integrations.document_stores.pgvector.document_store.PgvectorDocumentStore", "init_parameters": { "connection_string": {"env_vars": ["PG_CONN_STR"], "strict": True, "type": "env_var"}, + "create_extension": False, "table_name": "haystack_test_to_dict", "embedding_dimension": 768, "vector_function": "cosine_similarity", @@ -106,6 +108,7 @@ def test_from_dict(self, monkeypatch): assert isinstance(document_store, PgvectorDocumentStore) assert isinstance(document_store.connection_string, EnvVarSecret) + assert not document_store.create_extension assert document_store.table_name == "haystack_test_to_dict" assert document_store.embedding_dimension == 768 assert document_store.vector_function == "cosine_similarity" @@ -176,6 +179,7 @@ def test_to_dict(self, mock_store): "type": "haystack_integrations.document_stores.pgvector.document_store.PgvectorDocumentStore", "init_parameters": { "connection_string": {"env_vars": ["PG_CONN_STR"], "strict": True, "type": "env_var"}, + "create_extension": True, "schema_name": "public", "table_name": "haystack", "embedding_dimension": 768, @@ -207,6 +211,7 @@ def test_from_dict(self, monkeypatch): "type": "haystack_integrations.document_stores.pgvector.document_store.PgvectorDocumentStore", "init_parameters": { "connection_string": {"env_vars": ["PG_CONN_STR"], "strict": True, "type": "env_var"}, + "create_extension": False, "table_name": "haystack_test_to_dict", "embedding_dimension": 768, "vector_function": "cosine_similarity", @@ -230,6 +235,7 @@ def test_from_dict(self, monkeypatch): assert isinstance(document_store, PgvectorDocumentStore) assert isinstance(document_store.connection_string, EnvVarSecret) + assert not document_store.create_extension assert document_store.table_name == "haystack_test_to_dict" assert document_store.embedding_dimension == 768 assert document_store.vector_function == "cosine_similarity" diff --git a/integrations/qdrant/src/haystack_integrations/components/retrievers/qdrant/__init__.py b/integrations/qdrant/src/haystack_integrations/components/retrievers/qdrant/__init__.py index ed6422bfe..bbb7251d0 100644 --- a/integrations/qdrant/src/haystack_integrations/components/retrievers/qdrant/__init__.py +++ b/integrations/qdrant/src/haystack_integrations/components/retrievers/qdrant/__init__.py @@ -4,4 +4,4 @@ from .retriever import QdrantEmbeddingRetriever, QdrantHybridRetriever, QdrantSparseEmbeddingRetriever -__all__ = ("QdrantEmbeddingRetriever", "QdrantSparseEmbeddingRetriever", "QdrantHybridRetriever") +__all__ = ("QdrantEmbeddingRetriever", "QdrantHybridRetriever", "QdrantSparseEmbeddingRetriever") diff --git a/integrations/weaviate/src/haystack_integrations/document_stores/weaviate/__init__.py b/integrations/weaviate/src/haystack_integrations/document_stores/weaviate/__init__.py index 87c7b6b01..db084502b 100644 --- a/integrations/weaviate/src/haystack_integrations/document_stores/weaviate/__init__.py +++ b/integrations/weaviate/src/haystack_integrations/document_stores/weaviate/__init__.py @@ -5,10 +5,10 @@ from .document_store import WeaviateDocumentStore __all__ = [ - "WeaviateDocumentStore", "AuthApiKey", "AuthBearerToken", "AuthClientCredentials", "AuthClientPassword", "AuthCredentials", + "WeaviateDocumentStore", ]