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

422 lines
15 KiB
Python

# Copyright (c) 2024 Microsoft Corporation.
# Licensed under the MIT License
"""A package containing the CosmosDB vector store implementation."""
from typing import Any
from azure.cosmos import ContainerProxy, CosmosClient, DatabaseProxy
from azure.cosmos.exceptions import CosmosHttpResponseError
from azure.cosmos.partition_key import PartitionKey
from azure.identity import DefaultAzureCredential
from graphrag_vectors.filtering import (
AndExpr,
Condition,
FilterExpr,
NotExpr,
Operator,
OrExpr,
)
from graphrag_vectors.vector_store import (
VectorStore,
VectorStoreDocument,
VectorStoreSearchResult,
)
class CosmosDBVectorStore(VectorStore):
"""Azure CosmosDB vector storage implementation."""
_cosmos_client: CosmosClient
_database_client: DatabaseProxy
_container_client: ContainerProxy
def __init__(
self,
database_name: str,
connection_string: str | None = None,
url: str | None = None,
**kwargs,
):
super().__init__(**kwargs)
if self.id_field != "id":
msg = "CosmosDB requires the id_field to be 'id'."
raise ValueError(msg)
if not connection_string and not url:
msg = "Either connection_string or url must be provided for CosmosDB."
raise ValueError(msg)
self.database_name = database_name
self.connection_string = connection_string
self.url = url
def connect(self) -> Any:
"""Connect to CosmosDB vector storage."""
if self.connection_string:
self._cosmos_client = CosmosClient.from_connection_string(
self.connection_string
)
else:
self._cosmos_client = CosmosClient(
url=self.url, credential=DefaultAzureCredential()
)
self._create_database()
self._create_container()
def _create_database(self) -> None:
"""Create the database if it doesn't exist."""
self._cosmos_client.create_database_if_not_exists(id=self.database_name)
self._database_client = self._cosmos_client.get_database_client(
self.database_name
)
def _delete_database(self) -> None:
"""Delete the database if it exists."""
if self._database_exists():
self._cosmos_client.delete_database(self.database_name)
def _database_exists(self) -> bool:
"""Check if the database exists."""
existing_database_names = [
database["id"] for database in self._cosmos_client.list_databases()
]
return self.database_name in existing_database_names
def _create_container(self) -> None:
"""Create the container if it doesn't exist."""
partition_key = PartitionKey(path=f"/{self.id_field}", kind="Hash")
# Define the container vector policy
vector_embedding_policy = {
"vectorEmbeddings": [
{
"path": f"/{self.vector_field}",
"dataType": "float32",
"distanceFunction": "cosine",
"dimensions": self.vector_size,
}
]
}
# Define the vector indexing policy
indexing_policy = {
"indexingMode": "consistent",
"automatic": True,
"includedPaths": [{"path": "/*"}],
"excludedPaths": [
{"path": "/_etag/?"},
{"path": f"/{self.vector_field}/*"},
],
}
# Currently, the CosmosDB emulator does not support the diskANN policy.
try:
# First try with the standard diskANN policy
indexing_policy["vectorIndexes"] = [
{"path": f"/{self.vector_field}", "type": "diskANN"}
]
# Create the container and container client
self._database_client.create_container_if_not_exists(
id=self.index_name,
partition_key=partition_key,
indexing_policy=indexing_policy,
vector_embedding_policy=vector_embedding_policy,
)
except CosmosHttpResponseError:
# If diskANN fails (likely in emulator), retry without vector indexes
indexing_policy.pop("vectorIndexes", None)
# Create the container with compatible indexing policy
self._database_client.create_container_if_not_exists(
id=self.index_name,
partition_key=partition_key,
indexing_policy=indexing_policy,
vector_embedding_policy=vector_embedding_policy,
)
self._container_client = self._database_client.get_container_client(
self.index_name
)
def _delete_container(self) -> None:
"""Delete the vector store container in the database if it exists."""
if self._container_exists():
self._database_client.delete_container(self.index_name)
def _container_exists(self) -> bool:
"""Check if the container name exists in the database."""
existing_container_names = [
container["id"] for container in self._database_client.list_containers()
]
return self.index_name in existing_container_names
def create_index(self) -> None:
"""Load documents into CosmosDB."""
# Create a CosmosDB container on overwrite
self._delete_container()
self._create_container()
if self._container_client is None:
msg = "Container client is not initialized."
raise ValueError(msg)
def load_documents(self, documents: list[VectorStoreDocument]) -> None:
"""Load documents into CosmosDB.
CosmosDB does not support native batch upsert, so each
document is upserted individually after preparation.
"""
for document in documents:
self._prepare_document(document)
if document.vector is None:
continue
doc_json: 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_json[field_name] = document.data[field_name]
self._container_client.upsert_item(doc_json)
def _compile_filter(self, expr: FilterExpr) -> str:
"""Compile a FilterExpr into a CosmosDB SQL WHERE clause.
All field references are prefixed with 'c.' for Cosmos SQL.
"""
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 CosmosDB SQL syntax."""
field = f"c.{cond.field}"
value = cond.value
def quote(v: Any) -> str:
return f"'{v}'" if isinstance(v, str) else str(v)
match cond.operator:
case Operator.eq:
return f"{field} = {quote(value)}"
case Operator.ne:
return f"{field} != {quote(value)}"
case Operator.gt:
return f"{field} > {quote(value)}"
case Operator.gte:
return f"{field} >= {quote(value)}"
case Operator.lt:
return f"{field} < {quote(value)}"
case Operator.lte:
return f"{field} <= {quote(value)}"
case Operator.in_:
items = ", ".join(quote(v) for v in value)
return f"{field} IN ({items})"
case Operator.not_in:
items = ", ".join(quote(v) for v in value)
return f"{field} NOT IN ({items})"
case Operator.contains:
return f"CONTAINS({field}, '{value}')"
case Operator.startswith:
return f"STARTSWITH({field}, '{value}')"
case Operator.endswith:
return f"ENDSWITH({field}, '{value}')"
case Operator.exists:
return f"IS_DEFINED({field})" if value else f"NOT IS_DEFINED({field})"
case _:
msg = f"Unsupported operator for CosmosDB: {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."""
if self._container_client is None:
msg = "Container client is not initialized."
raise ValueError(msg)
# Build field selection for query based on select parameter
fields_to_select = select if select is not None else list(self.fields.keys())
field_selections = ", ".join([f"c.{field}" for field in fields_to_select])
if field_selections:
field_selections = ", " + field_selections
# Always include timestamps
field_selections = (
f", c.{self.create_date_field}, c.{self.update_date_field}"
f"{field_selections}"
)
# Optionally include vector
vector_select = f", c.{self.vector_field}" if include_vectors else ""
# Build WHERE clause from filters
where_clause = ""
if filters is not None:
where_clause = f" WHERE {self._compile_filter(filters)}"
try:
query = (
f"SELECT TOP {k} c.{self.id_field}{vector_select}" # noqa: S608
f"{field_selections},"
f" VectorDistance(c.{self.vector_field}, @embedding)"
f" AS SimilarityScore FROM c{where_clause}"
f" ORDER BY VectorDistance(c.{self.vector_field}, @embedding)"
)
query_params = [{"name": "@embedding", "value": query_embedding}]
items = list(
self._container_client.query_items(
query=query,
parameters=query_params,
enable_cross_partition_query=True,
)
)
except (CosmosHttpResponseError, ValueError):
# Currently, the CosmosDB emulator does not support the VectorDistance function.
# For emulator or test environments - fetch all items and calculate distance locally
query = (
f"SELECT c.{self.id_field}, c.{self.vector_field}" # noqa: S608
f"{field_selections} FROM c{where_clause}"
)
items = list(
self._container_client.query_items(
query=query,
enable_cross_partition_query=True,
)
)
# Calculate cosine similarity locally (1 - cosine distance)
from numpy import dot
from numpy.linalg import norm
def cosine_similarity(a, b):
if norm(a) * norm(b) == 0:
return 0.0
return dot(a, b) / (norm(a) * norm(b))
# Calculate scores for all items
for item in items:
item_vector = item.get(self.vector_field, [])
similarity = cosine_similarity(query_embedding, item_vector)
item["SimilarityScore"] = similarity
# Sort by similarity score (higher is better) and take top k
items = sorted(
items,
key=lambda x: x.get("SimilarityScore", 0.0),
reverse=True,
)[:k]
return [
VectorStoreSearchResult(
document=VectorStoreDocument(
id=item.get(self.id_field, ""),
vector=item.get(self.vector_field, []) if include_vectors else None,
data=self._extract_data(item, select),
create_date=item.get(self.create_date_field),
update_date=item.get(self.update_date_field),
),
score=item.get("SimilarityScore", 0.0),
)
for item in items
]
def search_by_id(
self,
id: str,
select: list[str] | None = None,
include_vectors: bool = True,
) -> VectorStoreDocument:
"""Search for a document by id."""
if self._container_client is None:
msg = "Container client is not initialized."
raise ValueError(msg)
item = self._container_client.read_item(item=id, partition_key=id)
return VectorStoreDocument(
id=item[self.id_field],
vector=item.get(self.vector_field, []) if include_vectors else None,
data=self._extract_data(item, select),
create_date=item.get(self.create_date_field),
update_date=item.get(self.update_date_field),
)
def count(self) -> int:
"""Return the total number of documents in the store."""
query = "SELECT VALUE COUNT(1) FROM c"
result = list(
self._container_client.query_items(
query=query,
enable_cross_partition_query=True,
)
)
return result[0] if result else 0
def remove(self, ids: list[str]) -> None:
"""Remove documents by their IDs."""
for doc_id in ids:
self._container_client.delete_item(item=doc_id, partition_key=doc_id)
def update(self, document: VectorStoreDocument) -> None:
"""Update an existing document in the store."""
self._prepare_update(document)
# Read the existing document
existing = self._container_client.read_item(
item=document.id, partition_key=document.id
)
# Set update_date
existing[self.update_date_field] = document.update_date
# Update vector if provided
if document.vector is not None:
existing[self.vector_field] = document.vector
# Update data fields if provided
if document.data:
for field_name in self.fields:
if field_name in document.data:
existing[field_name] = document.data[field_name]
# Upsert the updated document
self._container_client.upsert_item(existing)
def clear(self) -> None:
"""Clear the vector store."""
self._delete_container()
self._delete_database()