chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
# Vertex AI Search accessed via Google Cloud Functions
|
||||
|
||||
This directory contains several versions of approximately the same
|
||||
implementation.
|
||||
|
||||
The functions can be deployed to
|
||||
[Cloud functions](https://cloud.google.com/functions/) and can be modified to
|
||||
supports many different triggers and use cases. Each can also
|
||||
[be deployed locally](https://cloud.google.com/functions/docs/running/overview)
|
||||
which allows easy experimentation and iteration.
|
||||
|
||||
This example is powered by
|
||||
[Vertex AI Search](https://cloud.google.com/generative-ai-app-builder/docs/enterprise-search-introduction)
|
||||
which does many different things, including **Document & Intranet Search**,
|
||||
**Recommendations** and **Grounding and RAG** out-of-the-box (For more
|
||||
information, see the blog post
|
||||
[Your RAG powered by Google Search](https://cloud.google.com/blog/products/ai-machine-learning/rags-powered-by-google-search-technology-part-1)).
|
||||
|
||||
If you want even more control see
|
||||
[Vertex AI Search Component APIs](https://cloud.google.com/generative-ai-app-builder/docs/builder-apis),
|
||||
but first explore the out-of-the-box offering because it's easy to setup.
|
||||
|
||||
This example is for the out-of-the-box Vertex AI Search supporting many
|
||||
configurations and data types.
|
||||
|
||||
## Pre-requisites
|
||||
|
||||
Before you can use these functions to query Vertex AI Search, you need to create
|
||||
and populate a search "data store"; read through instructions in
|
||||
[get started with generic search](https://cloud.google.com/generative-ai-app-builder/docs/try-enterprise-search).
|
||||
These functions could easily be adapted to other types of Vertex AI Search like
|
||||
[generic recommendations](https://cloud.google.com/generative-ai-app-builder/docs/try-generic-recommendations),
|
||||
[media search](https://cloud.google.com/generative-ai-app-builder/docs/try-media-search),
|
||||
[media recommendations](https://cloud.google.com/generative-ai-app-builder/docs/try-media-recommendations),
|
||||
[healthcare search](https://cloud.google.com/generative-ai-app-builder/docs/create-data-store-hc),
|
||||
or even
|
||||
[retail product discovery](https://cloud.google.com/solutions/retail-product-discovery#documentation).
|
||||
|
||||
You'll need to collect the following details from your search app data store:
|
||||
|
||||
```python
|
||||
PROJECT_ID = "YOUR_PROJECT_ID" # alphanumeric
|
||||
LOCATION = "global" # or an alternate location
|
||||
DATA_STORE_ID = "YOUR_DATA_STORE_ID" # not the app id, alphanumeric
|
||||
```
|
||||
|
||||
Additionally you'll need to keep track of some of the choices you make when you
|
||||
configure Vertex AI Search.
|
||||
|
||||
### Type of data source
|
||||
|
||||
<!-- textlint-disable -->
|
||||
|
||||
- UNSTRUCTURED
|
||||
- STRUCTURED
|
||||
- WEBSITE
|
||||
- BLENDED
|
||||
|
||||
<!-- textlint-enable -->
|
||||
|
||||
```python
|
||||
ENGINE_DATA_TYPE = UNSTRUCTURED
|
||||
```
|
||||
|
||||
### Type of chunks to return
|
||||
|
||||
- DOCUMENT_WITH_SNIPPETS
|
||||
- DOCUMENT_WITH_EXTRACTIVE_SEGMENTS
|
||||
- CHUNK
|
||||
- NONE
|
||||
|
||||
```python
|
||||
ENGINE_CHUNK_TYPE = DOCUMENT_WITH_EXTRACTIVE_SEGMENTS
|
||||
```
|
||||
|
||||
### Type of summarization
|
||||
|
||||
- NONE results only
|
||||
- VERTEX_AI_SEARCH LLM add on provided by
|
||||
[Vertex AI Search](https://cloud.google.com/generative-ai-app-builder/docs/enterprise-search-introduction)
|
||||
<!-- NOT ready yet
|
||||
- GENERATE_GROUNDED_ANSWERS use the
|
||||
[Generate grounded answers with RAG](https://cloud.google.com/generative-ai-app-builder/docs/grounded-gen)
|
||||
provided by
|
||||
[Vertex AI Search Builder APIs](https://cloud.google.com/generative-ai-app-builder/docs/builder-apis)
|
||||
- GEMINI use one of the Gemini models to generate an answer from the results -->
|
||||
|
||||
```python
|
||||
SUMMARY_TYPE = VERTEX_AI_SEARCH
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
1. Vertex AI Search is an API hosted on Google Cloud
|
||||
2. You will call that API via a Google Cloud Function, which exposes its own API
|
||||
3. Your users will the Google Cloud Function API, via your custom app or UI
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[fa:fa-search Vertex AI Search] --> B(Google Cloud Function)
|
||||
B --> C[My App Server]
|
||||
C -->|One| D[fa:fa-laptop web]
|
||||
C -->|Two| E[fa:fa-mobile mobile]
|
||||
```
|
||||
|
||||
## Use case: RAG / Grounding
|
||||
|
||||
Any time you have more source data than can fit into a LLM context window, you
|
||||
could benefit from RAG (Retrieval Augmented Generation). The more data you have,
|
||||
the more important search is - to get the relevant chunks into the prompt of the
|
||||
LLM.
|
||||
|
||||
- **Retrieve** relevant search results, with text chunks (snippets or segments)
|
||||
- **Augmented Generation** uses Gemini to generate an answer or summary grounded
|
||||
on the relevant search results
|
||||
|
||||
## Use case: Agent Tool (Knowledge Base)
|
||||
|
||||
A natural extension of RAG / Grounding is agentic behavior.
|
||||
|
||||
Whether creating a basic chatbot or a sophisticated tool using multi-agent
|
||||
system, you're always going to need search based RAG. The better the search
|
||||
quality the better the agent response based on your source data.
|
||||
|
||||
For more on agents, check out
|
||||
[Vertex AI Search Use Cases](https://cloud.google.com/products/agent-builder?hl=en#common-uses)
|
||||
and
|
||||
[https://github.com/GoogleCloudPlatform/generative-ai](https://github.com/GoogleCloudPlatform/generative-ai).
|
||||
@@ -0,0 +1,6 @@
|
||||
DATA_STORE_ID=your-data-store-id-here
|
||||
ENGINE_CHUNK_TYPE=DOCUMENT_WITH_EXTRACTIVE_SEGMENTS
|
||||
ENGINE_DATA_TYPE=UNSTRUCTURED
|
||||
LOCATION=global
|
||||
PROJECT_ID=your-project-id-here
|
||||
SUMMARY_TYPE=VERTEX_AI_SEARCH
|
||||
@@ -0,0 +1,127 @@
|
||||
# Vertex AI Search accessed via Google Cloud Functions
|
||||
|
||||
This example is based on the
|
||||
[Python client for the Vertex AI Search API](https://cloud.google.com/generative-ai-app-builder/docs/libraries#client-libraries-usage-python),
|
||||
which will get search results, snippets, metadata, and the LLM summary grounded
|
||||
on search results. This is implemented in the `vertex_ai_search_client.py` file.
|
||||
|
||||
That functionality is exposed on a REST API which is implemented in `main.py`
|
||||
intended to be deployed to a Google Cloud Function using an HTTPS trigger on a
|
||||
Python 3 runtime;
|
||||
[read more here](https://cloud.google.com/functions/docs/samples/functions-http-content#functions_http_content-python).
|
||||
|
||||
**[Read more about Vertex AI Search accessed via Google Cloud Functions](../)**
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The following environment variables are required for both local development and
|
||||
deployment:
|
||||
|
||||
- `PROJECT_ID`: Your Google Cloud project ID
|
||||
- `LOCATION`: The location of your Vertex AI Search data store
|
||||
- `DATA_STORE_ID`: The ID of your Vertex AI Search data store
|
||||
- `ENGINE_DATA_TYPE`: Type of data in the engine (0-3)
|
||||
- `ENGINE_CHUNK_TYPE`: Type of chunking used (0-3)
|
||||
- `SUMMARY_TYPE`: Type of summary used (0-3)
|
||||
|
||||
## Local Development
|
||||
|
||||
### Setup
|
||||
|
||||
1. Ensure you have the Google Cloud SDK installed and configured.
|
||||
2. Clone this repository and navigate to the project directory.
|
||||
3. Set up your environment variables:
|
||||
|
||||
```bash
|
||||
gcloud auth login
|
||||
bash setup_env.sh
|
||||
```
|
||||
|
||||
Alternatively, you can manually create and edit a `.env` file with the required
|
||||
variables.
|
||||
|
||||
### Run locally
|
||||
|
||||
Run this code locally via **Functions Framework** or **Functions Emulator**;
|
||||
[read more about running cloud functions locally](https://cloud.google.com/functions/docs/running/overview).
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install functions-framework
|
||||
functions-framework --target=vertex_ai_search
|
||||
```
|
||||
|
||||
In a different terminal, execute a `POST` search query based on your data:
|
||||
|
||||
```bash
|
||||
export SEARCH_TERM="What is the ... for ...?"
|
||||
curl -m 310 -X POST localhost:8080 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"search_term\": \"${SEARCH_TERM}\"}"
|
||||
```
|
||||
|
||||
### Run tests
|
||||
|
||||
#### Unit tests
|
||||
|
||||
These tests mock the API interactions and should run quickly:
|
||||
|
||||
```bash
|
||||
pip install pytest
|
||||
pytest test_vertex_ai_search_client.py
|
||||
```
|
||||
|
||||
#### Integration tests
|
||||
|
||||
These tests actually call the Vertex AI Search API and depend on your data
|
||||
stores being configured in Vertex AI Search:
|
||||
|
||||
```bash
|
||||
pip install pytest
|
||||
pytest test_integration_vertex_ai_search_client.py
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
To deploy this function to Google Cloud:
|
||||
|
||||
1. Ensure you have set up the required environment variables (see Environment
|
||||
Variables section).
|
||||
2. Run the following command:
|
||||
|
||||
```bash
|
||||
gcloud functions deploy vertex_ai_search --runtime python39 --trigger-http --allow-unauthenticated
|
||||
```
|
||||
|
||||
You will get back a URL for triggering the function.
|
||||
|
||||
## Usage
|
||||
|
||||
After deployment, you can use the function as follows:
|
||||
|
||||
```bash
|
||||
curl -X POST https://YOUR_FUNCTION_URL \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"search_term": "your search query"}'
|
||||
```
|
||||
|
||||
Replace `YOUR_FUNCTION_URL` with the URL of your deployed function, and fill in
|
||||
the search query.
|
||||
|
||||
If you run into problems, go to
|
||||
[Google Cloud Functions](https://console.cloud.google.com/functions), find the
|
||||
function you just deployed, and review the logs for informative errors. Perhaps
|
||||
you need to setup
|
||||
[Google Cloud IAM](https://cloud.google.com/functions/docs/reference/iam) roles
|
||||
or permissions.
|
||||
|
||||
## Customization
|
||||
|
||||
This implementation provides a basic way to access and control your queries to
|
||||
the Vertex AI Search API. It simplifies CORS and bearer token authentication,
|
||||
and allows for some minor customization of inputs and outputs.
|
||||
|
||||
If you require more extensive customization, consider using an orchestration
|
||||
framework like [LangChain](https://www.langchain.com/) or
|
||||
[LlamaIndex](https://www.llamaindex.ai/) which have Vertex AI Search
|
||||
integrations.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Copyright 2024 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""
|
||||
Google Cloud Function for Vertex AI Search
|
||||
|
||||
This module provides an HTTP endpoint for performing searches using
|
||||
the Vertex AI Search API. It uses the VertexAISearchClient to handle
|
||||
the core search functionality.
|
||||
|
||||
For deployment instructions, environment variable setup, and usage examples,
|
||||
please refer to the README.md file.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, Request, jsonify, request
|
||||
import functions_framework
|
||||
from google.api_core.exceptions import GoogleAPICallError
|
||||
from vertex_ai_search_client import VertexAISearchClient, VertexAISearchConfig
|
||||
|
||||
# Load environment variables
|
||||
project_id = os.getenv("PROJECT_ID", "your-project")
|
||||
location = os.getenv("LOCATION", "global")
|
||||
data_store_id = os.getenv("DATA_STORE_ID", "your-data-store")
|
||||
engine_data_type = os.getenv("ENGINE_DATA_TYPE", "UNSTRUCTURED")
|
||||
engine_chunk_type = os.getenv("ENGINE_CHUNK_TYPE", "CHUNK")
|
||||
summary_type = os.getenv("SUMMARY_TYPE", "VERTEX_AI_SEARCH")
|
||||
|
||||
# Create VertexAISearchConfig
|
||||
config = VertexAISearchConfig(
|
||||
project_id=project_id,
|
||||
location=location,
|
||||
data_store_id=data_store_id,
|
||||
engine_data_type=engine_data_type,
|
||||
engine_chunk_type=engine_chunk_type,
|
||||
summary_type=summary_type,
|
||||
)
|
||||
|
||||
# Initialize VertexAISearchClient
|
||||
vertex_ai_search_client = VertexAISearchClient(config)
|
||||
|
||||
|
||||
@functions_framework.http
|
||||
def vertex_ai_search(http_request: Request) -> tuple[Any, int, dict[str, str]]:
|
||||
"""
|
||||
Handle HTTP requests for Vertex AI Search.
|
||||
|
||||
This function processes incoming HTTP requests, performs the search using
|
||||
the VertexAISearchClient, and returns the results. It handles CORS, validates
|
||||
the request, and manages potential errors.
|
||||
|
||||
Args:
|
||||
http_request (flask.Request): The request object.
|
||||
<https://flask.palletsprojects.com/en/1.1.x/api/#incoming-request-data>
|
||||
|
||||
Returns:
|
||||
Tuple[Any, int, Dict[str, str]]: A tuple containing the response body,
|
||||
status code, and headers. This output will be turned into a Response
|
||||
object using `make_response`
|
||||
<https://flask.palletsprojects.com/en/1.1.x/api/#flask.make_response>.
|
||||
"""
|
||||
# Set CORS headers for the preflight request
|
||||
if http_request.method == "OPTIONS":
|
||||
# Allows GET requests from any origin with the Content-Type
|
||||
# header and caches preflight response for an 3600s
|
||||
headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Max-Age": "3600",
|
||||
}
|
||||
return ("", 204, headers)
|
||||
|
||||
# Set CORS headers for all responses
|
||||
headers = {"Access-Control-Allow-Origin": "*"}
|
||||
|
||||
def create_error_response(
|
||||
message: str, status_code: int
|
||||
) -> tuple[Any, int, dict[str, str]]:
|
||||
"""Standardize the error responses with common headers."""
|
||||
return (jsonify({"error": message}), status_code, headers)
|
||||
|
||||
# Handle the request and get the search_term
|
||||
request_json = http_request.get_json(silent=True)
|
||||
request_args = http_request.args
|
||||
|
||||
if request_json and "search_term" in request_json:
|
||||
search_term = request_json["search_term"]
|
||||
elif request_args and "search_term" in request_args:
|
||||
search_term = request_args["search_term"]
|
||||
else:
|
||||
return create_error_response("No search term provided", 400)
|
||||
|
||||
# Handle the Vertex AI Search and return JSON
|
||||
try:
|
||||
results = vertex_ai_search_client.search(search_term)
|
||||
return (jsonify(results), 200, headers)
|
||||
except GoogleAPICallError as e:
|
||||
return create_error_response(
|
||||
f"Error calling Vertex AI Search API: {str(e)}", 500
|
||||
)
|
||||
except ValueError as e:
|
||||
return create_error_response(f"Invalid input: {str(e)}", 400)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/", methods=["POST"])
|
||||
def index() -> tuple[Any, int, dict[str, str]]:
|
||||
"""
|
||||
Flask route for handling POST requests when running locally.
|
||||
|
||||
This function is used when the script is run directly (not as a Google Cloud Function).
|
||||
It mimics the behavior of the vertex_ai_search function for local testing.
|
||||
|
||||
Returns:
|
||||
Tuple[Any, int, Dict[str, str]]: The vertex search result.
|
||||
"""
|
||||
|
||||
return vertex_ai_search(request)
|
||||
|
||||
app.run("localhost", 8080, debug=True)
|
||||
@@ -0,0 +1,7 @@
|
||||
Flask==3.1.1
|
||||
functions_framework==3.8.2
|
||||
google-cloud-discoveryengine>=0.11
|
||||
mypy==1.15.0
|
||||
protobuf==5.29.5
|
||||
pytest==8.3.5
|
||||
python-dotenv==1.1.0
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
cp .env.example .env
|
||||
PROJECT_ID=$(gcloud config get-value project)
|
||||
sed -i.old "s/PROJECT_ID=.*/PROJECT_ID=$PROJECT_ID/" .env && rm .env.old
|
||||
echo "Project ID set to $PROJECT_ID in .env file"
|
||||
echo "Please open .env and set the LOCATION, DATA_STORE_ID, and enum values"
|
||||
@@ -0,0 +1,121 @@
|
||||
# Copyright 2024 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""
|
||||
Integration tests for the VertexAISearchClient.
|
||||
|
||||
This module contains integration tests that interact with the actual
|
||||
Vertex AI Search API. These tests require proper configuration of
|
||||
environment variables and access to the Vertex AI Search service.
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from vertex_ai_search_client import VertexAISearchClient, VertexAISearchConfig
|
||||
|
||||
# Load environment variables
|
||||
PROJECT_ID = os.getenv("PROJECT_ID", "your-project")
|
||||
LOCATION = os.getenv("LOCATION", "global")
|
||||
DATA_STORE_ID = os.getenv("DATA_STORE_ID", "your-data-store")
|
||||
ENGINE_DATA_TYPE = os.getenv("ENGINE_DATA_TYPE", "UNSTRUCTURED")
|
||||
ENGINE_CHUNK_TYPE = os.getenv("ENGINE_CHUNK_TYPE", "CHUNK")
|
||||
SUMMARY_TYPE = os.getenv("SUMMARY_TYPE", "VERTEX_AI_SEARCH")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def vertex_ai_search_client() -> Generator[VertexAISearchClient, None, None]:
|
||||
"""
|
||||
Fixture to create and yield a VertexAISearchClient instance for testing.
|
||||
|
||||
This fixture creates a VertexAISearchClient instance using the
|
||||
environment variables and yields it for use in tests. The client
|
||||
is shared across all tests in the module for efficiency.
|
||||
|
||||
Yields:
|
||||
VertexAISearchClient: An instance of the VertexAISearchClient for testing.
|
||||
"""
|
||||
config = VertexAISearchConfig(
|
||||
project_id=PROJECT_ID,
|
||||
location=LOCATION,
|
||||
data_store_id=DATA_STORE_ID,
|
||||
engine_data_type="UNSTRUCTURED",
|
||||
engine_chunk_type="DOCUMENT_WITH_EXTRACTIVE_SEGMENTS",
|
||||
summary_type="VERTEX_AI_SEARCH",
|
||||
)
|
||||
client = VertexAISearchClient(config)
|
||||
yield client
|
||||
|
||||
|
||||
def test_search_integration(client: VertexAISearchClient) -> None:
|
||||
"""
|
||||
Test the search functionality of VertexAISearchClient with the actual API.
|
||||
|
||||
This test performs a search using the VertexAISearchClient and verifies
|
||||
that the results have the expected structure and content types.
|
||||
|
||||
Args:
|
||||
vertex_ai_search_client (VertexAISearchClient): The client instance to test.
|
||||
"""
|
||||
# Perform a search
|
||||
query = "test query"
|
||||
results = client.search(query)
|
||||
|
||||
# Check the structure of the results
|
||||
assert "simplified_results" in results
|
||||
assert isinstance(results["simplified_results"], list)
|
||||
|
||||
if results["simplified_results"]:
|
||||
first_result = results["simplified_results"][0]
|
||||
assert "metadata" in first_result
|
||||
assert "page_content" in first_result
|
||||
|
||||
# Check for other expected fields
|
||||
assert "total_size" in results
|
||||
assert isinstance(results["total_size"], int)
|
||||
|
||||
if "summary" in results:
|
||||
assert "summary_text" in results["summary"]
|
||||
|
||||
|
||||
def test_unstructured_summary() -> None:
|
||||
"""
|
||||
Test VertexAISearchClient with unstructured data and summary generation.
|
||||
|
||||
This test creates a new VertexAISearchClient instance with specific
|
||||
settings for unstructured data and summary generation, then performs
|
||||
a search to verify the results.
|
||||
"""
|
||||
config = VertexAISearchConfig(
|
||||
project_id=PROJECT_ID,
|
||||
location=LOCATION,
|
||||
data_store_id=DATA_STORE_ID,
|
||||
engine_data_type="UNSTRUCTURED",
|
||||
engine_chunk_type="DOCUMENT_WITH_EXTRACTIVE_SEGMENTS",
|
||||
summary_type="VERTEX_AI_SEARCH",
|
||||
)
|
||||
client = VertexAISearchClient(config)
|
||||
results = client.search("What is the name of the company?")
|
||||
# Check the structure of the results
|
||||
assert "simplified_results" in results
|
||||
assert isinstance(results["simplified_results"], list)
|
||||
|
||||
if results["simplified_results"]:
|
||||
first_result = results["simplified_results"][0]
|
||||
assert "metadata" in first_result
|
||||
assert "page_content" in first_result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main()
|
||||
@@ -0,0 +1,338 @@
|
||||
# Copyright 2024 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# pylint: disable=redefined-outer-name,protected-access
|
||||
|
||||
"""
|
||||
Unit tests for the VertexAISearchClient class.
|
||||
|
||||
This module contains unit tests for the VertexAISearchClient class, using
|
||||
mocks to simulate the behavior of the Google Cloud Search API. These tests
|
||||
ensure that the client correctly handles various scenarios and data structures.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from google.cloud import discoveryengine_v1alpha as discoveryengine
|
||||
from google.cloud.discoveryengine_v1alpha.services.search_service.pagers import (
|
||||
SearchPager,
|
||||
)
|
||||
from google.cloud.discoveryengine_v1alpha.types import Document, SearchResponse
|
||||
import pytest
|
||||
from vertex_ai_search_client import VertexAISearchClient, VertexAISearchConfig
|
||||
|
||||
|
||||
# Test helper functions
|
||||
def create_mock_search_pager_result() -> MagicMock:
|
||||
"""Create a mock SearchPager result for testing."""
|
||||
mock_pager = MagicMock(spec=SearchPager)
|
||||
mock_pager.__iter__.return_value = [create_mock_search_pager_return_value()]
|
||||
mock_pager.total_size = 1
|
||||
mock_pager.attribution_token = "test-token"
|
||||
mock_pager.next_page_token = "next-page"
|
||||
mock_pager.corrected_query = "corrected query"
|
||||
mock_pager.summary = SearchResponse.Summary(summary_text="Test summary")
|
||||
mock_pager.applied_controls = None
|
||||
mock_pager.facets = []
|
||||
mock_pager.guided_search_result = []
|
||||
mock_pager.query_expansion_info = []
|
||||
return mock_pager
|
||||
|
||||
|
||||
def create_mock_search_pager_return_value() -> SearchResponse.SearchResult:
|
||||
"""Create a mock SearchResponse.SearchResult for testing."""
|
||||
search_result = SearchResponse.SearchResult()
|
||||
document = Document()
|
||||
|
||||
derived_struct_data = {
|
||||
"title": "Employee Benefits Summary",
|
||||
"link": "gs://company-docs/Employee_Benefits_Summary.pdf",
|
||||
"extractive_answers": [{"content": "Test content", "pageNumber": "1"}],
|
||||
"snippets": [{"snippet_status": "SUCCESS", "snippet": "Test snippet"}],
|
||||
}
|
||||
|
||||
document.derived_struct_data = derived_struct_data
|
||||
search_result.document = document
|
||||
return search_result
|
||||
|
||||
|
||||
# Fixtures
|
||||
@pytest.fixture
|
||||
def mock_search_service_client() -> MagicMock:
|
||||
"""Fixture to create a mock SearchServiceClient."""
|
||||
with patch(
|
||||
"vertex_ai_search_client.discoveryengine.SearchServiceClient"
|
||||
) as mock_client:
|
||||
mock_client.return_value.serving_config_path.return_value = (
|
||||
"projects/test-project/locations/us-central1/dataStores/test-data-store/"
|
||||
"servingConfigs/default_config"
|
||||
)
|
||||
yield mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def search_config() -> VertexAISearchConfig:
|
||||
"""Fixture to create a VertexAISearchConfig instance for testing."""
|
||||
return VertexAISearchConfig(
|
||||
project_id="test-project",
|
||||
location="us-central1",
|
||||
data_store_id="test-data-store",
|
||||
engine_data_type="UNSTRUCTURED",
|
||||
engine_chunk_type="DOCUMENT_WITH_EXTRACTIVE_SEGMENTS",
|
||||
summary_type="VERTEX_AI_SEARCH",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def search_client(search_config: VertexAISearchConfig) -> VertexAISearchClient:
|
||||
"""Fixture to create a VertexAISearchClient instance for testing."""
|
||||
with patch(
|
||||
"vertex_ai_search_client.discoveryengine.SearchServiceClient"
|
||||
) as mock_client:
|
||||
mock_client.return_value.serving_config_path.return_value = (
|
||||
"projects/test-project/locations/us-central1/dataStores/test-data-store/"
|
||||
"servingConfigs/default_config"
|
||||
)
|
||||
return VertexAISearchClient(search_config)
|
||||
|
||||
|
||||
# Tests
|
||||
def test_init(search_config: VertexAISearchConfig) -> None:
|
||||
"""Test the initialization of VertexAISearchClient."""
|
||||
with patch("vertex_ai_search_client.discoveryengine.SearchServiceClient"):
|
||||
client = VertexAISearchClient(search_config)
|
||||
|
||||
assert client.config.project_id == "test-project"
|
||||
assert client.config.location == "us-central1"
|
||||
assert client.config.data_store_id == "test-data-store"
|
||||
assert client.config.engine_data_type == "UNSTRUCTURED"
|
||||
assert client.config.engine_chunk_type == "DOCUMENT_WITH_EXTRACTIVE_SEGMENTS"
|
||||
assert client.config.summary_type == "VERTEX_AI_SEARCH"
|
||||
|
||||
|
||||
def test_get_serving_config(search_client: VertexAISearchClient) -> None:
|
||||
"""Test the get_serving_config method of VertexAISearchClient."""
|
||||
expected_serving_config = (
|
||||
"projects/test-project/locations/us-central1/dataStores/test-data-store/"
|
||||
"servingConfigs/default_config"
|
||||
)
|
||||
assert search_client.serving_config == expected_serving_config
|
||||
|
||||
|
||||
def test_build_search_request(search_client: VertexAISearchClient) -> None:
|
||||
"""Test the build_search_request method of VertexAISearchClient."""
|
||||
query = "test query"
|
||||
page_size = 5
|
||||
# Access to protected member is necessary for testing
|
||||
request = search_client.build_search_request(
|
||||
query, page_size
|
||||
) # pylint: disable=protected-access
|
||||
|
||||
assert isinstance(request, discoveryengine.SearchRequest)
|
||||
assert request.serving_config == search_client.serving_config
|
||||
assert request.query == query
|
||||
assert request.page_size == page_size
|
||||
|
||||
assert (
|
||||
request.query_expansion_spec.condition
|
||||
== discoveryengine.SearchRequest.QueryExpansionSpec.Condition.AUTO
|
||||
)
|
||||
assert (
|
||||
request.spell_correction_spec.mode
|
||||
== discoveryengine.SearchRequest.SpellCorrectionSpec.Mode.AUTO
|
||||
)
|
||||
|
||||
assert request.content_search_spec.snippet_spec.return_snippet is True
|
||||
assert request.content_search_spec.summary_spec.summary_result_count == 5
|
||||
assert request.content_search_spec.summary_spec.include_citations is True
|
||||
assert request.content_search_spec.summary_spec.ignore_adversarial_query is True
|
||||
assert (
|
||||
request.content_search_spec.summary_spec.ignore_non_summary_seeking_query
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
request.content_search_spec.extractive_content_spec.max_extractive_answer_count
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
request.content_search_spec.extractive_content_spec.return_extractive_segment_score
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_map_search_pager_to_dict_basic(search_client: VertexAISearchClient) -> None:
|
||||
"""Test the map_search_pager_to_dict method with basic data."""
|
||||
mock_pager = create_mock_search_pager_result()
|
||||
|
||||
# Access to protected member is necessary for testing
|
||||
result = search_client.map_search_pager_to_dict(
|
||||
mock_pager
|
||||
) # pylint: disable=protected-access
|
||||
|
||||
assert "results" in result
|
||||
assert len(result["results"]) == 1
|
||||
assert result["total_size"] == 1
|
||||
assert result["attribution_token"] == "test-token"
|
||||
assert result["next_page_token"] == "next-page"
|
||||
assert result["corrected_query"] == "corrected query"
|
||||
assert result["summary"]["summary_text"] == "Test summary"
|
||||
|
||||
|
||||
def test_map_search_pager_to_dict_document_content(
|
||||
search_client: VertexAISearchClient,
|
||||
) -> None:
|
||||
"""Test the map_search_pager_to_dict method with document content."""
|
||||
mock_pager = create_mock_search_pager_result()
|
||||
|
||||
# Access to protected member is necessary for testing
|
||||
result = search_client.map_search_pager_to_dict(
|
||||
mock_pager
|
||||
) # pylint: disable=protected-access
|
||||
|
||||
document = result["results"][0]["document"]
|
||||
assert document["derived_struct_data"]["title"] == "Employee Benefits Summary"
|
||||
assert (
|
||||
document["derived_struct_data"]["link"]
|
||||
== "gs://company-docs/Employee_Benefits_Summary.pdf"
|
||||
)
|
||||
assert len(document["derived_struct_data"]["extractive_answers"]) == 1
|
||||
assert (
|
||||
document["derived_struct_data"]["extractive_answers"][0]["content"]
|
||||
== "Test content"
|
||||
)
|
||||
assert len(document["derived_struct_data"]["snippets"]) == 1
|
||||
assert document["derived_struct_data"]["snippets"][0]["snippet"] == "Test snippet"
|
||||
|
||||
|
||||
def test_parse_chunk_result(search_client: VertexAISearchClient) -> None:
|
||||
"""Test the _parse_chunk_result method of VertexAISearchClient."""
|
||||
chunk = {
|
||||
"id": "chunk1",
|
||||
"relevance_score": 0.95,
|
||||
"content": "Test content",
|
||||
"document_metadata": {"title": "Test Title", "uri": "https://example.com"},
|
||||
"page_span": {"page_start": 1, "page_end": 2},
|
||||
}
|
||||
|
||||
# Access to protected member is necessary for testing
|
||||
result = search_client._parse_chunk_result(
|
||||
chunk
|
||||
) # pylint: disable=protected-access
|
||||
|
||||
assert result["page_content"] == "Test content"
|
||||
assert result["metadata"]["chunk_id"] == "chunk1"
|
||||
assert result["metadata"]["score"] == 0.95
|
||||
assert result["metadata"]["title"] == "Test Title"
|
||||
assert result["metadata"]["uri"] == "https://example.com"
|
||||
assert result["metadata"]["page"] == 1
|
||||
assert result["metadata"]["page_span_end"] == 2
|
||||
|
||||
|
||||
def test_strip_content() -> None:
|
||||
"""Test the _strip_content static method of VertexAISearchClient."""
|
||||
input_text = "<p>Test <strong>content</strong> with "quotes"</p>"
|
||||
expected_output = 'Test content with "quotes"'
|
||||
# Access to protected member is necessary for testing
|
||||
assert (
|
||||
VertexAISearchClient._strip_content(input_text) == expected_output
|
||||
) # pylint: disable=protected-access
|
||||
|
||||
|
||||
def test_simplify_search_results_mixed_chunk_and_segments(
|
||||
search_client: VertexAISearchClient,
|
||||
) -> None:
|
||||
"""Test the simplify_search_results method with mixed chunk and segment data."""
|
||||
input_dict = {
|
||||
"results": [
|
||||
{"document": {"id": "doc1", "derived_struct_data": {"title": "Test"}}},
|
||||
{"chunk": {"id": "chunk1", "content": "Test content"}},
|
||||
]
|
||||
}
|
||||
|
||||
# Access to protected member is necessary for testing
|
||||
result = search_client.simplify_search_results(
|
||||
input_dict
|
||||
) # pylint: disable=protected-access
|
||||
|
||||
assert "simplified_results" in result
|
||||
assert len(result["simplified_results"]) == 2
|
||||
assert "metadata" in result["simplified_results"][0]
|
||||
assert "page_content" in result["simplified_results"][1]
|
||||
|
||||
|
||||
def test_parse_document_result(search_client: VertexAISearchClient) -> None:
|
||||
"""Test the _parse_document_result method of VertexAISearchClient."""
|
||||
document = {
|
||||
"id": "doc1",
|
||||
"derived_struct_data": {
|
||||
"title": "Employee Benefits Summary",
|
||||
"extractive_answers": [
|
||||
{
|
||||
"content": "Test content",
|
||||
"page_number": "3",
|
||||
}
|
||||
],
|
||||
"snippets": [
|
||||
{
|
||||
"snippet_status": "SUCCESS",
|
||||
"snippet": "Test snippet",
|
||||
}
|
||||
],
|
||||
"link": "gs://company-docs/Employee_Benefits_Summary.pdf",
|
||||
},
|
||||
}
|
||||
|
||||
# Access to protected member is necessary for testing
|
||||
result = search_client._parse_document_result(
|
||||
document
|
||||
) # pylint: disable=protected-access
|
||||
|
||||
assert result["metadata"]["title"] == "Employee Benefits Summary"
|
||||
assert (
|
||||
result["metadata"]["link"] == "gs://company-docs/Employee_Benefits_Summary.pdf"
|
||||
)
|
||||
assert "page_content" in result
|
||||
assert "Test content" in result["page_content"]
|
||||
assert "On page 3" in result["page_content"]
|
||||
|
||||
|
||||
@patch("vertex_ai_search_client.VertexAISearchClient.map_search_pager_to_dict")
|
||||
@patch("vertex_ai_search_client.VertexAISearchClient.simplify_search_results")
|
||||
def test_search(
|
||||
mock_simplify: MagicMock,
|
||||
mock_map_pager: MagicMock,
|
||||
search_client: VertexAISearchClient,
|
||||
) -> None:
|
||||
"""Test the search method of VertexAISearchClient."""
|
||||
mock_pager = create_mock_search_pager_result()
|
||||
|
||||
search_client.client.search.return_value = mock_pager
|
||||
|
||||
mock_map_pager.return_value = {"results": [{"document": {"id": "doc1"}}]}
|
||||
mock_simplify.return_value = {"simplified_results": [{"id": "doc1"}]}
|
||||
|
||||
results = search_client.search("test query")
|
||||
|
||||
search_client.client.search.assert_called_once()
|
||||
mock_map_pager.assert_called_once_with(mock_pager)
|
||||
mock_simplify.assert_called_once_with({"results": [{"document": {"id": "doc1"}}]})
|
||||
assert results == {"simplified_results": [{"id": "doc1"}]}
|
||||
|
||||
results_json = json.dumps(results)
|
||||
assert results_json == '{"simplified_results": [{"id": "doc1"}]}'
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main()
|
||||
@@ -0,0 +1,420 @@
|
||||
# Copyright 2024 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""
|
||||
VertexAISearchClient for interacting with Google Cloud Vertex AI Search.
|
||||
|
||||
This module provides a client class for simplifying interactions with the
|
||||
Vertex AI Search API. It handles configuration, query construction, and
|
||||
result parsing.
|
||||
|
||||
Example usage:
|
||||
config = VertexAISearchConfig(
|
||||
project_id="your-project",
|
||||
location="global",
|
||||
data_store_id="your-data-store",
|
||||
engine_data_type="UNSTRUCTURED",
|
||||
engine_chunk_type="CHUNK",
|
||||
summary_type="VERTEX_AI_SEARCH",
|
||||
)
|
||||
client = VertexAISearchClient(config)
|
||||
results = client.search("your search query")
|
||||
print(results)
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
from google.api_core.client_options import ClientOptions
|
||||
from google.cloud import discoveryengine_v1alpha as discoveryengine
|
||||
from google.cloud.discoveryengine_v1alpha.services.search_service.pagers import (
|
||||
SearchPager,
|
||||
)
|
||||
from google.cloud.discoveryengine_v1alpha.types import SearchResponse
|
||||
|
||||
# Define types using string literals, similar to enums.
|
||||
EngineDataTypeStr = Literal["UNSTRUCTURED", "STRUCTURED", "WEBSITE", "BLENDED"]
|
||||
EngineChunkTypeStr = Literal[
|
||||
"DOCUMENT_WITH_SNIPPETS", "DOCUMENT_WITH_EXTRACTIVE_SEGMENTS", "CHUNK"
|
||||
]
|
||||
SummaryTypeStr = Literal[
|
||||
"NONE", "VERTEX_AI_SEARCH", "GENERATE_GROUNDED_ANSWERS", "GEMINI"
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class VertexAISearchConfig:
|
||||
"""Config for the Vertex AI Search data store."""
|
||||
|
||||
project_id: str
|
||||
location: str
|
||||
data_store_id: str
|
||||
engine_data_type: EngineDataTypeStr | str
|
||||
engine_chunk_type: EngineChunkTypeStr | str
|
||||
summary_type: SummaryTypeStr | str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate and convert string inputs to appropriate types."""
|
||||
self.engine_data_type = self._validate_enum(
|
||||
self.engine_data_type, EngineDataTypeStr, "UNSTRUCTURED"
|
||||
)
|
||||
self.engine_chunk_type = self._validate_enum(
|
||||
self.engine_chunk_type, EngineChunkTypeStr, "CHUNK"
|
||||
)
|
||||
self.summary_type = self._validate_enum(
|
||||
self.summary_type, SummaryTypeStr, "VERTEX_AI_SEARCH"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_enum(value: str, enum_type: Any, default: str) -> str:
|
||||
"""Validate and convert string to enum type."""
|
||||
if value in enum_type.__args__:
|
||||
return value
|
||||
print(f"Warning: Invalid value '{value}'. Using default: '{default}'")
|
||||
return default
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
"""Convert the config to a dictionary."""
|
||||
return {
|
||||
"project_id": self.project_id,
|
||||
"location": self.location,
|
||||
"data_store_id": self.data_store_id,
|
||||
"engine_data_type": self.engine_data_type,
|
||||
"engine_chunk_type": self.engine_chunk_type,
|
||||
"summary_type": self.summary_type,
|
||||
}
|
||||
|
||||
|
||||
class VertexAISearchClient:
|
||||
"""
|
||||
A client for interacting with Google Cloud Vertex AI Search.
|
||||
|
||||
This class provides methods to configure the search engine, perform searches,
|
||||
and parse the results. It supports different types of data stores and search
|
||||
configurations.
|
||||
"""
|
||||
|
||||
def __init__(self, config: VertexAISearchConfig):
|
||||
"""
|
||||
Initialize the VertexAISearchClient.
|
||||
|
||||
Args:
|
||||
config (VertexAISearchConfig): The configuration for the Vertex AI Search client.
|
||||
"""
|
||||
self.config = config
|
||||
self.client = self._create_client()
|
||||
self.serving_config = self._get_serving_config()
|
||||
|
||||
def _create_client(self) -> discoveryengine.SearchServiceClient:
|
||||
"""
|
||||
Create and configure the SearchServiceClient.
|
||||
|
||||
Returns:
|
||||
discoveryengine.SearchServiceClient: The configured client.
|
||||
"""
|
||||
client_options = None
|
||||
if self.config.location != "global":
|
||||
api_endpoint = f"{self.config.location}-discoveryengine.googleapis.com"
|
||||
client_options = ClientOptions(api_endpoint=api_endpoint)
|
||||
return discoveryengine.SearchServiceClient(client_options=client_options)
|
||||
|
||||
def _get_serving_config(self) -> str:
|
||||
"""
|
||||
Get the serving configuration path for the Vertex AI Search data store.
|
||||
|
||||
Returns:
|
||||
str: The serving configuration path.
|
||||
"""
|
||||
return self.client.serving_config_path(
|
||||
project=self.config.project_id,
|
||||
location=self.config.location,
|
||||
data_store=self.config.data_store_id,
|
||||
serving_config="default_config",
|
||||
)
|
||||
|
||||
def search(self, query: str, page_size: int = 10) -> dict[str, Any]:
|
||||
"""
|
||||
Perform a search query using Vertex AI Search.
|
||||
|
||||
Args:
|
||||
query (str): The search query.
|
||||
page_size (int): Number of results to return per page.
|
||||
|
||||
Returns:
|
||||
dict: Parsed and simplified search results.
|
||||
"""
|
||||
request = self.build_search_request(query, page_size)
|
||||
print(f"<request> {request} </request>")
|
||||
search_pager = self.client.search(request)
|
||||
response = self.map_search_pager_to_dict(search_pager)
|
||||
print(f"<response> {response} </response>")
|
||||
return self.simplify_search_results(response)
|
||||
|
||||
def build_search_request(
|
||||
self, query: str, page_size: int
|
||||
) -> discoveryengine.SearchRequest:
|
||||
"""
|
||||
Build a SearchRequest object based on the client configuration and query.
|
||||
|
||||
Args:
|
||||
query (str): The search query.
|
||||
page_size (int): Number of results to return per page.
|
||||
|
||||
Returns:
|
||||
discoveryengine.SearchRequest: The configured search request object.
|
||||
"""
|
||||
snippet_spec = None
|
||||
extractive_content_spec = None
|
||||
if self.config.engine_chunk_type == "DOCUMENT_WITH_SNIPPETS":
|
||||
snippet_spec = discoveryengine.SearchRequest.ContentSearchSpec.SnippetSpec(
|
||||
return_snippet=True,
|
||||
)
|
||||
if self.config.engine_chunk_type == "DOCUMENT_WITH_EXTRACTIVE_SEGMENTS":
|
||||
snippet_spec = discoveryengine.SearchRequest.ContentSearchSpec.SnippetSpec(
|
||||
return_snippet=True,
|
||||
)
|
||||
extractive_content_spec = (
|
||||
discoveryengine.SearchRequest.ContentSearchSpec.ExtractiveContentSpec(
|
||||
max_extractive_answer_count=1,
|
||||
return_extractive_segment_score=True,
|
||||
)
|
||||
)
|
||||
|
||||
summary_spec = None
|
||||
if self.config.summary_type == "VERTEX_AI_SEARCH":
|
||||
summary_spec = discoveryengine.SearchRequest.ContentSearchSpec.SummarySpec(
|
||||
summary_result_count=5,
|
||||
include_citations=True,
|
||||
ignore_adversarial_query=True,
|
||||
ignore_non_summary_seeking_query=True,
|
||||
)
|
||||
|
||||
return discoveryengine.SearchRequest(
|
||||
serving_config=self.serving_config,
|
||||
query=query,
|
||||
page_size=page_size,
|
||||
content_search_spec=discoveryengine.SearchRequest.ContentSearchSpec(
|
||||
snippet_spec=snippet_spec,
|
||||
extractive_content_spec=extractive_content_spec,
|
||||
summary_spec=summary_spec,
|
||||
),
|
||||
query_expansion_spec=discoveryengine.SearchRequest.QueryExpansionSpec(
|
||||
condition=discoveryengine.SearchRequest.QueryExpansionSpec.Condition.AUTO,
|
||||
),
|
||||
spell_correction_spec=discoveryengine.SearchRequest.SpellCorrectionSpec(
|
||||
mode=discoveryengine.SearchRequest.SpellCorrectionSpec.Mode.AUTO
|
||||
),
|
||||
)
|
||||
|
||||
def map_search_pager_to_dict(self, pager: SearchPager) -> dict[str, Any]:
|
||||
"""
|
||||
Maps a SearchPager to a dictionary structure, iterativly requesting results.
|
||||
|
||||
https://cloud.google.com/python/docs/reference/discoveryengine/latest/google.cloud.discoveryengine_v1alpha.services.search_service.pagers.SearchPager
|
||||
|
||||
Args:
|
||||
pager (SearchPager): The pager returned by the search method.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: A dictionary containing the search results and metadata.
|
||||
"""
|
||||
output: dict[str, Any] = {
|
||||
"results": [
|
||||
SearchResponse.SearchResult.to_dict(result) for result in pager
|
||||
],
|
||||
"total_size": pager.total_size,
|
||||
"attribution_token": pager.attribution_token,
|
||||
"next_page_token": pager.next_page_token,
|
||||
"corrected_query": pager.corrected_query,
|
||||
"facets": [],
|
||||
"applied_controls": [],
|
||||
}
|
||||
|
||||
if pager.summary:
|
||||
output["summary"] = SearchResponse.Summary.to_dict(pager.summary)
|
||||
|
||||
if pager.facets:
|
||||
output["facets"] = [
|
||||
SearchResponse.Facet.to_dict(facet) for facet in pager.facets
|
||||
]
|
||||
|
||||
if pager.guided_search_result:
|
||||
output["guided_search_result"] = SearchResponse.GuidedSearchResult.to_dict(
|
||||
pager.guided_search_result
|
||||
)
|
||||
|
||||
if pager.query_expansion_info:
|
||||
output["query_expansion_info"] = SearchResponse.QueryExpansionInfo.to_dict(
|
||||
pager.query_expansion_info
|
||||
)
|
||||
|
||||
if pager.applied_controls:
|
||||
output["applied_controls"] = [
|
||||
control.strip() for control in pager.applied_controls
|
||||
]
|
||||
|
||||
return output
|
||||
|
||||
def simplify_search_results(self, response: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Simplify the search results by parsing documents and chunks.
|
||||
|
||||
Args:
|
||||
response (Dict[str, Any]): The raw search response.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: The simplified search results.
|
||||
"""
|
||||
if "results" not in response:
|
||||
return response
|
||||
simplified_results = []
|
||||
for result in response["results"]:
|
||||
if "document" in result:
|
||||
simplified_results.append(
|
||||
self._parse_document_result(result["document"])
|
||||
)
|
||||
elif "chunk" in result:
|
||||
simplified_results.append(self._parse_chunk_result(result["chunk"]))
|
||||
response["simplified_results"] = simplified_results
|
||||
return response
|
||||
|
||||
def _parse_document_result(self, document: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Parse a single document result from the search response.
|
||||
|
||||
This supports both structured and unstructured data, and also supports
|
||||
extractive segments and answers and snippets.
|
||||
|
||||
Args:
|
||||
document (Dict[str, Any]): The document data from the search result.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: The parsed document page_content and metadata.
|
||||
"""
|
||||
metadata = {
|
||||
**document.get("derived_struct_data", {}),
|
||||
**document.get("struct_data", {}),
|
||||
}
|
||||
|
||||
json_data = document.get("json_data", {})
|
||||
if isinstance(json_data, str):
|
||||
try:
|
||||
json_data = json.loads(json_data)
|
||||
except json.JSONDecodeError:
|
||||
print(f"Warning: Failed to parse json_data: {json_data}")
|
||||
json_data = {}
|
||||
|
||||
metadata.update(json_data)
|
||||
result: dict[str, Any] = {"metadata": metadata}
|
||||
|
||||
if self.config.engine_data_type == "STRUCTURED":
|
||||
structured_data = (
|
||||
json_data if json_data else document.get("struct_data", {})
|
||||
)
|
||||
result["page_content"] = json.dumps(structured_data, indent=2)
|
||||
for key in structured_data.keys():
|
||||
result["metadata"].pop(key, None)
|
||||
elif "extractive_answers" in metadata:
|
||||
result["page_content"] = self._parse_segments(
|
||||
metadata.get("extractive_answers", [])
|
||||
)
|
||||
elif "snippets" in metadata:
|
||||
result["page_content"] = self._parse_snippets(metadata.get("snippets", []))
|
||||
else:
|
||||
result["page_content"] = metadata.get("content", "")
|
||||
|
||||
return result
|
||||
|
||||
def _parse_segments(self, segments: list[dict[str, Any]]) -> str:
|
||||
"""
|
||||
Parse extractive segments from a single document of search results.
|
||||
|
||||
Args:
|
||||
segments (List[Dict[str, Any]]): The list of extractive segments.
|
||||
|
||||
Returns:
|
||||
str: A formatted string containing page number, score and the text of each segment.
|
||||
"""
|
||||
parsed_segments = [
|
||||
{
|
||||
"content": self._strip_content(segment.get("content", "")),
|
||||
"page_number": segment.get("page_number") or segment.get("pageNumber"),
|
||||
"score": segment.get("score"),
|
||||
}
|
||||
for segment in segments
|
||||
]
|
||||
return "\n\n".join(
|
||||
f"On page {segment['page_number']} with a relevance score of {segment['score']}:\n"
|
||||
f"```\n{segment['content']}\n```"
|
||||
for segment in parsed_segments
|
||||
)
|
||||
|
||||
def _parse_snippets(self, snippets: list[dict[str, Any]]) -> str:
|
||||
"""
|
||||
Parse snippets from a single document of search results.
|
||||
|
||||
Args:
|
||||
snippets (List[Dict[str, Any]]): The list of snippets.
|
||||
|
||||
Returns:
|
||||
str: A formatted string containing the successfully parsed snippets.
|
||||
"""
|
||||
return "\n\n".join(
|
||||
self._strip_content(snippet.get("snippet", ""))
|
||||
for snippet in snippets
|
||||
if snippet.get("snippetStatus") == "SUCCESS"
|
||||
)
|
||||
|
||||
def _parse_chunk_result(self, chunk: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Parse a single chunk result from the search response.
|
||||
|
||||
Args:
|
||||
chunk (Dict[str, Any]): The chunk data from the search result.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: The parsed chunk page_content and metadata.
|
||||
"""
|
||||
metadata = {
|
||||
"chunk_id": chunk.get("id"),
|
||||
"score": chunk.get("relevance_score"),
|
||||
}
|
||||
|
||||
page_span = chunk.get("page_span", {})
|
||||
if page_span:
|
||||
metadata["page"] = page_span.get("page_start")
|
||||
metadata["page_span_end"] = page_span.get("page_end")
|
||||
|
||||
metadata.update(chunk.get("document_metadata", {}))
|
||||
metadata.update(chunk.get("derived_struct_data", {}))
|
||||
|
||||
return {
|
||||
"metadata": metadata,
|
||||
"page_content": self._strip_content(chunk.get("content", "")),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _strip_content(text: str) -> str:
|
||||
"""
|
||||
Strip HTML tags and unescape HTML entities from the given text.
|
||||
|
||||
Args:
|
||||
text (str): The input text to clean.
|
||||
|
||||
Returns:
|
||||
str: The cleaned text.
|
||||
"""
|
||||
text = re.sub("<.*?>", "", text)
|
||||
return html.unescape(text).strip()
|
||||
Reference in New Issue
Block a user