| """ |
| Tests for dimension mismatch handling during migration. |
| |
| This test module verifies that both PostgreSQL and Qdrant storage backends |
| properly detect and handle vector dimension mismatches when migrating from |
| legacy collections/tables to new ones with different embedding models. |
| """ |
|
|
| import json |
| import pytest |
| from unittest.mock import MagicMock, AsyncMock, patch |
|
|
| from lightrag.kg.qdrant_impl import QdrantVectorDBStorage |
| from lightrag.kg.postgres_impl import PGVectorStorage |
| from lightrag.exceptions import DataMigrationError |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestQdrantDimensionMismatch: |
| """Test suite for Qdrant dimension mismatch handling.""" |
|
|
| def test_qdrant_dimension_mismatch_raises_error(self): |
| """ |
| Test that Qdrant raises DataMigrationError when dimensions don't match. |
| |
| Scenario: Legacy collection has 1536d vectors, new model expects 3072d. |
| Expected: DataMigrationError is raised to prevent data corruption. |
| """ |
| from qdrant_client import models |
|
|
| |
| client = MagicMock() |
|
|
| |
| legacy_collection_info = MagicMock() |
| legacy_collection_info.config.params.vectors.size = 1536 |
|
|
| |
| def collection_exists_side_effect(name): |
| if ( |
| name == "lightrag_vdb_chunks" |
| ): |
| return True |
| elif name == "lightrag_chunks_model_3072d": |
| return False |
| return False |
|
|
| client.collection_exists.side_effect = collection_exists_side_effect |
| client.get_collection.return_value = legacy_collection_info |
| client.count.return_value.count = 100 |
|
|
| |
| with patch( |
| "lightrag.kg.qdrant_impl._find_legacy_collection", |
| return_value="lightrag_vdb_chunks", |
| ): |
| |
| |
| with pytest.raises(DataMigrationError) as exc_info: |
| QdrantVectorDBStorage.setup_collection( |
| client, |
| "lightrag_chunks_model_3072d", |
| namespace="chunks", |
| workspace="test", |
| vectors_config=models.VectorParams( |
| size=3072, distance=models.Distance.COSINE |
| ), |
| hnsw_config=models.HnswConfigDiff( |
| payload_m=16, |
| m=0, |
| ), |
| model_suffix="model_3072d", |
| ) |
|
|
| |
| assert "3072" in str(exc_info.value) or "1536" in str(exc_info.value) |
|
|
| |
| client.create_collection.assert_not_called() |
|
|
| |
| client.scroll.assert_not_called() |
| client.upsert.assert_not_called() |
|
|
| def test_qdrant_dimension_match_proceed_migration(self): |
| """ |
| Test that Qdrant proceeds with migration when dimensions match. |
| |
| Scenario: Legacy collection has 1536d vectors, new model also expects 1536d. |
| Expected: Migration proceeds normally. |
| """ |
| from qdrant_client import models |
|
|
| client = MagicMock() |
|
|
| |
| legacy_collection_info = MagicMock() |
| legacy_collection_info.config.params.vectors.size = 1536 |
|
|
| def collection_exists_side_effect(name): |
| if name == "lightrag_chunks": |
| return True |
| elif name == "lightrag_chunks_model_1536d": |
| return False |
| return False |
|
|
| client.collection_exists.side_effect = collection_exists_side_effect |
| client.get_collection.return_value = legacy_collection_info |
|
|
| |
| migration_done = {"value": False} |
|
|
| def upsert_side_effect(*args, **kwargs): |
| migration_done["value"] = True |
| return MagicMock() |
|
|
| client.upsert.side_effect = upsert_side_effect |
|
|
| |
| |
| |
| def count_side_effect(collection_name, **kwargs): |
| result = MagicMock() |
| if collection_name == "lightrag_chunks": |
| result.count = 1 |
| elif collection_name == "lightrag_chunks_model_1536d": |
| |
| result.count = 1 if migration_done["value"] else 0 |
| else: |
| result.count = 0 |
| return result |
|
|
| client.count.side_effect = count_side_effect |
|
|
| |
| sample_point = MagicMock() |
| sample_point.id = "test_id" |
| sample_point.vector = [0.1] * 1536 |
| sample_point.payload = {"id": "test"} |
| client.scroll.return_value = ([sample_point], None) |
|
|
| |
| with patch( |
| "lightrag.kg.qdrant_impl._find_legacy_collection", |
| return_value="lightrag_chunks", |
| ): |
| |
| QdrantVectorDBStorage.setup_collection( |
| client, |
| "lightrag_chunks_model_1536d", |
| namespace="chunks", |
| workspace="test", |
| vectors_config=models.VectorParams( |
| size=1536, distance=models.Distance.COSINE |
| ), |
| hnsw_config=models.HnswConfigDiff( |
| payload_m=16, |
| m=0, |
| ), |
| model_suffix="model_1536d", |
| ) |
|
|
| |
| client.create_collection.assert_called_once() |
| client.scroll.assert_called() |
| client.upsert.assert_called() |
|
|
|
|
| class TestPostgresDimensionMismatch: |
| """Test suite for PostgreSQL dimension mismatch handling.""" |
|
|
| async def test_postgres_dimension_mismatch_raises_error_metadata(self): |
| """ |
| Test that PostgreSQL raises DataMigrationError when dimensions don't match. |
| |
| Scenario: Legacy table has 1536d vectors, new model expects 3072d. |
| Expected: DataMigrationError is raised to prevent data corruption. |
| """ |
| |
| db = AsyncMock() |
|
|
| |
| async def mock_check_table_exists(table_name): |
| if table_name == "LIGHTRAG_DOC_CHUNKS": |
| return True |
| elif table_name == "LIGHTRAG_DOC_CHUNKS_model_3072d": |
| return False |
| return False |
|
|
| db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists) |
|
|
| |
| async def query_side_effect(query, params, **kwargs): |
| if "COUNT(*)" in query: |
| return {"count": 100} |
| elif "SELECT content_vector FROM" in query: |
| |
| return {"content_vector": [0.1] * 1536} |
| return {} |
|
|
| db.query.side_effect = query_side_effect |
| db.execute = AsyncMock() |
| db._create_vector_index = AsyncMock() |
|
|
| |
| |
| with pytest.raises(DataMigrationError) as exc_info: |
| await PGVectorStorage.setup_table( |
| db, |
| "LIGHTRAG_DOC_CHUNKS_model_3072d", |
| legacy_table_name="LIGHTRAG_DOC_CHUNKS", |
| base_table="LIGHTRAG_DOC_CHUNKS", |
| embedding_dim=3072, |
| workspace="test", |
| ) |
|
|
| |
| assert "3072" in str(exc_info.value) or "1536" in str(exc_info.value) |
|
|
| async def test_postgres_dimension_mismatch_raises_error_sampling(self): |
| """ |
| Test that PostgreSQL raises error when dimensions don't match (via sampling). |
| |
| Scenario: Legacy table vector sampling detects 1536d vs expected 3072d. |
| Expected: DataMigrationError is raised to prevent data corruption. |
| """ |
| db = AsyncMock() |
|
|
| |
| async def mock_check_table_exists(table_name): |
| if table_name == "LIGHTRAG_DOC_CHUNKS": |
| return True |
| elif table_name == "LIGHTRAG_DOC_CHUNKS_model_3072d": |
| return False |
| return False |
|
|
| db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists) |
|
|
| |
| async def query_side_effect(query, params, **kwargs): |
| if "information_schema.tables" in query: |
| if params[0] == "LIGHTRAG_DOC_CHUNKS": |
| return {"exists": True} |
| elif params[0] == "LIGHTRAG_DOC_CHUNKS_model_3072d": |
| return {"exists": False} |
| elif "COUNT(*)" in query: |
| return {"count": 100} |
| elif "SELECT content_vector FROM" in query: |
| |
| return {"content_vector": json.dumps([0.1] * 1536)} |
| return {} |
|
|
| db.query.side_effect = query_side_effect |
| db.execute = AsyncMock() |
| db._create_vector_index = AsyncMock() |
|
|
| |
| |
| with pytest.raises(DataMigrationError) as exc_info: |
| await PGVectorStorage.setup_table( |
| db, |
| "LIGHTRAG_DOC_CHUNKS_model_3072d", |
| legacy_table_name="LIGHTRAG_DOC_CHUNKS", |
| base_table="LIGHTRAG_DOC_CHUNKS", |
| embedding_dim=3072, |
| workspace="test", |
| ) |
|
|
| |
| assert "3072" in str(exc_info.value) or "1536" in str(exc_info.value) |
|
|
| async def test_postgres_dimension_match_proceed_migration(self): |
| """ |
| Test that PostgreSQL proceeds with migration when dimensions match. |
| |
| Scenario: Legacy table has 1536d vectors, new model also expects 1536d. |
| Expected: Migration proceeds normally. |
| """ |
| db = AsyncMock() |
|
|
| |
| migration_done = {"value": False} |
|
|
| |
| mock_records = [ |
| { |
| "id": "test1", |
| "content_vector": [0.1] * 1536, |
| "workspace": "test", |
| }, |
| { |
| "id": "test2", |
| "content_vector": [0.2] * 1536, |
| "workspace": "test", |
| }, |
| ] |
|
|
| |
| async def mock_check_table_exists(table_name): |
| if table_name == "LIGHTRAG_DOC_CHUNKS": |
| return True |
| elif table_name == "LIGHTRAG_DOC_CHUNKS_model_1536d": |
| return False |
| return False |
|
|
| db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists) |
|
|
| async def query_side_effect(query, params, **kwargs): |
| multirows = kwargs.get("multirows", False) |
| query_upper = query.upper() |
|
|
| if "information_schema.tables" in query: |
| if params[0] == "LIGHTRAG_DOC_CHUNKS": |
| return {"exists": True} |
| elif params[0] == "LIGHTRAG_DOC_CHUNKS_model_1536d": |
| return {"exists": False} |
| elif "COUNT(*)" in query_upper: |
| |
| if "LIGHTRAG_DOC_CHUNKS_MODEL_1536D" in query_upper: |
| |
| return { |
| "count": len(mock_records) if migration_done["value"] else 0 |
| } |
| |
| return {"count": len(mock_records)} |
| elif "PG_ATTRIBUTE" in query_upper: |
| return {"vector_dim": 1536} |
| elif "SELECT" in query_upper and "FROM" in query_upper and multirows: |
| |
| |
| if "id >" in query.lower(): |
| |
| last_id = params[1] if len(params) > 1 else None |
| |
| found_idx = -1 |
| for i, rec in enumerate(mock_records): |
| if rec["id"] == last_id: |
| found_idx = i |
| break |
| if found_idx >= 0: |
| return mock_records[found_idx + 1 :] |
| return [] |
| else: |
| |
| return mock_records |
| return {} |
|
|
| db.query.side_effect = query_side_effect |
|
|
| |
| migration_executed = [] |
|
|
| async def mock_run_with_retry(operation, *args, **kwargs): |
| migration_executed.append(True) |
| migration_done["value"] = True |
| return None |
|
|
| db._run_with_retry = AsyncMock(side_effect=mock_run_with_retry) |
| db.execute = AsyncMock() |
| db._create_vector_index = AsyncMock() |
|
|
| |
| await PGVectorStorage.setup_table( |
| db, |
| "LIGHTRAG_DOC_CHUNKS_model_1536d", |
| legacy_table_name="LIGHTRAG_DOC_CHUNKS", |
| base_table="LIGHTRAG_DOC_CHUNKS", |
| embedding_dim=1536, |
| workspace="test", |
| ) |
|
|
| |
| assert len(migration_executed) > 0, "Migration should have been executed" |
|
|