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
422 lines
15 KiB
Python
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()
|