6b7e6b44f1
Python Build and Type Check / python-ci (ubuntu-latest, 3.11) (push) Has been cancelled
Python Build and Type Check / python-ci (ubuntu-latest, 3.13) (push) Has been cancelled
Python Build and Type Check / python-ci (windows-latest, 3.11) (push) Has been cancelled
Python Build and Type Check / python-ci (windows-latest, 3.13) (push) Has been cancelled
Python Integration Tests / python-ci (ubuntu-latest, 3.13) (push) Has been cancelled
Python Integration Tests / python-ci (windows-latest, 3.13) (push) Has been cancelled
Python Notebook Tests / python-ci (ubuntu-latest, 3.13) (push) Has been cancelled
Python Notebook Tests / python-ci (windows-latest, 3.13) (push) Has been cancelled
Python Smoke Tests / python-ci (ubuntu-latest, 3.13) (push) Has been cancelled
Python Smoke Tests / python-ci (windows-latest, 3.13) (push) Has been cancelled
Python Unit Tests / python-ci (ubuntu-latest, 3.13) (push) Has been cancelled
Python Unit Tests / python-ci (windows-latest, 3.13) (push) Has been cancelled
gh-pages / build (push) Has been cancelled
Python Publish (pypi) / Upload release to PyPI (push) Has been cancelled
Spellcheck / spellcheck (push) Has been cancelled
374 lines
13 KiB
Python
374 lines
13 KiB
Python
# Copyright (c) 2024 Microsoft Corporation.
|
|
# Licensed under the MIT License
|
|
|
|
"""A package containing the Azure AI Search vector store implementation."""
|
|
|
|
from typing import Any
|
|
|
|
from azure.core.credentials import AzureKeyCredential
|
|
from azure.identity import DefaultAzureCredential
|
|
from azure.search.documents import SearchClient
|
|
from azure.search.documents.indexes import SearchIndexClient
|
|
from azure.search.documents.indexes.models import (
|
|
HnswAlgorithmConfiguration,
|
|
HnswParameters,
|
|
SearchField,
|
|
SearchFieldDataType,
|
|
SearchIndex,
|
|
SimpleField,
|
|
VectorSearch,
|
|
VectorSearchAlgorithmMetric,
|
|
VectorSearchProfile,
|
|
)
|
|
from azure.search.documents.models import VectorizedQuery
|
|
|
|
from graphrag_vectors.filtering import (
|
|
AndExpr,
|
|
Condition,
|
|
FilterExpr,
|
|
NotExpr,
|
|
Operator,
|
|
OrExpr,
|
|
)
|
|
from graphrag_vectors.vector_store import (
|
|
VectorStore,
|
|
VectorStoreDocument,
|
|
VectorStoreSearchResult,
|
|
)
|
|
|
|
# Mapping from field type strings to Azure AI Search data types
|
|
FIELD_TYPE_MAPPING: dict[str, SearchFieldDataType] = {
|
|
"str": SearchFieldDataType.String,
|
|
"int": SearchFieldDataType.Int64,
|
|
"float": SearchFieldDataType.Double,
|
|
"bool": SearchFieldDataType.Boolean,
|
|
}
|
|
|
|
|
|
class AzureAISearchVectorStore(VectorStore):
|
|
"""Azure AI Search vector storage implementation."""
|
|
|
|
index_client: SearchIndexClient
|
|
|
|
def __init__(
|
|
self,
|
|
url: str,
|
|
api_key: str | None = None,
|
|
audience: str | None = None,
|
|
vector_search_profile_name: str = "vectorSearchProfile",
|
|
**kwargs: Any,
|
|
):
|
|
super().__init__(**kwargs)
|
|
if not url:
|
|
msg = "url must be provided for Azure AI Search."
|
|
raise ValueError(msg)
|
|
self.url = url
|
|
self.api_key = api_key
|
|
self.audience = audience
|
|
self.vector_search_profile_name = vector_search_profile_name
|
|
|
|
def connect(self) -> Any:
|
|
"""Connect to AI search vector storage."""
|
|
audience_arg = (
|
|
{"audience": self.audience} if self.audience and not self.api_key else {}
|
|
)
|
|
self.db_connection = SearchClient(
|
|
endpoint=self.url,
|
|
index_name=self.index_name,
|
|
credential=(
|
|
AzureKeyCredential(self.api_key)
|
|
if self.api_key
|
|
else DefaultAzureCredential()
|
|
),
|
|
**audience_arg,
|
|
)
|
|
self.index_client = SearchIndexClient(
|
|
endpoint=self.url,
|
|
credential=(
|
|
AzureKeyCredential(self.api_key)
|
|
if self.api_key
|
|
else DefaultAzureCredential()
|
|
),
|
|
**audience_arg,
|
|
)
|
|
|
|
def create_index(self) -> None:
|
|
"""Load documents into an Azure AI Search index."""
|
|
if (
|
|
self.index_name is not None
|
|
and self.index_name in self.index_client.list_index_names()
|
|
):
|
|
self.index_client.delete_index(self.index_name)
|
|
|
|
# Configure vector search profile
|
|
vector_search = VectorSearch(
|
|
algorithms=[
|
|
HnswAlgorithmConfiguration(
|
|
name="HnswAlg",
|
|
parameters=HnswParameters(
|
|
metric=VectorSearchAlgorithmMetric.COSINE
|
|
),
|
|
)
|
|
],
|
|
profiles=[
|
|
VectorSearchProfile(
|
|
name=self.vector_search_profile_name,
|
|
algorithm_configuration_name="HnswAlg",
|
|
)
|
|
],
|
|
)
|
|
|
|
# Build the list of fields
|
|
fields = [
|
|
SimpleField(
|
|
name=self.id_field,
|
|
type=SearchFieldDataType.String,
|
|
key=True,
|
|
),
|
|
SearchField(
|
|
name=self.vector_field,
|
|
type=SearchFieldDataType.Collection(SearchFieldDataType.Single),
|
|
searchable=True,
|
|
hidden=False, # DRIFT needs to return the vector for client-side similarity
|
|
vector_search_dimensions=self.vector_size,
|
|
vector_search_profile_name=self.vector_search_profile_name,
|
|
),
|
|
SimpleField(
|
|
name=self.create_date_field,
|
|
type=SearchFieldDataType.String,
|
|
filterable=True,
|
|
),
|
|
SimpleField(
|
|
name=self.update_date_field,
|
|
type=SearchFieldDataType.String,
|
|
filterable=True,
|
|
),
|
|
]
|
|
|
|
# Add additional fields from the fields dictionary
|
|
for field_name, field_type in self.fields.items():
|
|
fields.append(
|
|
SimpleField(
|
|
name=field_name,
|
|
type=FIELD_TYPE_MAPPING[field_type],
|
|
filterable=True,
|
|
)
|
|
)
|
|
|
|
# Configure the index
|
|
index = SearchIndex(
|
|
name=self.index_name,
|
|
fields=fields,
|
|
vector_search=vector_search,
|
|
)
|
|
self.index_client.create_or_update_index(
|
|
index,
|
|
)
|
|
|
|
def load_documents(self, documents: list[VectorStoreDocument]) -> None:
|
|
"""Load documents into Azure AI Search as a single batch upload."""
|
|
batch: list[dict[str, Any]] = []
|
|
for document in documents:
|
|
self._prepare_document(document)
|
|
if document.vector is None:
|
|
continue
|
|
doc_dict: dict[str, Any] = {
|
|
self.id_field: document.id,
|
|
self.vector_field: document.vector,
|
|
self.create_date_field: document.create_date,
|
|
self.update_date_field: document.update_date,
|
|
}
|
|
if document.data:
|
|
for field_name in self.fields:
|
|
if field_name in document.data:
|
|
doc_dict[field_name] = document.data[field_name]
|
|
batch.append(doc_dict)
|
|
|
|
if batch:
|
|
self.db_connection.upload_documents(batch)
|
|
|
|
def _compile_filter(self, expr: FilterExpr) -> str:
|
|
"""Compile a FilterExpr into an Azure AI Search OData filter string."""
|
|
match expr:
|
|
case Condition():
|
|
return self._compile_condition(expr)
|
|
case AndExpr():
|
|
parts = [self._compile_filter(e) for e in expr.and_]
|
|
return " and ".join(f"({p})" for p in parts)
|
|
case OrExpr():
|
|
parts = [self._compile_filter(e) for e in expr.or_]
|
|
return " or ".join(f"({p})" for p in parts)
|
|
case NotExpr():
|
|
inner = self._compile_filter(expr.not_)
|
|
return f"not ({inner})"
|
|
case _:
|
|
msg = f"Unsupported filter expression type: {type(expr)}"
|
|
raise ValueError(msg)
|
|
|
|
def _compile_condition(self, cond: Condition) -> str:
|
|
"""Compile a single Condition to OData filter syntax."""
|
|
field = cond.field
|
|
value = cond.value
|
|
|
|
def quote(v: Any) -> str:
|
|
return (
|
|
f"'{v}'"
|
|
if isinstance(v, str)
|
|
else str(v).lower()
|
|
if isinstance(v, bool)
|
|
else str(v)
|
|
)
|
|
|
|
match cond.operator:
|
|
case Operator.eq:
|
|
return f"{field} eq {quote(value)}"
|
|
case Operator.ne:
|
|
return f"{field} ne {quote(value)}"
|
|
case Operator.gt:
|
|
return f"{field} gt {quote(value)}"
|
|
case Operator.gte:
|
|
return f"{field} ge {quote(value)}"
|
|
case Operator.lt:
|
|
return f"{field} lt {quote(value)}"
|
|
case Operator.lte:
|
|
return f"{field} le {quote(value)}"
|
|
case Operator.in_:
|
|
items = " or ".join(f"{field} eq {quote(v)}" for v in value)
|
|
return f"({items})"
|
|
case Operator.not_in:
|
|
items = " and ".join(f"{field} ne {quote(v)}" for v in value)
|
|
return f"({items})"
|
|
case Operator.contains:
|
|
return f"search.ismatch('{value}', '{field}')"
|
|
case Operator.startswith:
|
|
return f"search.ismatch('{value}*', '{field}')"
|
|
case Operator.endswith:
|
|
return f"search.ismatch('*{value}', '{field}')"
|
|
case Operator.exists:
|
|
return f"{field} ne null" if value else f"{field} eq null"
|
|
case _:
|
|
msg = f"Unsupported operator for Azure AI Search: {cond.operator}"
|
|
raise ValueError(msg)
|
|
|
|
def _extract_data(
|
|
self, doc: dict[str, Any], select: list[str] | None = None
|
|
) -> dict[str, Any]:
|
|
"""Extract additional field data from a document response."""
|
|
fields_to_extract = select if select is not None else list(self.fields.keys())
|
|
return {
|
|
field_name: doc[field_name]
|
|
for field_name in fields_to_extract
|
|
if field_name in doc
|
|
}
|
|
|
|
def similarity_search_by_vector(
|
|
self,
|
|
query_embedding: list[float],
|
|
k: int = 10,
|
|
select: list[str] | None = None,
|
|
filters: FilterExpr | None = None,
|
|
include_vectors: bool = True,
|
|
) -> list[VectorStoreSearchResult]:
|
|
"""Perform a vector-based similarity search."""
|
|
vectorized_query = VectorizedQuery(
|
|
vector=query_embedding,
|
|
k_nearest_neighbors=k,
|
|
fields=self.vector_field,
|
|
)
|
|
|
|
# Build the list of fields to select - always include id, vector, and timestamps
|
|
fields_to_select = [
|
|
self.id_field,
|
|
self.create_date_field,
|
|
self.update_date_field,
|
|
]
|
|
if include_vectors:
|
|
fields_to_select.append(self.vector_field)
|
|
if select is not None:
|
|
fields_to_select.extend(select)
|
|
else:
|
|
fields_to_select.extend(self.fields.keys())
|
|
|
|
# Build OData filter string
|
|
filter_str = self._compile_filter(filters) if filters is not None else None
|
|
|
|
response = self.db_connection.search(
|
|
vector_queries=[vectorized_query],
|
|
select=fields_to_select,
|
|
filter=filter_str,
|
|
)
|
|
|
|
return [
|
|
VectorStoreSearchResult(
|
|
document=VectorStoreDocument(
|
|
id=doc.get(self.id_field, ""),
|
|
vector=doc.get(self.vector_field, []) if include_vectors else None,
|
|
data=self._extract_data(doc, select),
|
|
create_date=doc.get(self.create_date_field),
|
|
update_date=doc.get(self.update_date_field),
|
|
),
|
|
# Cosine similarity between 0.333 and 1.000
|
|
# https://learn.microsoft.com/en-us/azure/search/hybrid-search-ranking#scores-in-a-hybrid-search-results
|
|
score=doc["@search.score"],
|
|
)
|
|
for doc in response
|
|
]
|
|
|
|
def search_by_id(
|
|
self,
|
|
id: str,
|
|
select: list[str] | None = None,
|
|
include_vectors: bool = True,
|
|
) -> VectorStoreDocument:
|
|
"""Search for a document by id."""
|
|
# Build the list of fields to select - always include id, vector, and timestamps
|
|
fields_to_select = [
|
|
self.id_field,
|
|
self.create_date_field,
|
|
self.update_date_field,
|
|
]
|
|
if include_vectors:
|
|
fields_to_select.append(self.vector_field)
|
|
if select is not None:
|
|
fields_to_select.extend(select)
|
|
else:
|
|
fields_to_select.extend(self.fields.keys())
|
|
|
|
response = self.db_connection.get_document(id, selected_fields=fields_to_select)
|
|
return VectorStoreDocument(
|
|
id=response[self.id_field],
|
|
vector=response.get(self.vector_field, []) if include_vectors else None,
|
|
data=self._extract_data(response, select),
|
|
create_date=response.get(self.create_date_field),
|
|
update_date=response.get(self.update_date_field),
|
|
)
|
|
|
|
def count(self) -> int:
|
|
"""Return the total number of documents in the store."""
|
|
return self.db_connection.get_document_count()
|
|
|
|
def remove(self, ids: list[str]) -> None:
|
|
"""Remove documents by their IDs."""
|
|
batch = [{"@search.action": "delete", self.id_field: id} for id in ids]
|
|
self.db_connection.upload_documents(batch)
|
|
|
|
def update(self, document: VectorStoreDocument) -> None:
|
|
"""Update an existing document in the store."""
|
|
self._prepare_update(document)
|
|
|
|
doc: dict[str, Any] = {
|
|
"@search.action": "merge",
|
|
self.id_field: document.id,
|
|
self.update_date_field: document.update_date,
|
|
}
|
|
|
|
if document.vector is not None:
|
|
doc[self.vector_field] = document.vector
|
|
|
|
if document.data:
|
|
for field_name in self.fields:
|
|
if field_name in document.data:
|
|
doc[field_name] = document.data[field_name]
|
|
|
|
self.db_connection.upload_documents([doc])
|