| 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 |
|
|
|
|
| |
| @pytest.fixture |
| def mock_pg_db(): |
| """Mock PostgreSQL database connection""" |
| db = AsyncMock() |
| db.workspace = "test_workspace" |
| db.vector_index_type = None |
|
|
| |
| async def mock_query(sql, params=None, multirows=False, **kwargs): |
| if multirows: |
| return [] |
| return {"exists": False, "count": 0} |
|
|
| |
| 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 |
|
|
|
|
| |
| @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 |
|
|
|
|
| |
| @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 |
|
|
|
|
| |
| @pytest.fixture |
| def mock_embedding_func(): |
| async def embed_func(texts, **kwargs): |
| return np.array([[0.1] * 768 for _ in texts]) |
|
|
| |
| 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""" |
| |
| 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_pg_db.check_table_exists = AsyncMock(return_value=False) |
|
|
| |
| await storage.initialize() |
|
|
| |
| 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""" |
| |
| 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_pg_db.check_table_exists = AsyncMock(return_value=False) |
|
|
| |
| await storage.initialize() |
|
|
| |
| 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 |
|
|
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
|
|
| @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() |
|
|
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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, |
| ) |
|
|