375 lines
12 KiB
Python
375 lines
12 KiB
Python
import pytest
|
|
import numpy as np
|
|
from unittest.mock import patch, AsyncMock
|
|
from lightrag.utils import EmbeddingFunc
|
|
from lightrag.kg.postgres_impl import (
|
|
PGVectorStorage,
|
|
PostgreSQLDB,
|
|
_safe_index_name,
|
|
)
|
|
from lightrag.exceptions import DataMigrationError
|
|
from lightrag.namespace import NameSpace
|
|
|
|
|
|
# Mock PostgreSQLDB
|
|
@pytest.fixture
|
|
def mock_pg_db():
|
|
"""Mock PostgreSQL database connection"""
|
|
db = AsyncMock()
|
|
db.workspace = "test_workspace"
|
|
db.vector_index_type = None
|
|
|
|
# Mock query responses: list for search queries (multirows=True), dict for DDL checks
|
|
async def mock_query(sql, params=None, multirows=False, **kwargs):
|
|
if multirows:
|
|
return []
|
|
return {"exists": False, "count": 0}
|
|
|
|
# Mock for execute that mimics PostgreSQLDB.execute() behavior
|
|
async def mock_execute(sql, data=None, **kwargs):
|
|
return None
|
|
|
|
db.query = AsyncMock(side_effect=mock_query)
|
|
db.execute = AsyncMock(side_effect=mock_execute)
|
|
|
|
return db
|
|
|
|
|
|
# Mock get_data_init_lock to avoid async lock issues in tests
|
|
@pytest.fixture(autouse=True)
|
|
def mock_data_init_lock():
|
|
with patch("lightrag.kg.postgres_impl.get_data_init_lock") as mock_lock:
|
|
mock_lock_ctx = AsyncMock()
|
|
mock_lock.return_value = mock_lock_ctx
|
|
yield mock_lock
|
|
|
|
|
|
# Mock ClientManager
|
|
@pytest.fixture
|
|
def mock_client_manager(mock_pg_db):
|
|
with patch("lightrag.kg.postgres_impl.ClientManager") as mock_manager:
|
|
mock_manager.get_client = AsyncMock(return_value=mock_pg_db)
|
|
mock_manager.release_client = AsyncMock()
|
|
yield mock_manager
|
|
|
|
|
|
# Mock Embedding function
|
|
@pytest.fixture
|
|
def mock_embedding_func():
|
|
async def embed_func(texts, **kwargs):
|
|
return np.array([[0.1] * 768 for _ in texts])
|
|
|
|
# Note: EmbeddingFunc in this version of lightrag supports model_name
|
|
func = EmbeddingFunc(embedding_dim=768, func=embed_func, model_name="test_model")
|
|
return func
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_postgres_halfvec_table_creation(
|
|
mock_client_manager, mock_pg_db, mock_embedding_func
|
|
):
|
|
"""Test if table is created with HALFVEC type when HNSW_HALFVEC is selected"""
|
|
# Set index type to HNSW_HALFVEC
|
|
mock_pg_db.vector_index_type = "HNSW_HALFVEC"
|
|
|
|
config = {
|
|
"embedding_batch_num": 10,
|
|
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
|
}
|
|
|
|
storage = PGVectorStorage(
|
|
namespace=NameSpace.VECTOR_STORE_CHUNKS,
|
|
global_config=config,
|
|
embedding_func=mock_embedding_func,
|
|
workspace="test_ws",
|
|
)
|
|
|
|
# Mock table doesn't exist
|
|
mock_pg_db.check_table_exists = AsyncMock(return_value=False)
|
|
|
|
# Initialize storage (should trigger table creation)
|
|
await storage.initialize()
|
|
|
|
# Verify table creation SQL contains HALFVEC(768)
|
|
create_table_calls = [
|
|
call
|
|
for call in mock_pg_db.execute.call_args_list
|
|
if "CREATE TABLE" in call[0][0]
|
|
]
|
|
|
|
assert len(create_table_calls) > 0
|
|
create_sql = create_table_calls[0][0][0]
|
|
assert "HALFVEC(768)" in create_sql
|
|
assert "VECTOR(768)" not in create_sql
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_postgres_vector_table_creation_default(
|
|
mock_client_manager, mock_pg_db, mock_embedding_func
|
|
):
|
|
"""Test if table is created with default VECTOR type when other index type is selected"""
|
|
# Set index type to HNSW (default)
|
|
mock_pg_db.vector_index_type = "HNSW"
|
|
|
|
config = {
|
|
"embedding_batch_num": 10,
|
|
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
|
}
|
|
|
|
storage = PGVectorStorage(
|
|
namespace=NameSpace.VECTOR_STORE_CHUNKS,
|
|
global_config=config,
|
|
embedding_func=mock_embedding_func,
|
|
workspace="test_ws",
|
|
)
|
|
|
|
# Mock table doesn't exist
|
|
mock_pg_db.check_table_exists = AsyncMock(return_value=False)
|
|
|
|
# Initialize storage (should trigger table creation)
|
|
await storage.initialize()
|
|
|
|
# Verify table creation SQL contains VECTOR(768)
|
|
create_table_calls = [
|
|
call
|
|
for call in mock_pg_db.execute.call_args_list
|
|
if "CREATE TABLE" in call[0][0]
|
|
]
|
|
|
|
assert len(create_table_calls) > 0
|
|
create_sql = create_table_calls[0][0][0]
|
|
assert "VECTOR(768)" in create_sql
|
|
assert "HALFVEC(768)" not in create_sql
|
|
|
|
|
|
# Namespaces that use vector search SQL templates (query path)
|
|
QUERY_NAMESPACES = [
|
|
NameSpace.VECTOR_STORE_CHUNKS,
|
|
NameSpace.VECTOR_STORE_ENTITIES,
|
|
NameSpace.VECTOR_STORE_RELATIONSHIPS,
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("namespace", QUERY_NAMESPACES)
|
|
async def test_query_uses_halfvec_cast_when_hnsw_halfvec(
|
|
mock_client_manager, mock_pg_db, mock_embedding_func, namespace
|
|
):
|
|
"""When HNSW_HALFVEC is set, generated search SQL uses ::halfvec (not ::vector)."""
|
|
mock_pg_db.vector_index_type = "HNSW_HALFVEC"
|
|
mock_pg_db.check_table_exists = AsyncMock(return_value=True)
|
|
|
|
config = {
|
|
"embedding_batch_num": 10,
|
|
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
|
}
|
|
storage = PGVectorStorage(
|
|
namespace=namespace,
|
|
global_config=config,
|
|
embedding_func=mock_embedding_func,
|
|
workspace="test_ws",
|
|
)
|
|
await storage.initialize()
|
|
|
|
query_embedding = [0.1] * 768
|
|
await storage.query("test query", top_k=5, query_embedding=query_embedding)
|
|
|
|
assert mock_pg_db.query.called
|
|
call_args = mock_pg_db.query.call_args
|
|
sql = call_args[0][0]
|
|
assert "::halfvec" in sql
|
|
assert "::vector" not in sql
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("namespace", QUERY_NAMESPACES)
|
|
async def test_query_uses_vector_cast_when_hnsw_default(
|
|
mock_client_manager, mock_pg_db, mock_embedding_func, namespace
|
|
):
|
|
"""When HNSW (default) is set, generated search SQL uses ::vector (not ::halfvec)."""
|
|
mock_pg_db.vector_index_type = "HNSW"
|
|
mock_pg_db.check_table_exists = AsyncMock(return_value=True)
|
|
|
|
config = {
|
|
"embedding_batch_num": 10,
|
|
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
|
}
|
|
storage = PGVectorStorage(
|
|
namespace=namespace,
|
|
global_config=config,
|
|
embedding_func=mock_embedding_func,
|
|
workspace="test_ws",
|
|
)
|
|
await storage.initialize()
|
|
|
|
query_embedding = [0.1] * 768
|
|
await storage.query("test query", top_k=5, query_embedding=query_embedding)
|
|
|
|
assert mock_pg_db.query.called
|
|
call_args = mock_pg_db.query.call_args
|
|
sql = call_args[0][0]
|
|
assert "::vector" in sql
|
|
assert "::halfvec" not in sql
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Index switching: old conflicting indexes are dropped
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_vector_index_drops_old_indexes_when_switching(mock_pg_db):
|
|
"""Switching from HNSW to HNSW_HALFVEC drops the old hnsw_cosine index."""
|
|
mock_pg_db.vector_index_type = "HNSW_HALFVEC"
|
|
mock_pg_db.hnsw_m = 16
|
|
mock_pg_db.hnsw_ef = 64
|
|
mock_pg_db.ivfflat_lists = 100
|
|
mock_pg_db.vchordrq_build_options = ""
|
|
|
|
table_name = "lightrag_vdb_chunks_test"
|
|
|
|
async def mock_query(sql, params=None, multirows=False, **kwargs):
|
|
if "pg_indexes" in sql:
|
|
return None
|
|
return None
|
|
|
|
mock_pg_db.query = AsyncMock(side_effect=mock_query)
|
|
mock_pg_db.execute = AsyncMock()
|
|
|
|
# Call the real method with mock_pg_db as self
|
|
await PostgreSQLDB._create_vector_index(mock_pg_db, table_name, 3072)
|
|
|
|
execute_calls = [call[0][0] for call in mock_pg_db.execute.call_args_list]
|
|
|
|
old_hnsw_name = _safe_index_name(table_name, "hnsw_cosine")
|
|
old_ivfflat_name = _safe_index_name(table_name, "ivfflat_cosine")
|
|
old_vchordrq_name = _safe_index_name(table_name, "vchordrq_cosine")
|
|
|
|
drop_calls = [c for c in execute_calls if "DROP INDEX IF EXISTS" in c]
|
|
dropped_names = {c.split("DROP INDEX IF EXISTS ")[1].strip() for c in drop_calls}
|
|
assert old_hnsw_name in dropped_names
|
|
assert old_ivfflat_name in dropped_names
|
|
assert old_vchordrq_name in dropped_names
|
|
|
|
new_index_name = _safe_index_name(table_name, "hnsw_halfvec_cosine")
|
|
assert new_index_name not in dropped_names
|
|
|
|
alter_calls = [c for c in execute_calls if "ALTER TABLE" in c]
|
|
assert any("HALFVEC(3072)" in c for c in alter_calls)
|
|
|
|
create_calls = [c for c in execute_calls if "CREATE INDEX" in c]
|
|
assert any("halfvec_cosine_ops" in c for c in create_calls)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_vector_index_no_drop_when_index_exists(mock_pg_db):
|
|
"""If the target index already exists, no DROP or CREATE is issued."""
|
|
mock_pg_db.vector_index_type = "HNSW_HALFVEC"
|
|
mock_pg_db.hnsw_m = 16
|
|
mock_pg_db.hnsw_ef = 64
|
|
mock_pg_db.ivfflat_lists = 100
|
|
mock_pg_db.vchordrq_build_options = ""
|
|
|
|
table_name = "lightrag_vdb_chunks_test"
|
|
|
|
async def mock_query(sql, params=None, multirows=False, **kwargs):
|
|
if "pg_indexes" in sql:
|
|
return {"?column?": 1}
|
|
return None
|
|
|
|
mock_pg_db.query = AsyncMock(side_effect=mock_query)
|
|
mock_pg_db.execute = AsyncMock()
|
|
|
|
await PostgreSQLDB._create_vector_index(mock_pg_db, table_name, 3072)
|
|
|
|
execute_calls = [call[0][0] for call in mock_pg_db.execute.call_args_list]
|
|
assert not any("DROP INDEX" in c for c in execute_calls)
|
|
assert not any("CREATE INDEX" in c for c in execute_calls)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HalfVector dimension detection in setup_table
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _MockHalfVector:
|
|
"""Mimics pgvector.halfvec.HalfVector for testing dimension detection."""
|
|
|
|
def __init__(self, dim: int):
|
|
self._dim = dim
|
|
|
|
def dimensions(self) -> int:
|
|
return self._dim
|
|
|
|
def to_list(self):
|
|
return [0.0] * self._dim
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_table_detects_halfvector_dimension_mismatch(mock_pg_db):
|
|
"""DataMigrationError is raised when a HalfVector column has a different dimension."""
|
|
table_name = "lightrag_vdb_chunks_new"
|
|
legacy_table = "lightrag_vdb_chunks"
|
|
|
|
mock_pg_db.check_table_exists = AsyncMock(
|
|
side_effect=lambda t: t.lower() == legacy_table.lower()
|
|
)
|
|
|
|
call_count = 0
|
|
|
|
async def mock_query(sql, params=None, multirows=False, **kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if "COUNT(*)" in sql:
|
|
return {"count": 5}
|
|
if "content_vector" in sql:
|
|
return {"content_vector": _MockHalfVector(1024)}
|
|
return None
|
|
|
|
mock_pg_db.query = AsyncMock(side_effect=mock_query)
|
|
mock_pg_db.execute = AsyncMock()
|
|
|
|
with pytest.raises(DataMigrationError, match="Dimension mismatch"):
|
|
await PGVectorStorage.setup_table(
|
|
db=mock_pg_db,
|
|
table_name=table_name,
|
|
workspace="test_ws",
|
|
embedding_dim=768,
|
|
legacy_table_name=legacy_table,
|
|
base_table=legacy_table,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_table_accepts_matching_halfvector_dimension(mock_pg_db):
|
|
"""No error when HalfVector dimension matches the expected embedding_dim."""
|
|
table_name = "lightrag_vdb_chunks_new"
|
|
legacy_table = "lightrag_vdb_chunks"
|
|
|
|
mock_pg_db.check_table_exists = AsyncMock(
|
|
side_effect=lambda t: t.lower() == legacy_table.lower()
|
|
)
|
|
mock_pg_db.vector_index_type = "HNSW_HALFVEC"
|
|
|
|
async def mock_query(sql, params=None, multirows=False, **kwargs):
|
|
if "COUNT(*)" in sql:
|
|
return {"count": 5}
|
|
if "content_vector" in sql:
|
|
return {"content_vector": _MockHalfVector(768)}
|
|
if multirows:
|
|
return []
|
|
return None
|
|
|
|
mock_pg_db.query = AsyncMock(side_effect=mock_query)
|
|
mock_pg_db.execute = AsyncMock()
|
|
|
|
with patch.object(PGVectorStorage, "_pg_create_table", new_callable=AsyncMock):
|
|
await PGVectorStorage.setup_table(
|
|
db=mock_pg_db,
|
|
table_name=table_name,
|
|
workspace="test_ws",
|
|
embedding_dim=768,
|
|
legacy_table_name=legacy_table,
|
|
base_table=legacy_table,
|
|
)
|