diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 4b33fd0..f63f9e0 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -2,7 +2,7 @@ > **Note:** This document is automatically generated and verified against the live test suite by `scripts/generate_requirements.py` and `tests/backend/test_requirements_sync.py`. -**Test Verification Baseline:** **1115 Automated Tests** (757 Pytest Backend + 308 Vitest Frontend + 50 Playwright E2E). +**Test Verification Baseline:** **1120 Automated Tests** (762 Pytest Backend + 308 Vitest Frontend + 50 Playwright E2E). --- @@ -1155,12 +1155,19 @@ and leaves the prior indexed state intact without data loss._ - `test_scanned_pdf_cache_invalidation_on_replace_and_delete` - `test_real_asd_ste100_pdf_preview_or_indexing` +#### `tests/test_pgvector_native.py` (5 tests) +- `test_native_initialization_and_collection_isolation` +- `test_native_migrates_null_legacy_collection` +- `test_native_initialization_error_is_explicit_and_atomic` +- `test_native_dimension_mismatch_preserves_populated_table` +- `test_native_dimension_change_after_explicit_clear` + #### `tests/test_pgvector_store.py` (62 tests) - `TestPgVectorStoreImports::test_import_pgvector_store` - `TestPgVectorStoreImports::test_manager_supported_providers_includes_postgres_and_pgvector` - `TestPgVectorStoreLifecycle::test_initialization_defaults` - `TestPgVectorStoreLifecycle::test_ensure_collection_executes_ddl` -- `TestPgVectorStoreLifecycle::test_ensure_collection_handles_extension_permission_warning` +- `TestPgVectorStoreLifecycle::test_ensure_collection_extension_permissions` - `TestPgVectorStoreLifecycle::test_ensure_collection_failure` - `TestPgVectorStoreLifecycle::test_health_check_success` - `TestPgVectorStoreLifecycle::test_health_check_failure` @@ -1186,12 +1193,12 @@ and leaves the prior indexed state intact without data loss._ - `TestVectorStoreManagerPgVectorDispatch::test_test_connection_pgvector` - `TestPgVectorCollectionIsolation::test_collection_isolation_reads_writes_deletes` - _Test that collection_a writes/deletes do not affect collection_b in the same table._ - `TestPgVectorCollectionIsolation::test_migration_and_pk_update` - _Test schema migration for legacy table without collection_name column._ -- `TestPgVectorCollectionIsolation::test_dimension_mismatch_warning` - _Test logging warning when table dimension differs from store dimension._ +- `TestPgVectorCollectionIsolation::test_dimension_mismatch_requires_explicit_rebuild` - _A populated shared table cannot be silently recreated._ - `test_import_pgvector_store` - `test_manager_supported_providers_includes_postgres_and_pgvector` - `test_initialization_defaults` - `test_ensure_collection_executes_ddl` -- `test_ensure_collection_handles_extension_permission_warning` +- `test_ensure_collection_extension_permissions` - `test_ensure_collection_failure` - `test_health_check_success` - `test_health_check_failure` @@ -1217,7 +1224,7 @@ and leaves the prior indexed state intact without data loss._ - `test_test_connection_pgvector` - `test_collection_isolation_reads_writes_deletes` - _Test that collection_a writes/deletes do not affect collection_b in the same table._ - `test_migration_and_pk_update` - _Test schema migration for legacy table without collection_name column._ -- `test_dimension_mismatch_warning` - _Test logging warning when table dimension differs from store dimension._ +- `test_dimension_mismatch_requires_explicit_rebuild` - _A populated shared table cannot be silently recreated._ #### `tests/test_poller.py` (9 tests) - `test_check_all_auto_sync_repos_triggers_sync` diff --git a/app/services/vector_store/pgvector_store.py b/app/services/vector_store/pgvector_store.py index df11b78..f8074f6 100644 --- a/app/services/vector_store/pgvector_store.py +++ b/app/services/vector_store/pgvector_store.py @@ -49,38 +49,37 @@ def __init__( def ensure_collection(self) -> bool: """ Initializes vector extension, documents table schema, and HNSW indexes idempotently. + + Schema changes are atomic. Populated tables require an explicit rebuild before + changing dimensions, because all collections share the embedding column. """ try: - with self.engine.connect() as conn: - # 1. Enable pgvector extension - try: + with self.engine.connect() as conn, conn.begin(): + # An installed extension needs no CREATE privilege. + extension = conn.execute(text( + "SELECT 1 FROM pg_extension WHERE extname = 'vector'" + )).scalar() + if not extension: conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector;")) - conn.commit() - except Exception as ext_err: - logger.warning( - f"Could not automatically create 'vector' extension (may require superuser or already exist): {ext_err}" - ) - # Check existing table vector dimension - try: - check_dim_sql = f""" + # to_regclass returns NULL for a fresh table instead of aborting the transaction. + check_dim_sql = text(""" SELECT atttypmod FROM pg_attribute - WHERE attrelid = '{self.table_name}'::regclass AND attname = 'embedding'; - """ - row = conn.execute(text(check_dim_sql)).fetchone() - if row and row[0] is not None and row[0] > 0 and row[0] != self.dimension: - logger.warning( - f"PgVectorStore table '{self.table_name}' dimension mismatch: expected {self.dimension}d, found {row[0]}d. Recreating..." + WHERE attrelid = to_regclass(:table_name) AND attname = 'embedding'; + """) + row = conn.execute(check_dim_sql, {"table_name": self.table_name}).fetchone() + if row and row[0] is not None and row[0] > 0 and row[0] != self.dimension: + conn.execute(text(f"LOCK TABLE {self.table_name} IN ACCESS EXCLUSIVE MODE")) + if conn.execute(text(f"SELECT EXISTS (SELECT 1 FROM {self.table_name})")).scalar(): + raise ValueError( + f"Vector dimension mismatch for table '{self.table_name}': " + f"existing {row[0]}d, configured {self.dimension}d. " + "An explicit rebuild is required; all collections sharing this " + "table must be cleared before changing dimensions." ) - conn.execute(text(f"DROP TABLE IF EXISTS {self.table_name} CASCADE;")) - conn.commit() - try: - from app.services.database import clear_indexing_metadata - clear_indexing_metadata() - except Exception as cm_err: - logger.warning(f"Failed to clear indexing metadata during PgVector table recreation: {cm_err}") - except Exception: - pass + conn.execute(text( + f"ALTER TABLE {self.table_name} ALTER COLUMN embedding TYPE vector({self.dimension})" + )) # 2. Create vector documents table create_table_sql = f""" @@ -111,63 +110,33 @@ def ensure_collection(self) -> bool: """ conn.execute(text(create_table_sql)) - # Migration for existing tables: add collection_name column if missing and adjust PK constraint - try: - conn.execute(text( - f"ALTER TABLE {self.table_name} ADD COLUMN IF NOT EXISTS collection_name VARCHAR(255) DEFAULT 'knowledge_rag_v1';" - )) - conn.commit() - except Exception as alter_err: - logger.debug(f"Migration add collection_name column info: {alter_err}") - - # Ensure null collection_name values in legacy rows are updated - try: - conn.execute(text( - f"UPDATE {self.table_name} SET collection_name = 'knowledge_rag_v1' WHERE collection_name IS NULL;" - )) - conn.commit() - except Exception as update_err: - logger.debug(f"Migration update null collection_name info: {update_err}") - - # Check and update PK constraint if it was on id alone - try: - pk_check_sql = text(""" - SELECT a.attname - FROM pg_index i - JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) - WHERE i.indrelid = :table_name::regclass AND i.indisprimary; - """) - pk_cols = [row[0] for row in conn.execute(pk_check_sql, {"table_name": self.table_name}).fetchall()] - if pk_cols and "collection_name" not in pk_cols: - # Find constraint name + # Migrate legacy rows before replacing the old id-only primary key. + conn.execute(text( + f"ALTER TABLE {self.table_name} ADD COLUMN IF NOT EXISTS collection_name VARCHAR(255) DEFAULT 'knowledge_rag_v1';" + )) + conn.execute(text( + f"UPDATE {self.table_name} SET collection_name = 'knowledge_rag_v1' WHERE collection_name IS NULL;" + )) + + pk_check_sql = text(""" + SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = CAST(:table_name AS regclass) AND i.indisprimary; + """) + pk_cols = [row[0] for row in conn.execute(pk_check_sql, {"table_name": self.table_name}).fetchall()] + if set(pk_cols) != {"collection_name", "id"}: + if pk_cols: conname_sql = text(""" SELECT conname FROM pg_constraint - WHERE conrelid = :table_name::regclass AND contype = 'p'; + WHERE conrelid = CAST(:table_name AS regclass) AND contype = 'p'; """) conname_row = conn.execute(conname_sql, {"table_name": self.table_name}).fetchone() if conname_row: - conname = conname_row[0] + conname = self.engine.dialect.identifier_preparer.quote(conname_row[0]) conn.execute(text(f"ALTER TABLE {self.table_name} DROP CONSTRAINT {conname};")) - conn.execute(text(f"ALTER TABLE {self.table_name} ADD PRIMARY KEY (collection_name, id);")) - conn.commit() - except Exception as pk_err: - logger.debug(f"Migration update primary key info: {pk_err}") - - # Validate embedding dimension compatibility if table exists - try: - dim_check_sql = text(""" - SELECT atttypmod - FROM pg_attribute - WHERE attrelid = :table_name::regclass AND attname = 'embedding'; - """) - dim_row = conn.execute(dim_check_sql, {"table_name": self.table_name}).fetchone() - if dim_row and dim_row[0] > 0 and dim_row[0] != self.dimension: - logger.warning( - f"Table '{self.table_name}' embedding dimension ({dim_row[0]}) does not match configured dimension ({self.dimension})." - ) - except Exception as dim_err: - logger.debug(f"Embedding dimension validation info: {dim_err}") + conn.execute(text(f"ALTER TABLE {self.table_name} ADD PRIMARY KEY (collection_name, id);")) # 3. Create HNSW cosine index hnsw_index_sql = f""" @@ -182,12 +151,13 @@ def ensure_collection(self) -> bool: conn.execute(text(f"CREATE INDEX IF NOT EXISTS idx_{self.table_name}_type ON {self.table_name}(doc_type);")) conn.execute(text(f"CREATE INDEX IF NOT EXISTS idx_{self.table_name}_path ON {self.table_name}(path);")) - conn.commit() logger.info(f"PgVectorStore initialized table '{self.table_name}' with {self.dimension}d HNSW index.") return True + except ValueError: + raise except Exception as e: logger.error(f"Error initializing PgVectorStore table '{self.table_name}': {e}", exc_info=True) - return False + raise RuntimeError(f"Failed to initialize PgVectorStore table '{self.table_name}'.") from e def upsert_documents( self, diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index deef2fd..ac549ce 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -74,6 +74,30 @@ ChromaDB provides lightweight embedded vector storage without external services: export VECTOR_STORE_PROVIDER="chroma" ``` +### PostgreSQL / pgvector Mode + +Initialization creates or migrates the vector table and its indexes in one transaction. +Legacy rows without a collection are assigned to `knowledge_rag_v1`; the primary key +becomes `(collection_name, id)`, allowing the same document ID in separate collections. +Initialization failures raise an explicit error and roll back schema changes rather +than leaving a partially migrated store. If `vector` is already installed, startup +does not attempt extension creation; otherwise the database role needs permission +to install it (or an administrator must install it first). + +All collections in a table share one embedding dimension. Initialization never drops +a populated table to resolve a dimension mismatch. Coordinate an explicit rebuild +with every affected collection owner, clear their vectors and indexing metadata, +then initialize the empty table with the new dimension and reindex. Changing the +dimension of an empty table preserves its schema and does not clear unrelated +relational metadata. + +Native regression tests require `TEST_POSTGRES_URL` pointing to a **disposable** +PostgreSQL database with pgvector available. They create a unique schema per test +and remove only that schema; do not use the application database. Run +`python -m pytest tests/test_pgvector_native.py`. Without that variable, the native +tests skip. Search tests use a deterministic loopback HTTP embedding service; no +embedding model downloads are required. + --- ## LiteLLM Proxy Integration diff --git a/tests/test_pgvector_native.py b/tests/test_pgvector_native.py new file mode 100644 index 0000000..fc9f01c --- /dev/null +++ b/tests/test_pgvector_native.py @@ -0,0 +1,215 @@ +"""Native pgvector regressions; requires an explicit, disposable TEST_POSTGRES_URL.""" + +import json +import os +import threading +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest +from openai import OpenAI +from sqlalchemy import create_engine, text + +from app.services import embeddings +from app.services.database.engine import get_db_url +from app.services.vector_store.pgvector_store import PgVectorStore + + +@pytest.fixture +def native_engine(): + url = os.getenv("TEST_POSTGRES_URL") + if not url: + pytest.skip("Set TEST_POSTGRES_URL to a disposable PostgreSQL database with pgvector") + schema = f"pg54_{uuid.uuid4().hex}" + admin = create_engine(get_db_url(url), hide_parameters=True) + engine = None + try: + with admin.begin() as conn: + conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + conn.execute(text(f"CREATE SCHEMA {schema}")) + engine = create_engine( + get_db_url(url), + connect_args={"options": f"-csearch_path={schema},public"}, + hide_parameters=True, + ) + yield engine + finally: + if engine is not None: + engine.dispose() + with admin.begin() as conn: + conn.execute(text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) + admin.dispose() + + +@pytest.fixture +def native_embeddings(monkeypatch): + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + request = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + inputs = request["input"] + if isinstance(inputs, str): + inputs = [inputs] + body = json.dumps({ + "object": "list", + "model": "native-test", + "data": [ + {"object": "embedding", "index": i, + "embedding": [1.0, 0.0] if value == "wrong-size" else [1.0, 0.0, 0.0]} + for i, value in enumerate(inputs) + ], + "usage": {"prompt_tokens": 1, "total_tokens": 1}, + }).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + client = OpenAI(base_url=f"http://127.0.0.1:{server.server_port}/v1", api_key="native-test") + monkeypatch.setattr(embeddings, "EMBEDDING_PROVIDER", "api") + monkeypatch.setattr(embeddings, "DENSE_MODEL_NAME", "native-test") + monkeypatch.setattr(embeddings, "_openai_client", client) + try: + yield + finally: + client.close() + server.shutdown() + server.server_close() + thread.join() + + +def create_legacy_table(engine, dimension=3, with_collection=False): + collection = "collection_name VARCHAR(255)," if with_collection else "" + with engine.begin() as conn: + conn.execute(text(f""" + CREATE TABLE vector_documents ( + id VARCHAR(255) PRIMARY KEY, {collection} + repo VARCHAR(255), doc_type VARCHAR(50) DEFAULT 'doc', + path TEXT, rel_path TEXT, title TEXT, folder TEXT, category TEXT, + tags JSONB, heading TEXT, symbol TEXT, language VARCHAR(50), + start_line INTEGER, end_line INTEGER, github_url TEXT, + permalink_url TEXT, content TEXT, embedding vector({dimension}), + payload JSONB, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """)) + conn.execute(text(""" + INSERT INTO vector_documents (id, content, payload) + VALUES ('legacy', 'preserved', '{"content": "preserved"}') + """)) + + +def primary_key_columns(conn): + return conn.execute(text(""" + SELECT a.attname FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = to_regclass('vector_documents') AND i.indisprimary + ORDER BY a.attname + """)).scalars().all() + + +@pytest.mark.parametrize("legacy", [False, True]) +def test_native_initialization_and_collection_isolation(native_engine, native_embeddings, legacy): + if legacy: + create_legacy_table(native_engine) + a = PgVectorStore(engine=native_engine, collection_name="a", dimension=3) + b = PgVectorStore(engine=native_engine, collection_name="b", dimension=3) + assert a.ensure_collection() is True + assert a.health_check()[0] is True + with native_engine.connect() as conn: + assert primary_key_columns(conn) == ["collection_name", "id"] + assert conn.execute(text(""" + SELECT count(*) FROM pg_indexes + WHERE schemaname = current_schema() AND indexdef LIKE '%USING hnsw%' + """)).scalar_one() == 1 + if legacy: + assert conn.execute(text(""" + SELECT collection_name, content FROM vector_documents WHERE id = 'legacy' + """)).one() == ("knowledge_rag_v1", "preserved") + + for store, content, vector in [(a, "A", [1.0, 0.0, 0.0]), (b, "B", [0.0, 1.0, 0.0])]: + assert store.upsert_documents([{ + "id": "shared", "text": content, "dense_vector": vector, + "path": "same.py", "repo": "same", + }]) + assert a.upsert_documents([{ + "id": "shared", "text": "A updated", "dense_vector": [1.0, 0.0, 0.0], + "path": "same.py", "repo": "same", + }]) + assert a.get_stats()["points_count"] == b.get_stats()["points_count"] == 1 + result_a, result_b = a.search("horizontal"), b.search("horizontal") + assert [(r.id, r.payload["content"], r.score) for r in result_a] == [ + ("shared", "A updated", 1.0) + ] + assert [(r.id, r.payload["content"], r.score) for r in result_b] == [("shared", "B", 0.0)] + with pytest.raises(ValueError, match="Vector dimension mismatch"): + a.search("wrong-size") + + assert a.delete_by_path("same.py") + assert a.get_stats()["points_count"] == 0 + assert b.get_stats()["points_count"] == 1 + assert a.delete_by_repo("same") + assert a.delete_collection() + assert b.get_stats()["points_count"] == 1 + assert b.delete_by_repo("same") + assert b.get_stats()["points_count"] == 0 + assert b.upsert_documents([{"id": "again", "text": "B", "dense_vector": [0.0, 1.0, 0.0]}]) + assert b.delete_collection() + assert b.search("horizontal") == [] + + +def test_native_migrates_null_legacy_collection(native_engine): + create_legacy_table(native_engine, with_collection=True) + PgVectorStore(engine=native_engine, dimension=3) + with native_engine.connect() as conn: + assert conn.execute(text("SELECT collection_name FROM vector_documents")).scalar_one() == "knowledge_rag_v1" + assert primary_key_columns(conn) == ["collection_name", "id"] + + +@pytest.mark.parametrize("legacy", [False, True]) +def test_native_initialization_error_is_explicit_and_atomic(native_engine, legacy): + if legacy: + create_legacy_table(native_engine, dimension=2001) + with pytest.raises(RuntimeError, match="initializ") as error: + PgVectorStore(engine=native_engine, dimension=2001) + assert error.value.__cause__ is not None + with native_engine.connect() as conn: + assert conn.execute(text("SELECT 1")).scalar_one() == 1 + if legacy: + assert primary_key_columns(conn) == ["id"] + assert conn.execute(text(""" + SELECT count(*) FROM pg_attribute + WHERE attrelid = to_regclass('vector_documents') AND attname = 'collection_name' + """)).scalar_one() == 0 + assert conn.execute(text("SELECT content FROM vector_documents")).scalar_one() == "preserved" + else: + assert conn.execute(text("SELECT to_regclass('vector_documents')")).scalar_one() is None + + +@pytest.mark.parametrize("collection", ["a", "other"]) +def test_native_dimension_mismatch_preserves_populated_table(native_engine, collection): + owner = PgVectorStore(engine=native_engine, collection_name=collection, dimension=3) + assert owner.upsert_documents([{ + "id": "preserve", "text": "keep me", "dense_vector": [1.0, 0.0, 0.0], + }]) + with pytest.raises(ValueError, match="dimension.*rebuild"): + PgVectorStore(engine=native_engine, collection_name="a", dimension=2) + assert owner.get_stats()["points_count"] == 1 + with native_engine.connect() as conn: + assert conn.execute(text("SELECT content, embedding::text FROM vector_documents")).one() == ( + "keep me", "[1,0,0]" + ) + + +def test_native_dimension_change_after_explicit_clear(native_engine): + store = PgVectorStore(engine=native_engine, dimension=3) + assert store.upsert_documents([{"id": "old", "dense_vector": [1.0, 0.0, 0.0]}]) + assert store.clear_collection() + rebuilt = PgVectorStore(engine=native_engine, dimension=2) + assert rebuilt.upsert_documents([{"id": "new", "dense_vector": [1.0, 0.0]}]) + assert rebuilt.get_stats()["points_count"] == 1 diff --git a/tests/test_pgvector_store.py b/tests/test_pgvector_store.py index f232157..d1b75f8 100644 --- a/tests/test_pgvector_store.py +++ b/tests/test_pgvector_store.py @@ -5,12 +5,20 @@ import pytest from unittest.mock import MagicMock, patch, call from sqlalchemy import create_engine, text +from sqlalchemy.dialects import postgresql from app.services.vector_store.base import VectorStore, VectorDocument, VectorSearchResult from app.services.vector_store import manager as vs_manager from app.services.vector_store.manager import VectorStoreManager, SUPPORTED_PROVIDERS +@pytest.fixture(autouse=True) +def embedding_dimension(): + # These unit tests exercise the store, not an external embedding service. + with patch("app.services.vector_store.pgvector_store.get_dense_dim", return_value=384): + yield + + class TestPgVectorStoreImports: """Tests ensuring PgVectorStore is importable and registered.""" @@ -44,6 +52,9 @@ def test_ensure_collection_executes_ddl(self): mock_engine = MagicMock() mock_conn = MagicMock() mock_engine.connect.return_value.__enter__.return_value = mock_conn + mock_conn.execute.return_value.scalar.return_value = None + mock_conn.execute.return_value.fetchone.return_value = None + mock_conn.execute.return_value.fetchall.return_value = [("collection_name",), ("id",)] store = PgVectorStore(engine=mock_engine, auto_init=False) success = store.ensure_collection() @@ -58,7 +69,8 @@ def test_ensure_collection_executes_ddl(self): assert "hnsw" in full_sql.lower() assert "vector_cosine_ops" in full_sql - def test_ensure_collection_handles_extension_permission_warning(self): + @pytest.mark.parametrize("installed", [False, True]) + def test_ensure_collection_extension_permissions(self, installed): from app.services.vector_store.pgvector_store import PgVectorStore mock_engine = MagicMock() mock_conn = MagicMock() @@ -66,20 +78,30 @@ def test_ensure_collection_handles_extension_permission_warning(self): def execute_side_effect(statement, *args, **kwargs): if "CREATE EXTENSION" in str(statement): - raise Exception("permission denied to create extension") - return MagicMock() + raise PermissionError("permission denied to create extension") + result = MagicMock() + result.scalar.return_value = 1 if installed else None + result.fetchone.return_value = None + result.fetchall.return_value = [("collection_name",), ("id",)] + return result mock_conn.execute.side_effect = execute_side_effect store = PgVectorStore(engine=mock_engine, auto_init=False) - success = store.ensure_collection() - assert success is True + if installed: + assert store.ensure_collection() is True + else: + with pytest.raises(RuntimeError, match="initialize") as error: + store.ensure_collection() + assert isinstance(error.value.__cause__, PermissionError) def test_ensure_collection_failure(self): from app.services.vector_store.pgvector_store import PgVectorStore mock_engine = MagicMock() mock_engine.connect.side_effect = Exception("DB Connection Error") store = PgVectorStore(engine=mock_engine, auto_init=False) - assert store.ensure_collection() is False + with pytest.raises(RuntimeError, match="initialize") as error: + store.ensure_collection() + assert str(error.value.__cause__) == "DB Connection Error" def test_health_check_success(self): from app.services.vector_store.pgvector_store import PgVectorStore @@ -273,7 +295,7 @@ def test_upsert_failure_returns_false(self): mock_engine = MagicMock() mock_engine.connect.side_effect = Exception("Write error") store = PgVectorStore(engine=mock_engine, auto_init=False) - assert store.upsert_documents([VectorDocument(id="1", text="abc")]) is False + assert store.upsert_documents([VectorDocument(id="1", text="abc", dense_vector=[0.1] * 384)]) is False def test_search_executes_cosine_distance_query(self): from app.services.vector_store.pgvector_store import PgVectorStore @@ -519,9 +541,9 @@ def test_migration_and_pk_update(self): mock_conn = MagicMock() mock_engine.connect.return_value.__enter__.return_value = mock_conn - # Mock primary key check returning ['id'] + mock_engine.dialect = postgresql.dialect() mock_conn.execute.return_value.fetchall.return_value = [("id",)] - mock_conn.execute.return_value.fetchone.return_value = ("vector_documents_pkey",) + mock_conn.execute.return_value.fetchone.side_effect = [(384,), ("vector_documents_pkey",)] store = PgVectorStore(engine=mock_engine, collection_name="collection_test", auto_init=False) success = store.ensure_collection() @@ -535,8 +557,8 @@ def test_migration_and_pk_update(self): assert "ALTER TABLE vector_documents DROP CONSTRAINT vector_documents_pkey" in full_sql assert "ALTER TABLE vector_documents ADD PRIMARY KEY (collection_name, id)" in full_sql - def test_dimension_mismatch_warning(self): - """Test logging warning when table dimension differs from store dimension.""" + def test_dimension_mismatch_requires_explicit_rebuild(self): + """A populated shared table cannot be silently recreated.""" from app.services.vector_store.pgvector_store import PgVectorStore mock_engine = MagicMock() @@ -548,8 +570,5 @@ def test_dimension_mismatch_warning(self): mock_conn.execute.return_value.fetchone.return_value = (1536,) store = PgVectorStore(engine=mock_engine, dimension=384, auto_init=False) - with patch("app.services.vector_store.pgvector_store.logger.warning") as mock_warn: + with pytest.raises(ValueError, match="dimension mismatch.*explicit rebuild"): store.ensure_collection() - mock_warn.assert_called() - warning_msg = mock_warn.call_args[0][0] - assert "embedding dimension (1536) does not match configured dimension (384)" in warning_msg