Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:37:31 +08:00

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])