* feat: introduce hindsight-api-slim and hindsight-all-slim packages Closes #552 - Move all source code from hindsight-api/ to new hindsight-api-slim/ - hindsight-api-slim has heavy ML deps (torch, sentence-transformers, transformers, einops, flashrank, mlx, mlx-lm, safetensors) and pg0-embedded as optional extras: [local-ml], [embedded-db], [all] - hindsight-api becomes a zero-code meta-package depending on hindsight-api-slim[all] for full backward compatibility - Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed - hindsight-all updated to depend on hindsight-api-slim[all] - pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db] - Dockerfile: replace sed hack with proper uv sync --extra flags - Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and all path references throughout the repo * refactor: rename hindsight/ directory to hindsight-all/ * docs: document hindsight-api-slim and hindsight-all-slim package variants Add package variants table and extras explanation to installation.md * docs: remove emojis from installation.md, use professional tone * docs: link Docker slim variant to pip package variants section * docs: consolidate Docker image variants into single table * ci: fix working-directory paths after package restructure - Replace all hindsight-api → hindsight-api-slim in test.yml - Replace hindsight → hindsight-all in test.yml - Add --extra embedded-db to test-embed API install step * ci: add local-ml and embedded-db extras to API sync steps These extras were previously implicit in the old hindsight-api package (which bundled everything). Now that hindsight-api-slim uses optional extras, we must explicitly request local-ml and embedded-db in CI. * ci: add API install step with embedded-db to test-embed smoke test The smoke test starts hindsight-api as a daemon, which requires pg0-embedded. Add a dedicated install step for hindsight-api-slim with embedded-db extra so the daemon can start successfully. * ci: remove --no-install-project when using optional extras When --no-install-project is combined with --extra, the optional deps are not installed because extras require the project to be active. Remove --no-install-project from steps that need local-ml or embedded-db. * ci: fix ordering of uv sync steps to preserve optional extras When uv sync runs for a different workspace member, it removes optional extras installed for other members. Fix by always running extra-requiring API sync last, after other workspace member syncs. Also remove --no-install-project from embedded-db sync in test-embed, as --no-install-project prevents optional extras from being active. * ci: add local-ml extra to test-embed API install for smoke test The smoke test starts the full API server which needs sentence-transformers for local embeddings (default provider). Add local-ml extra to the install. * ci: simplify extras with --all-extras and add slim pip smoke test - Replace explicit --extra local-ml --extra embedded-db with --all-extras for cleaner, more maintainable sync steps - Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without local ML models, using Cohere for embeddings/reranking (mirrors Docker slim smoke test approach) * ci: simplify slim smoke test to health check only (mirrors Docker test)
1092 lines
48 KiB
Python
1092 lines
48 KiB
Python
"""
|
|
Database migration management using Alembic.
|
|
|
|
This module provides programmatic access to run database migrations
|
|
on application startup. It is designed to be safe for concurrent
|
|
execution using PostgreSQL advisory locks to coordinate between
|
|
distributed workers.
|
|
|
|
Supports multi-tenant schema isolation: migrations can target a specific
|
|
PostgreSQL schema, allowing each tenant to have isolated tables.
|
|
|
|
Important: All migrations must be backward-compatible to allow
|
|
safe rolling deployments.
|
|
|
|
No alembic.ini required - all configuration is done programmatically.
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
from alembic.script.revision import ResolutionError
|
|
from sqlalchemy import Connection, create_engine, text
|
|
|
|
from .utils import mask_network_location
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Advisory lock ID for migrations (arbitrary unique number)
|
|
MIGRATION_LOCK_ID = 123456789
|
|
|
|
# Alembic's command.upgrade() is NOT thread-safe: it uses module-level global
|
|
# proxies (context._proxy, script) that get overwritten when two threads call
|
|
# upgrade() concurrently. This causes migrations to target the wrong schema
|
|
# and crash with "relation already exists" or KeyError: 'script'.
|
|
# Serialize all Alembic invocations with a process-level lock.
|
|
_alembic_lock = threading.Lock()
|
|
|
|
|
|
def _detect_vector_extension(conn, vector_extension: str = "pgvector") -> str:
|
|
"""
|
|
Validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
|
|
|
|
Args:
|
|
conn: SQLAlchemy connection object
|
|
vector_extension: Configured extension ("pgvector", "vchord", or "pgvectorscale")
|
|
|
|
Returns:
|
|
"pgvector", "vchord", "pgvectorscale", or "pg_diskann"
|
|
|
|
Raises:
|
|
RuntimeError: If configured extension is not installed
|
|
"""
|
|
# Verify the configured extension is installed
|
|
if vector_extension == "pgvectorscale":
|
|
# pgvectorscale/DiskANN requires pgvector to be installed first
|
|
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
|
|
if not pgvector_check:
|
|
raise RuntimeError(
|
|
"DiskANN (pgvectorscale/pg_diskann) requires pgvector to be installed. "
|
|
"Install it with: CREATE EXTENSION vector; then CREATE EXTENSION vectorscale CASCADE; (or pg_diskann on Azure)"
|
|
)
|
|
|
|
# Check for either vectorscale (open source) or pg_diskann (Azure)
|
|
vectorscale_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")).scalar()
|
|
pg_diskann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_diskann'")).scalar()
|
|
|
|
if vectorscale_check:
|
|
logger.debug("Using vector extension: pgvectorscale (DiskANN)")
|
|
return "pgvectorscale"
|
|
elif pg_diskann_check:
|
|
logger.debug("Using vector extension: pg_diskann (Azure DiskANN)")
|
|
return "pg_diskann" # Return distinct name for parameter handling
|
|
else:
|
|
raise RuntimeError(
|
|
"Configured vector extension 'pgvectorscale' not found. "
|
|
"Install either:\n"
|
|
" - pgvectorscale (open source): CREATE EXTENSION vectorscale CASCADE;\n"
|
|
" - pg_diskann (Azure): CREATE EXTENSION pg_diskann CASCADE;"
|
|
)
|
|
elif vector_extension == "vchord":
|
|
vchord_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord'")).scalar()
|
|
if not vchord_check:
|
|
raise RuntimeError(
|
|
"Configured vector extension 'vchord' not found. Install it with: CREATE EXTENSION vchord CASCADE;"
|
|
)
|
|
logger.debug("Using configured vector extension: vchord")
|
|
return "vchord"
|
|
elif vector_extension == "pgvector":
|
|
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
|
|
if not pgvector_check:
|
|
raise RuntimeError(
|
|
"Configured vector extension 'pgvector' not found. Install it with: CREATE EXTENSION vector;"
|
|
)
|
|
logger.debug("Using configured vector extension: pgvector")
|
|
return "pgvector"
|
|
else:
|
|
raise ValueError(
|
|
f"Invalid vector_extension: {vector_extension}. Must be 'pgvector', 'vchord', or 'pgvectorscale'"
|
|
)
|
|
|
|
|
|
def _get_schema_lock_id(schema: str) -> int:
|
|
"""
|
|
Generate a unique advisory lock ID for a schema.
|
|
|
|
Uses hash of schema name to create a deterministic lock ID.
|
|
"""
|
|
# Use hash to create a unique lock ID per schema
|
|
# Keep within PostgreSQL's bigint range
|
|
hash_bytes = hashlib.sha256(schema.encode()).digest()[:8]
|
|
return int.from_bytes(hash_bytes, byteorder="big") % (2**31)
|
|
|
|
|
|
def _run_migrations_internal(database_url: str, script_location: str, schema: str | None = None) -> None:
|
|
"""
|
|
Internal function to run migrations without locking.
|
|
|
|
Args:
|
|
database_url: SQLAlchemy database URL
|
|
script_location: Path to alembic scripts
|
|
schema: Target schema (None for default/public)
|
|
"""
|
|
schema_name = schema or "public"
|
|
logger.info(f"Running database migrations to head for schema '{schema_name}'...")
|
|
logger.info(f"Database URL: {mask_network_location(database_url)}")
|
|
logger.info(f"Script location: {script_location}")
|
|
|
|
# Create Alembic configuration programmatically (no alembic.ini needed)
|
|
alembic_cfg = Config()
|
|
|
|
# Set the script location (where alembic versions are stored)
|
|
alembic_cfg.set_main_option("script_location", script_location)
|
|
|
|
# Set the database URL
|
|
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
|
|
|
|
# Configure logging (optional, but helps with debugging)
|
|
# Uses Python's logging system instead of alembic.ini
|
|
alembic_cfg.set_main_option("prepend_sys_path", ".")
|
|
|
|
# Set path_separator to avoid deprecation warning
|
|
alembic_cfg.set_main_option("path_separator", "os")
|
|
|
|
# If targeting a specific schema, pass it to env.py via config
|
|
# env.py will handle setting search_path and version_table_schema
|
|
if schema:
|
|
alembic_cfg.set_main_option("target_schema", schema)
|
|
|
|
# Run migrations under a process-level lock. Alembic uses module-level
|
|
# global proxies that are not thread-safe, so concurrent command.upgrade()
|
|
# calls from different threads corrupt each other's context.
|
|
try:
|
|
with _alembic_lock:
|
|
command.upgrade(alembic_cfg, "head")
|
|
except ResolutionError as e:
|
|
# This happens during rolling deployments when a newer version of the code
|
|
# has already run migrations, and this older replica doesn't have the new
|
|
# migration files. The database is already at a newer revision than we know.
|
|
# This is safe to ignore - the newer code has already applied its migrations.
|
|
logger.warning(
|
|
f"Database is at a newer migration revision than this code version knows about. "
|
|
f"This is expected during rolling deployments. Skipping migrations. Error: {e}"
|
|
)
|
|
return
|
|
|
|
logger.info(f"Database migrations completed successfully for schema '{schema_name}'")
|
|
|
|
|
|
def run_migrations(
|
|
database_url: str,
|
|
script_location: str | None = None,
|
|
schema: str | None = None,
|
|
) -> None:
|
|
"""
|
|
Run database migrations to the latest version using programmatic Alembic configuration.
|
|
|
|
This function is safe to call from multiple distributed workers simultaneously:
|
|
- Uses PostgreSQL advisory lock to ensure only one worker runs migrations at a time
|
|
- Other workers wait for the lock, then verify migrations are complete
|
|
- If schema is already up-to-date, this is a fast no-op
|
|
|
|
Supports multi-tenant schema isolation: when a schema is specified, migrations
|
|
run in that schema instead of public. This allows tenant extensions to provision
|
|
new tenant schemas with their own isolated tables.
|
|
|
|
Args:
|
|
database_url: SQLAlchemy database URL (e.g., "postgresql://user:pass@host/db")
|
|
script_location: Path to alembic migrations directory (e.g., "/path/to/alembic").
|
|
If None, defaults to hindsight-api/alembic directory.
|
|
schema: Target PostgreSQL schema name. If None, uses default (public).
|
|
When specified, creates the schema if needed and runs migrations there.
|
|
|
|
Raises:
|
|
RuntimeError: If migrations fail to complete
|
|
FileNotFoundError: If script_location doesn't exist
|
|
|
|
Example:
|
|
# Using default location and public schema
|
|
run_migrations("postgresql://user:pass@host/db")
|
|
|
|
# Run migrations for a specific tenant schema
|
|
run_migrations("postgresql://user:pass@host/db", schema="tenant_acme")
|
|
|
|
# Using custom location (when importing from another project)
|
|
run_migrations(
|
|
"postgresql://user:pass@host/db",
|
|
script_location="/path/to/copied/_alembic"
|
|
)
|
|
"""
|
|
try:
|
|
# Determine script location
|
|
if script_location is None:
|
|
# Default: use the alembic directory inside the hindsight_api package
|
|
# This file is in: hindsight_api/migrations.py
|
|
# Alembic is in: hindsight_api/alembic/
|
|
package_dir = Path(__file__).parent
|
|
script_location = str(package_dir / "alembic")
|
|
|
|
script_path = Path(script_location)
|
|
if not script_path.exists():
|
|
raise FileNotFoundError(
|
|
f"Alembic script location not found at {script_location}. Database migrations cannot be run."
|
|
)
|
|
|
|
# Use schema-specific lock ID for multi-tenant isolation
|
|
lock_id = _get_schema_lock_id(schema) if schema else MIGRATION_LOCK_ID
|
|
schema_name = schema or "public"
|
|
|
|
# Use PostgreSQL advisory lock to coordinate between distributed workers.
|
|
#
|
|
# IMPORTANT: We must avoid holding an open transaction on the advisory-lock
|
|
# connection while CREATE INDEX CONCURRENTLY runs inside a migration.
|
|
# CONCURRENTLY waits for ALL active transactions to finish before the index
|
|
# becomes valid. If the advisory-lock connection (or any waiting worker's
|
|
# connection) holds an open transaction, CONCURRENTLY deadlocks:
|
|
# - migration worker waits for other workers' transactions to close
|
|
# - other workers wait for the advisory lock to be released
|
|
#
|
|
# Fix:
|
|
# 1. Use pg_try_advisory_lock (non-blocking) in a poll loop instead of
|
|
# blocking pg_advisory_lock, so we can COMMIT the transaction between
|
|
# retries. Between retries the connection holds no open transaction.
|
|
# 2. After acquiring the lock, COMMIT the transaction on the advisory-lock
|
|
# connection itself before running migrations. pg_advisory_lock is
|
|
# session-level, so the lock survives the COMMIT.
|
|
engine = create_engine(database_url)
|
|
with engine.connect() as conn:
|
|
logger.debug(f"Acquiring migration advisory lock for schema '{schema_name}' (id={lock_id})...")
|
|
while True:
|
|
acquired = conn.execute(text(f"SELECT pg_try_advisory_lock({lock_id})")).scalar()
|
|
if acquired:
|
|
break
|
|
# Commit the transaction so this connection holds no open snapshot
|
|
# while waiting. This prevents blocking CREATE INDEX CONCURRENTLY
|
|
# that may be running in the migration worker.
|
|
conn.commit()
|
|
time.sleep(0.5)
|
|
|
|
# Commit AFTER acquiring the lock too. pg_advisory_lock is session-level
|
|
# and survives the COMMIT, but the open transaction on this connection
|
|
# would otherwise block any CREATE INDEX CONCURRENTLY in the migration.
|
|
conn.commit()
|
|
logger.debug("Migration advisory lock acquired")
|
|
|
|
try:
|
|
# Ensure pgvector extension is installed globally BEFORE schema migrations
|
|
# This is critical: the extension must exist database-wide before any schema
|
|
# migrations run, otherwise custom schemas won't have access to vector types
|
|
logger.debug("Checking pgvector extension availability...")
|
|
|
|
# First, check if extension already exists
|
|
ext_check = conn.execute(
|
|
text(
|
|
"SELECT extname, nspname FROM pg_extension e "
|
|
"JOIN pg_namespace n ON e.extnamespace = n.oid "
|
|
"WHERE extname = 'vector'"
|
|
)
|
|
).fetchone()
|
|
|
|
if ext_check:
|
|
# Extension exists - check if in correct schema
|
|
ext_schema = ext_check[1]
|
|
if ext_schema == "public":
|
|
logger.info("pgvector extension found in public schema - ready to use")
|
|
else:
|
|
# Extension in wrong schema - try to fix if we have permissions
|
|
logger.warning(
|
|
f"pgvector extension found in schema '{ext_schema}' instead of 'public'. "
|
|
f"Attempting to relocate..."
|
|
)
|
|
try:
|
|
conn.execute(text("DROP EXTENSION vector CASCADE"))
|
|
conn.execute(text("SET search_path TO public"))
|
|
conn.execute(text("CREATE EXTENSION vector"))
|
|
conn.commit()
|
|
logger.info("pgvector extension relocated to public schema")
|
|
except Exception as e:
|
|
# Failed to relocate - log but don't fail if extension exists somewhere
|
|
logger.warning(
|
|
f"Could not relocate pgvector extension to public schema: {e}. "
|
|
f"Continuing with extension in '{ext_schema}' schema."
|
|
)
|
|
conn.rollback()
|
|
else:
|
|
# Extension doesn't exist - try to install
|
|
logger.info("pgvector extension not found, attempting to install...")
|
|
try:
|
|
conn.execute(text("SET search_path TO public"))
|
|
conn.execute(text("CREATE EXTENSION vector"))
|
|
conn.commit()
|
|
logger.info("pgvector extension installed in public schema")
|
|
except Exception as e:
|
|
# Installation failed - this is only fatal if extension truly doesn't exist
|
|
# Check one more time in case another process installed it
|
|
conn.rollback()
|
|
ext_recheck = conn.execute(
|
|
text(
|
|
"SELECT nspname FROM pg_extension e "
|
|
"JOIN pg_namespace n ON e.extnamespace = n.oid "
|
|
"WHERE extname = 'vector'"
|
|
)
|
|
).fetchone()
|
|
|
|
if ext_recheck:
|
|
logger.warning(
|
|
f"Could not install pgvector extension (permission denied?), "
|
|
f"but extension exists in '{ext_recheck[0]}' schema. Continuing..."
|
|
)
|
|
else:
|
|
# Extension truly doesn't exist and we can't install it
|
|
logger.error(
|
|
f"pgvector extension is not installed and cannot be installed: {e}. "
|
|
f"Please ensure pgvector is installed by a database administrator. "
|
|
f"See: https://github.com/pgvector/pgvector#installation"
|
|
)
|
|
raise RuntimeError(
|
|
"pgvector extension is required but not installed. "
|
|
"Please install it with: CREATE EXTENSION vector;"
|
|
) from e
|
|
|
|
# If using pgvectorscale, ensure vectorscale extension is also installed
|
|
vector_extension = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
|
|
if vector_extension == "pgvectorscale":
|
|
logger.debug("Checking pgvectorscale (vectorscale) extension availability...")
|
|
|
|
vectorscale_check = conn.execute(
|
|
text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")
|
|
).scalar()
|
|
|
|
if vectorscale_check:
|
|
logger.info("pgvectorscale extension already installed")
|
|
else:
|
|
# Extension doesn't exist - try to install
|
|
logger.info("pgvectorscale extension not found, attempting to install...")
|
|
try:
|
|
conn.execute(text("CREATE EXTENSION vectorscale CASCADE"))
|
|
conn.commit()
|
|
logger.info("pgvectorscale extension installed successfully")
|
|
except Exception as e:
|
|
# Installation failed - check one more time in case another process installed it
|
|
conn.rollback()
|
|
vectorscale_recheck = conn.execute(
|
|
text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")
|
|
).fetchone()
|
|
|
|
if vectorscale_recheck:
|
|
logger.warning(
|
|
"Could not install pgvectorscale extension (permission denied?), "
|
|
"but extension exists. Continuing..."
|
|
)
|
|
else:
|
|
# Extension truly doesn't exist and we can't install it
|
|
logger.error(
|
|
f"pgvectorscale extension is not installed and cannot be installed: {e}. "
|
|
f"Please ensure pgvectorscale is installed by a database administrator. "
|
|
f"See: https://github.com/timescale/pgvectorscale#installation"
|
|
)
|
|
raise RuntimeError(
|
|
"pgvectorscale extension is required but not installed. "
|
|
"Please install it with: CREATE EXTENSION vectorscale CASCADE;"
|
|
) from e
|
|
|
|
# Commit any pending transaction on the advisory-lock connection
|
|
# before running migrations. Some code paths above (e.g., the
|
|
# pgvector extension check) may have started a transaction via
|
|
# SQLAlchemy's autobegin. If we leave it open, CREATE INDEX
|
|
# CONCURRENTLY inside a migration will deadlock waiting for it.
|
|
conn.commit()
|
|
|
|
# Run migrations while holding the lock
|
|
_run_migrations_internal(database_url, script_location, schema=schema)
|
|
finally:
|
|
# Explicitly release the lock (also released on connection close)
|
|
conn.execute(text(f"SELECT pg_advisory_unlock({lock_id})"))
|
|
logger.debug("Migration advisory lock released")
|
|
|
|
except FileNotFoundError:
|
|
logger.error(f"Alembic script location not found at {script_location}")
|
|
raise
|
|
except SystemExit as e:
|
|
# Catch sys.exit() calls from Alembic
|
|
logger.error(f"Alembic called sys.exit() with code: {e.code}", exc_info=True)
|
|
raise RuntimeError(f"Database migration failed with exit code {e.code}") from e
|
|
except Exception as e:
|
|
logger.error(f"Failed to run database migrations: {e}", exc_info=True)
|
|
raise RuntimeError("Database migration failed") from e
|
|
|
|
|
|
def check_migration_status(
|
|
database_url: str | None = None, script_location: str | None = None
|
|
) -> tuple[str | None, str | None]:
|
|
"""
|
|
Check current database schema version and latest available version.
|
|
|
|
Args:
|
|
database_url: SQLAlchemy database URL. If None, uses HINDSIGHT_API_DATABASE_URL env var.
|
|
script_location: Path to alembic migrations directory. If None, uses default location.
|
|
|
|
Returns:
|
|
Tuple of (current_revision, head_revision)
|
|
Returns (None, None) if unable to determine versions
|
|
"""
|
|
try:
|
|
from alembic.runtime.migration import MigrationContext
|
|
from alembic.script import ScriptDirectory
|
|
from sqlalchemy import create_engine
|
|
|
|
# Get database URL
|
|
if database_url is None:
|
|
database_url = os.getenv("HINDSIGHT_API_DATABASE_URL")
|
|
if not database_url:
|
|
logger.warning(
|
|
"Database URL not provided and HINDSIGHT_API_DATABASE_URL not set, cannot check migration status"
|
|
)
|
|
return None, None
|
|
|
|
# Get current revision from database
|
|
engine = create_engine(database_url)
|
|
with engine.connect() as connection:
|
|
context = MigrationContext.configure(connection)
|
|
current_rev = context.get_current_revision()
|
|
|
|
# Get head revision from migration scripts
|
|
if script_location is None:
|
|
package_dir = Path(__file__).parent
|
|
script_location = str(package_dir / "alembic")
|
|
|
|
script_path = Path(script_location)
|
|
if not script_path.exists():
|
|
logger.warning(f"Script location not found at {script_location}")
|
|
return current_rev, None
|
|
|
|
# Create config programmatically
|
|
alembic_cfg = Config()
|
|
alembic_cfg.set_main_option("script_location", script_location)
|
|
alembic_cfg.set_main_option("path_separator", "os")
|
|
|
|
script = ScriptDirectory.from_config(alembic_cfg)
|
|
head_rev = script.get_current_head()
|
|
|
|
return current_rev, head_rev
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Unable to check migration status: {e}")
|
|
return None, None
|
|
|
|
|
|
def _migrate_table_embedding_dimension(
|
|
conn: Connection,
|
|
schema_name: str,
|
|
table_name: str,
|
|
required_dimension: int,
|
|
vector_ext: str,
|
|
) -> None:
|
|
"""
|
|
Migrate the embedding column of a single table to the required dimension.
|
|
|
|
- If dimensions match: no action needed
|
|
- If dimensions differ and table is empty: ALTER COLUMN to new dimension
|
|
- If dimensions differ and table has data: raise error with migration guidance
|
|
"""
|
|
current_dim = conn.execute(
|
|
text("""
|
|
SELECT atttypmod
|
|
FROM pg_attribute a
|
|
JOIN pg_class c ON a.attrelid = c.oid
|
|
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
WHERE n.nspname = :schema
|
|
AND c.relname = :table
|
|
AND a.attname = 'embedding'
|
|
"""),
|
|
{"schema": schema_name, "table": table_name},
|
|
).scalar()
|
|
|
|
if current_dim is None:
|
|
logger.debug(f"No embedding column found on {table_name}, skipping")
|
|
return
|
|
|
|
if current_dim == required_dimension:
|
|
logger.debug(f"Embedding dimension OK for {table_name}: {current_dim}")
|
|
return
|
|
|
|
logger.info(
|
|
f"Embedding dimension mismatch on {table_name}: database has {current_dim}, model requires {required_dimension}"
|
|
)
|
|
|
|
row_count = conn.execute(
|
|
text(f"SELECT COUNT(*) FROM {schema_name}.{table_name} WHERE embedding IS NOT NULL")
|
|
).scalar()
|
|
|
|
if row_count > 0:
|
|
raise RuntimeError(
|
|
f"Cannot change embedding dimension from {current_dim} to {required_dimension}: "
|
|
f"{table_name} table contains {row_count} rows with embeddings. "
|
|
f"To change dimensions, you must either:\n"
|
|
f" 1. Re-embed all data: DELETE FROM {schema_name}.{table_name}; then restart\n"
|
|
f" 2. Use a model with {current_dim}-dimensional embeddings"
|
|
)
|
|
|
|
logger.info(f"Altering {table_name}.embedding column dimension from {current_dim} to {required_dimension}")
|
|
|
|
# Drop existing vector index (works for both HNSW and vchordrq)
|
|
conn.execute(
|
|
text(f"""
|
|
DO $$
|
|
DECLARE idx_name TEXT;
|
|
BEGIN
|
|
FOR idx_name IN
|
|
SELECT indexname FROM pg_indexes
|
|
WHERE schemaname = '{schema_name}'
|
|
AND tablename = '{table_name}'
|
|
AND (indexdef LIKE '%hnsw%' OR indexdef LIKE '%vchordrq%' OR indexdef LIKE '%diskann%')
|
|
AND indexdef LIKE '%embedding%'
|
|
LOOP
|
|
EXECUTE 'DROP INDEX IF EXISTS {schema_name}.' || idx_name;
|
|
END LOOP;
|
|
END $$;
|
|
""")
|
|
)
|
|
|
|
conn.execute(
|
|
text(f"ALTER TABLE {schema_name}.{table_name} ALTER COLUMN embedding TYPE vector({required_dimension})")
|
|
)
|
|
conn.commit()
|
|
|
|
# Recreate index with appropriate type based on detected extension
|
|
if vector_ext == "pgvectorscale":
|
|
conn.execute(
|
|
text(f"""
|
|
CREATE INDEX IF NOT EXISTS idx_{table_name}_embedding_diskann
|
|
ON {schema_name}.{table_name}
|
|
USING diskann (embedding vector_cosine_ops)
|
|
WITH (num_neighbors = 50)
|
|
""")
|
|
)
|
|
logger.info(f"Created DiskANN index on {table_name} for {required_dimension}-dimensional embeddings")
|
|
elif vector_ext == "vchord":
|
|
conn.execute(
|
|
text(f"""
|
|
CREATE INDEX IF NOT EXISTS idx_{table_name}_embedding_vchordrq
|
|
ON {schema_name}.{table_name}
|
|
USING vchordrq (embedding vector_l2_ops)
|
|
""")
|
|
)
|
|
logger.info(f"Created vchordrq index on {table_name} for {required_dimension}-dimensional embeddings")
|
|
else: # pgvector
|
|
if required_dimension > 2000:
|
|
raise RuntimeError(
|
|
f"Embedding dimension {required_dimension} exceeds pgvector HNSW index limit of 2000. "
|
|
f"Use an embedding model with <= 2000 dimensions, or switch to a vector extension "
|
|
f"that supports higher dimensions (e.g., pgvectorscale/DiskANN)."
|
|
)
|
|
conn.execute(
|
|
text(f"""
|
|
CREATE INDEX IF NOT EXISTS idx_{table_name}_embedding_hnsw
|
|
ON {schema_name}.{table_name}
|
|
USING hnsw (embedding vector_cosine_ops)
|
|
WITH (m = 16, ef_construction = 64)
|
|
""")
|
|
)
|
|
logger.info(f"Created HNSW index on {table_name} for {required_dimension}-dimensional embeddings")
|
|
conn.commit()
|
|
|
|
logger.info(f"Successfully changed {table_name}.embedding dimension to {required_dimension}")
|
|
|
|
|
|
def ensure_embedding_dimension(
|
|
database_url: str,
|
|
required_dimension: int,
|
|
schema: str | None = None,
|
|
vector_extension: str = "pgvector",
|
|
) -> None:
|
|
"""
|
|
Ensure the embedding column dimension matches the model's dimension for all tables.
|
|
|
|
Checks and adjusts memory_units.embedding and mental_models.embedding:
|
|
- If dimensions match: no action needed
|
|
- If dimensions differ and table is empty: ALTER COLUMN to new dimension
|
|
- If dimensions differ and table has data: raise error with migration guidance
|
|
|
|
Args:
|
|
database_url: SQLAlchemy database URL
|
|
required_dimension: The embedding dimension required by the model
|
|
schema: Target PostgreSQL schema name (None for public)
|
|
vector_extension: Configured vector extension ("pgvector" or "vchord")
|
|
|
|
Raises:
|
|
RuntimeError: If dimension mismatch with existing data
|
|
"""
|
|
schema_name = schema or "public"
|
|
|
|
engine = create_engine(database_url)
|
|
with engine.connect() as conn:
|
|
# Check if memory_units table exists (proxy for schema being initialized)
|
|
table_exists = conn.execute(
|
|
text("""
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = :schema AND table_name = 'memory_units'
|
|
)
|
|
"""),
|
|
{"schema": schema_name},
|
|
).scalar()
|
|
|
|
if not table_exists:
|
|
logger.debug(f"memory_units table does not exist in schema '{schema_name}', skipping dimension check")
|
|
return
|
|
|
|
# Detect which vector extension is available
|
|
vector_ext = _detect_vector_extension(conn, vector_extension)
|
|
logger.info(f"Using vector extension: {vector_ext}")
|
|
|
|
_migrate_table_embedding_dimension(conn, schema_name, "memory_units", required_dimension, vector_ext)
|
|
_migrate_table_embedding_dimension(conn, schema_name, "mental_models", required_dimension, vector_ext)
|
|
|
|
|
|
def ensure_vector_extension(
|
|
database_url: str,
|
|
vector_extension: str = "pgvector",
|
|
schema: str | None = None,
|
|
) -> None:
|
|
"""
|
|
Ensure the vector indexes match the configured vector extension.
|
|
|
|
This function checks the current vector index type in the database
|
|
and adjusts it if necessary:
|
|
- If index type matches configured extension: no action needed
|
|
- If they differ and tables are empty: drop old indexes, recreate with new type
|
|
- If they differ and tables have data: raise error with migration guidance
|
|
|
|
Args:
|
|
database_url: SQLAlchemy database URL
|
|
vector_extension: Configured vector extension ("pgvector" or "vchord")
|
|
schema: Target PostgreSQL schema name (None for public)
|
|
|
|
Raises:
|
|
RuntimeError: If extension mismatch with existing data
|
|
"""
|
|
schema_name = schema or "public"
|
|
|
|
engine = create_engine(database_url)
|
|
with engine.connect() as conn:
|
|
# Detect which vector extension should be used
|
|
target_ext = _detect_vector_extension(conn, vector_extension)
|
|
logger.info(f"Target vector extension: {target_ext}")
|
|
|
|
# Tables with vector indexes to check
|
|
tables_to_check = [
|
|
("memory_units", "idx_memory_units_embedding"),
|
|
("learnings", "idx_learnings_embedding"),
|
|
("pinned_reflections", "idx_pinned_reflections_embedding"),
|
|
]
|
|
|
|
# Determine target index type
|
|
if target_ext in ("pgvectorscale", "pg_diskann"):
|
|
target_index_type = "diskann"
|
|
elif target_ext == "vchord":
|
|
target_index_type = "vchordrq"
|
|
else:
|
|
target_index_type = "hnsw"
|
|
|
|
mismatched_tables = []
|
|
tables_with_data = []
|
|
|
|
for table_name, index_name in tables_to_check:
|
|
# Check if table exists
|
|
table_exists = conn.execute(
|
|
text("""
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = :schema AND table_name = :table_name
|
|
)
|
|
"""),
|
|
{"schema": schema_name, "table_name": table_name},
|
|
).scalar()
|
|
|
|
if not table_exists:
|
|
logger.debug(f"Table {table_name} does not exist in schema '{schema_name}', skipping")
|
|
continue
|
|
|
|
# Check current index type by querying pg_indexes
|
|
current_index_info = conn.execute(
|
|
text("""
|
|
SELECT indexdef
|
|
FROM pg_indexes
|
|
WHERE schemaname = :schema
|
|
AND tablename = :table_name
|
|
AND indexname LIKE :index_pattern
|
|
"""),
|
|
{"schema": schema_name, "table_name": table_name, "index_pattern": "%embedding%"},
|
|
).fetchone()
|
|
|
|
if not current_index_info:
|
|
# Check whether per-bank partial HNSW indexes already cover this table
|
|
# (created by the bank_utils lifecycle — no global index needed in that case)
|
|
per_bank_index_count = conn.execute(
|
|
text("""
|
|
SELECT COUNT(*)
|
|
FROM pg_indexes
|
|
WHERE schemaname = :schema
|
|
AND tablename = :table_name
|
|
AND indexname LIKE 'idx_mu_emb_%'
|
|
"""),
|
|
{"schema": schema_name, "table_name": table_name},
|
|
).scalar()
|
|
if per_bank_index_count and per_bank_index_count > 0:
|
|
logger.debug(
|
|
f"No global embedding index on {table_name}, but {per_bank_index_count} "
|
|
f"per-bank partial HNSW indexes exist — skipping global index creation"
|
|
)
|
|
continue
|
|
logger.warning(f"No embedding index found for {table_name}, will create it")
|
|
mismatched_tables.append((table_name, index_name, None))
|
|
continue
|
|
|
|
indexdef = current_index_info[0].lower()
|
|
if "diskann" in indexdef:
|
|
current_index_type = "diskann"
|
|
elif "vchordrq" in indexdef:
|
|
current_index_type = "vchordrq"
|
|
elif "hnsw" in indexdef:
|
|
current_index_type = "hnsw"
|
|
else:
|
|
logger.warning(f"Unknown index type for {table_name}: {indexdef}")
|
|
continue
|
|
|
|
# Check if index type matches target
|
|
if current_index_type != target_index_type:
|
|
logger.info(
|
|
f"Index type mismatch on {table_name}: current={current_index_type}, target={target_index_type}"
|
|
)
|
|
mismatched_tables.append((table_name, index_name, current_index_type))
|
|
|
|
# Check if table has data
|
|
row_count = conn.execute(
|
|
text(f"SELECT COUNT(*) FROM {schema_name}.{table_name} WHERE embedding IS NOT NULL")
|
|
).scalar()
|
|
|
|
if row_count > 0:
|
|
tables_with_data.append((table_name, row_count))
|
|
else:
|
|
logger.debug(f"Index type OK for {table_name}: {current_index_type}")
|
|
|
|
# If no mismatches, we're done
|
|
if not mismatched_tables:
|
|
logger.debug(f"All vector indexes match configured extension: {target_ext}")
|
|
return
|
|
|
|
# If there's data in any mismatched table, raise error
|
|
if tables_with_data:
|
|
table_list = ", ".join([f"{table}({count} rows)" for table, count in tables_with_data])
|
|
# Map index type back to extension name for error message
|
|
current_ext_name = {"diskann": "pgvectorscale", "vchordrq": "vchord", "hnsw": "pgvector"}.get(
|
|
current_index_type, current_index_type
|
|
)
|
|
|
|
raise RuntimeError(
|
|
f"Cannot change vector extension from {current_index_type} to {target_index_type}: "
|
|
f"the following tables contain data: {table_list}. "
|
|
f"To change vector extension, you must either:\n"
|
|
f" 1. Re-embed all data: DELETE FROM {schema_name}.memory_units; "
|
|
f"DELETE FROM {schema_name}.learnings; DELETE FROM {schema_name}.pinned_reflections; then restart\n"
|
|
f" 2. Use the current vector extension (set HINDSIGHT_API_VECTOR_EXTENSION='{current_ext_name}')"
|
|
)
|
|
|
|
# Tables are empty, safe to recreate indexes
|
|
logger.info(f"Recreating vector indexes for {target_ext}")
|
|
|
|
for table_name, index_name, current_type in mismatched_tables:
|
|
# Drop existing index if it exists
|
|
if current_type:
|
|
logger.info(f"Dropping {current_type} index on {table_name}")
|
|
conn.execute(text(f"DROP INDEX IF EXISTS {schema_name}.{index_name}"))
|
|
|
|
# Create new index with appropriate type
|
|
if target_ext == "pgvectorscale":
|
|
logger.info(f"Creating DiskANN index on {table_name} (pgvectorscale)")
|
|
conn.execute(
|
|
text(f"""
|
|
CREATE INDEX IF NOT EXISTS {index_name}
|
|
ON {schema_name}.{table_name}
|
|
USING diskann (embedding vector_cosine_ops)
|
|
WITH (num_neighbors = 50)
|
|
""")
|
|
)
|
|
elif target_ext == "pg_diskann":
|
|
logger.info(f"Creating DiskANN index on {table_name} (pg_diskann/Azure)")
|
|
conn.execute(
|
|
text(f"""
|
|
CREATE INDEX IF NOT EXISTS {index_name}
|
|
ON {schema_name}.{table_name}
|
|
USING diskann (embedding vector_cosine_ops)
|
|
WITH (max_neighbors = 50)
|
|
""")
|
|
)
|
|
elif target_ext == "vchord":
|
|
logger.info(f"Creating vchordrq index on {table_name}")
|
|
conn.execute(
|
|
text(f"""
|
|
CREATE INDEX IF NOT EXISTS {index_name}
|
|
ON {schema_name}.{table_name}
|
|
USING vchordrq (embedding vector_l2_ops)
|
|
""")
|
|
)
|
|
else: # pgvector
|
|
# Check embedding dimension — pgvector HNSW indexes only support up to 2000 dims
|
|
embed_dim = conn.execute(
|
|
text("""
|
|
SELECT atttypmod
|
|
FROM pg_attribute a
|
|
JOIN pg_class c ON a.attrelid = c.oid
|
|
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
WHERE n.nspname = :schema AND c.relname = :table_name AND a.attname = 'embedding'
|
|
"""),
|
|
{"schema": schema_name, "table_name": table_name},
|
|
).scalar()
|
|
|
|
if embed_dim and embed_dim > 2000:
|
|
raise RuntimeError(
|
|
f"Embedding dimension {embed_dim} on {table_name} exceeds pgvector HNSW index limit of 2000. "
|
|
f"Use an embedding model with <= 2000 dimensions, or switch to a vector extension "
|
|
f"that supports higher dimensions (e.g., pgvectorscale/DiskANN)."
|
|
)
|
|
logger.info(f"Creating HNSW index on {table_name}")
|
|
conn.execute(
|
|
text(f"""
|
|
CREATE INDEX IF NOT EXISTS {index_name}
|
|
ON {schema_name}.{table_name}
|
|
USING hnsw (embedding vector_cosine_ops)
|
|
WITH (m = 16, ef_construction = 64)
|
|
""")
|
|
)
|
|
|
|
conn.commit()
|
|
logger.info(f"Successfully migrated vector indexes to {target_ext}")
|
|
|
|
|
|
def ensure_text_search_extension(
|
|
database_url: str,
|
|
text_search_extension: str = "native",
|
|
schema: str | None = None,
|
|
) -> None:
|
|
"""
|
|
Ensure the text search columns and indexes match the configured extension.
|
|
|
|
This function checks the current search_vector column type and index type
|
|
in the database and adjusts them if necessary:
|
|
- If they match configured extension: no action needed
|
|
- If they differ and tables are empty: drop old column/index, recreate with new type
|
|
- If they differ and tables have data: raise error with migration guidance
|
|
|
|
Args:
|
|
database_url: SQLAlchemy database URL
|
|
text_search_extension: Configured text search extension ("native" or "vchord")
|
|
schema: Target PostgreSQL schema name (None for public)
|
|
|
|
Raises:
|
|
RuntimeError: If extension mismatch with existing data
|
|
"""
|
|
schema_name = schema or "public"
|
|
|
|
engine = create_engine(database_url)
|
|
with engine.connect() as conn:
|
|
# Tables with search_vector columns to check
|
|
tables_to_check = [
|
|
"memory_units",
|
|
"reflections", # Renamed from pinned_reflections in p1k2l3m4n5o6 migration
|
|
]
|
|
|
|
# Determine target column type and index type
|
|
if text_search_extension == "vchord":
|
|
target_column_type = "bm25vector"
|
|
target_index_type = "bm25"
|
|
elif text_search_extension == "pg_textsearch":
|
|
target_column_type = "text"
|
|
target_index_type = "bm25"
|
|
else: # native
|
|
target_column_type = "tsvector"
|
|
target_index_type = "gin"
|
|
|
|
mismatched_tables = []
|
|
tables_with_data = []
|
|
|
|
for table_name in tables_to_check:
|
|
# Check if table exists
|
|
table_exists = conn.execute(
|
|
text("""
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = :schema AND table_name = :table_name
|
|
)
|
|
"""),
|
|
{"schema": schema_name, "table_name": table_name},
|
|
).scalar()
|
|
|
|
if not table_exists:
|
|
logger.debug(f"Table {table_name} does not exist in schema '{schema_name}', skipping")
|
|
continue
|
|
|
|
# Get current column type from information_schema
|
|
current_column_info = conn.execute(
|
|
text("""
|
|
SELECT data_type, udt_name
|
|
FROM information_schema.columns
|
|
WHERE table_schema = :schema
|
|
AND table_name = :table_name
|
|
AND column_name = 'search_vector'
|
|
"""),
|
|
{"schema": schema_name, "table_name": table_name},
|
|
).fetchone()
|
|
|
|
if not current_column_info:
|
|
logger.warning(f"No search_vector column found for {table_name}, will create it")
|
|
mismatched_tables.append((table_name, None, None))
|
|
continue
|
|
|
|
# Check column type (udt_name contains the actual type: tsvector, bm25vector, etc.)
|
|
current_column_type = current_column_info[1] # udt_name
|
|
|
|
# Get current index type
|
|
current_index_info = conn.execute(
|
|
text("""
|
|
SELECT am.amname
|
|
FROM pg_indexes pi
|
|
JOIN pg_class c ON c.relname = pi.indexname
|
|
JOIN pg_am am ON am.oid = c.relam
|
|
WHERE pi.schemaname = :schema
|
|
AND pi.tablename = :table_name
|
|
AND pi.indexname LIKE '%text_search%'
|
|
"""),
|
|
{"schema": schema_name, "table_name": table_name},
|
|
).fetchone()
|
|
|
|
current_index_type = current_index_info[0] if current_index_info else None
|
|
|
|
# Check if column and index types match target
|
|
column_matches = current_column_type == target_column_type
|
|
index_matches = current_index_type == target_index_type if current_index_type else False
|
|
|
|
if not (column_matches and index_matches):
|
|
logger.info(
|
|
f"Text search mismatch on {table_name}: "
|
|
f"column={current_column_type} (want {target_column_type}), "
|
|
f"index={current_index_type} (want {target_index_type})"
|
|
)
|
|
mismatched_tables.append((table_name, current_column_type, current_index_type))
|
|
|
|
# Check if table has data
|
|
row_count = conn.execute(text(f"SELECT COUNT(*) FROM {schema_name}.{table_name}")).scalar()
|
|
|
|
if row_count > 0:
|
|
tables_with_data.append((table_name, row_count))
|
|
else:
|
|
logger.debug(f"Text search OK for {table_name}: {current_column_type}/{current_index_type}")
|
|
|
|
# If no mismatches, we're done
|
|
if not mismatched_tables:
|
|
logger.debug(f"All text search columns/indexes match configured extension: {text_search_extension}")
|
|
return
|
|
|
|
# If there's data in any mismatched table, raise error
|
|
if tables_with_data:
|
|
table_list = ", ".join([f"{table}({count} rows)" for table, count in tables_with_data])
|
|
# Detect current extension from column type
|
|
current_col_type = mismatched_tables[0][1]
|
|
if current_col_type == "tsvector":
|
|
current_ext = "native"
|
|
elif current_col_type == "bm25vector":
|
|
current_ext = "vchord"
|
|
elif current_col_type == "text":
|
|
current_ext = "pg_textsearch"
|
|
else:
|
|
current_ext = "unknown"
|
|
raise RuntimeError(
|
|
f"Cannot change text search extension from {current_ext} to {text_search_extension}: "
|
|
f"the following tables contain data: {table_list}. "
|
|
f"To change text search extension, you must either:\n"
|
|
f" 1. Clear all data: DELETE FROM {schema_name}.memory_units; "
|
|
f"DELETE FROM {schema_name}.reflections; then restart\n"
|
|
f" 2. Use the current text search extension (set HINDSIGHT_API_TEXT_SEARCH_EXTENSION='{current_ext}')"
|
|
)
|
|
|
|
# Tables are empty, safe to recreate columns/indexes
|
|
logger.info(f"Recreating text search columns/indexes for {text_search_extension}")
|
|
|
|
for table_name, current_col_type, current_idx_type in mismatched_tables:
|
|
# Drop existing index if it exists
|
|
if current_idx_type:
|
|
logger.info(f"Dropping {current_idx_type} index on {table_name}")
|
|
conn.execute(
|
|
text(f"""
|
|
DROP INDEX IF EXISTS {schema_name}.idx_{table_name.replace(".", "_")}_text_search
|
|
""")
|
|
)
|
|
|
|
# Drop existing column if it exists
|
|
if current_col_type:
|
|
logger.info(f"Dropping {current_col_type} column on {table_name}")
|
|
conn.execute(text(f"ALTER TABLE {schema_name}.{table_name} DROP COLUMN IF EXISTS search_vector"))
|
|
|
|
# Create new column with appropriate type
|
|
if text_search_extension == "vchord":
|
|
logger.info(f"Creating bm25vector column on {table_name}")
|
|
# Note: vchord_bm25 extension creates types in bm25_catalog schema
|
|
conn.execute(
|
|
text(f"ALTER TABLE {schema_name}.{table_name} ADD COLUMN search_vector bm25_catalog.bm25vector")
|
|
)
|
|
|
|
# Create BM25 index
|
|
logger.info(f"Creating BM25 index on {table_name}")
|
|
conn.execute(
|
|
text(f"""
|
|
CREATE INDEX idx_{table_name.replace(".", "_")}_text_search
|
|
ON {schema_name}.{table_name}
|
|
USING bm25 (search_vector bm25_catalog.bm25_ops)
|
|
""")
|
|
)
|
|
elif text_search_extension == "pg_textsearch":
|
|
logger.info(f"Creating TEXT column on {table_name}")
|
|
# Dummy TEXT column for consistency (indexes operate on base columns)
|
|
conn.execute(text(f"ALTER TABLE {schema_name}.{table_name} ADD COLUMN search_vector TEXT"))
|
|
|
|
# Create BM25 index on expression
|
|
logger.info(f"Creating BM25 index on {table_name}")
|
|
# Different expression for each table
|
|
if table_name == "memory_units":
|
|
index_expr = "(COALESCE(text, '') || ' ' || COALESCE(context, ''))"
|
|
else: # reflections
|
|
index_expr = "(COALESCE(name, '') || ' ' || content)"
|
|
|
|
conn.execute(
|
|
text(f"""
|
|
CREATE INDEX idx_{table_name.replace(".", "_")}_text_search
|
|
ON {schema_name}.{table_name}
|
|
USING bm25({index_expr})
|
|
WITH (text_config='english')
|
|
""")
|
|
)
|
|
else: # native
|
|
logger.info(f"Creating tsvector column on {table_name}")
|
|
# Different GENERATED expression for each table
|
|
if table_name == "memory_units":
|
|
generated_expr = "to_tsvector('english', COALESCE(text, '') || ' ' || COALESCE(context, ''))"
|
|
else: # reflections
|
|
generated_expr = "to_tsvector('english', COALESCE(name, '') || ' ' || content)"
|
|
|
|
conn.execute(
|
|
text(f"""
|
|
ALTER TABLE {schema_name}.{table_name}
|
|
ADD COLUMN search_vector tsvector
|
|
GENERATED ALWAYS AS ({generated_expr}) STORED
|
|
""")
|
|
)
|
|
|
|
# Create GIN index
|
|
logger.info(f"Creating GIN index on {table_name}")
|
|
conn.execute(
|
|
text(f"""
|
|
CREATE INDEX idx_{table_name.replace(".", "_")}_text_search
|
|
ON {schema_name}.{table_name}
|
|
USING gin(search_vector)
|
|
""")
|
|
)
|
|
|
|
conn.commit()
|
|
logger.info(f"Successfully migrated text search to {text_search_extension}")
|