| |
| """ |
| Migrate RAG data from Redis to Supabase rag_vectors table. |
| Reads all documents from 10 Redis collections and upserts into pgvector. |
| Handles mixed embedding dimensions by padding to 640. |
| Processes in batches of 100. Idempotent (skips already-migrated docs). |
| """ |
|
|
| import asyncio |
| import json |
| import logging |
| import os |
| import sys |
| import time |
|
|
| import httpx |
| import redis.asyncio as aioredis |
| from dotenv import load_dotenv |
|
|
| load_dotenv("/app/.env", override=True) |
|
|
| logging.basicConfig( |
| level=logging.INFO, |
| format="%(asctime)s [%(levelname)s] %(message)s", |
| ) |
| logger = logging.getLogger(__name__) |
|
|
| |
| TARGET_DIM = 640 |
|
|
| COLLECTIONS = [ |
| "wallet_profiles", |
| "token_analysis", |
| "known_scams", |
| "scam_patterns", |
| "forensic_reports", |
| "market_intel", |
| "contract_audits", |
| "news_articles", |
| "transaction_patterns", |
| "general", |
| ] |
|
|
| BATCH_SIZE = 100 |
|
|
|
|
| def _get_url(): |
| return os.environ.get("SUPABASE_URL", "") |
|
|
|
|
| def _get_key(): |
| return os.environ.get("SUPABASE_SERVICE_KEY", "") or os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "") |
|
|
|
|
| def _get_headers(): |
| key = _get_key() |
| return { |
| "apikey": key, |
| "Authorization": f"Bearer {key}", |
| "Content-Type": "application/json", |
| "Prefer": "resolution=merge-duplicates", |
| } |
|
|
|
|
| def _get_redis_password(): |
| return os.environ.get("REDIS_PASSWORD", "") |
|
|
|
|
| def pad_vector(vec: list[float], target_dim: int) -> list[float]: |
| """Pad or truncate embedding vector to target dimension.""" |
| if len(vec) == target_dim: |
| return vec |
| if len(vec) > target_dim: |
| return vec[:target_dim] |
| return vec + [0.0] * (target_dim - len(vec)) |
|
|
|
|
| async def get_existing_ids(client: httpx.AsyncClient) -> set: |
| """Fetch all existing doc IDs from rag_vectors to skip already-migrated docs.""" |
| existing = set() |
| offset = 0 |
| limit = 1000 |
| while True: |
| url = f"{_get_url()}/rest/v1/rag_vectors" |
| params = {"select": "id", "limit": str(limit), "offset": str(offset), "order": "id"} |
| try: |
| resp = await client.get(url, params=params, headers=_get_headers(), timeout=30) |
| if resp.status_code != 200: |
| logger.warning(f"Could not fetch existing IDs: {resp.status_code} {resp.text[:300]}") |
| break |
| rows = resp.json() if resp.text else [] |
| if not rows: |
| break |
| for row in rows: |
| existing.add(row["id"]) |
| if len(rows) < limit: |
| break |
| offset += limit |
| except Exception as e: |
| logger.warning(f"Error fetching existing IDs: {e}") |
| break |
| logger.info(f"Found {len(existing)} existing docs in rag_vectors") |
| return existing |
|
|
|
|
| async def insert_batch(client: httpx.AsyncClient, rows: list[dict]) -> int: |
| """Insert a batch of rows into rag_vectors via Supabase REST API.""" |
| url = f"{_get_url()}/rest/v1/rag_vectors" |
| params = {"on_conflict": "id"} |
| try: |
| resp = await client.post(url, json=rows, params=params, headers=_get_headers(), timeout=120) |
| if resp.status_code in (200, 201): |
| return len(rows) |
| else: |
| logger.error(f"Batch insert failed: {resp.status_code} {resp.text[:500]}") |
| |
| success = 0 |
| for row in rows: |
| try: |
| r2 = await client.post(url, json=[row], params=params, headers=_get_headers(), timeout=60) |
| if r2.status_code in (200, 201): |
| success += 1 |
| else: |
| if r2.status_code == 400 and "embedding" in (r2.text or ""): |
| logger.warning(f"Skipping {row['id']} due to embedding error") |
| else: |
| logger.warning(f"Single insert failed for {row['id']}: {r2.status_code} {r2.text[:200]}") |
| except Exception as e: |
| logger.warning(f"Single insert error for {row['id']}: {e}") |
| return success |
| except Exception as e: |
| logger.error(f"Batch insert exception: {e}") |
| return 0 |
|
|
|
|
| async def migrate_collection(r: aioredis.Redis, client: httpx.AsyncClient, collection: str, existing_ids: set) -> int: |
| """Migrate all docs from one Redis collection to Supabase.""" |
| idx_key = f"rag:idx:{collection}" |
| doc_ids = await r.smembers(idx_key) |
| if not doc_ids: |
| logger.info(f"Collection {collection}: empty, skipping") |
| return 0 |
|
|
| total_docs = len(doc_ids) |
| logger.info(f"Collection {collection}: {total_docs} docs to process") |
|
|
| |
| to_migrate = [did for did in doc_ids if did not in existing_ids] |
| skipped = total_docs - len(to_migrate) |
| if skipped > 0: |
| logger.info(f" Skipping {skipped} already-migrated docs") |
|
|
| migrated = 0 |
| batch_rows = [] |
| batch_count = 0 |
| errors = 0 |
|
|
| for i, doc_id in enumerate(to_migrate): |
| doc_key = f"rag:{collection}:{doc_id}" |
| try: |
| data = await r.get(doc_key) |
| if not data: |
| logger.debug(f" Doc {doc_id}: not found in Redis") |
| continue |
|
|
| doc = json.loads(data) |
|
|
| |
| vector = doc.get("vector", []) |
| content = doc.get("content", "") or "" |
| metadata = doc.get("metadata", {}) or {} |
|
|
| source = metadata.get("source", "") or doc.get("source", "") or "" |
| severity = metadata.get("severity", "") or doc.get("severity", "") or "medium" |
| chain = metadata.get("chain", "") or doc.get("chain", "") or "" |
|
|
| |
| if vector: |
| padded = pad_vector(vector, TARGET_DIM) |
| else: |
| padded = [0.0] * TARGET_DIM |
| logger.debug(f" Doc {doc_id}: no embedding, using zero vector") |
|
|
| row = { |
| "id": str(doc_id), |
| "collection": collection, |
| "content": content[:10000], |
| "embedding": padded, |
| "metadata": json.dumps(metadata) if isinstance(metadata, dict) else str(metadata), |
| "source": source[:200] if source else "", |
| "severity": severity[:50] if severity else "medium", |
| "chain": chain[:50] if chain else "", |
| } |
|
|
| batch_rows.append(row) |
|
|
| if len(batch_rows) >= BATCH_SIZE: |
| count = await insert_batch(client, batch_rows) |
| migrated += count |
| batch_count += 1 |
| logger.info( |
| f" Batch {batch_count}: inserted {count}/{len(batch_rows)} ({migrated}/{len(to_migrate)} total)" |
| ) |
| batch_rows = [] |
| await asyncio.sleep(0.05) |
|
|
| except json.JSONDecodeError as e: |
| errors += 1 |
| if errors <= 5: |
| logger.warning(f" Doc {doc_id}: JSON decode error: {e}") |
| except Exception as e: |
| errors += 1 |
| if errors <= 5: |
| logger.warning(f" Doc {doc_id}: error: {e}") |
|
|
| |
| if (i + 1) % 500 == 0: |
| logger.info(f" Progress: processed {i + 1}/{len(to_migrate)}, migrated {migrated}, errors {errors}") |
|
|
| |
| if batch_rows: |
| count = await insert_batch(client, batch_rows) |
| migrated += count |
| batch_count += 1 |
| logger.info(f" Final batch {batch_count}: inserted {count}/{len(batch_rows)}") |
|
|
| logger.info( |
| f"Collection {collection}: migrated {migrated}/{len(to_migrate)} docs ({skipped} skipped, {errors} errors)" |
| ) |
| return migrated |
|
|
|
|
| async def main(): |
| logger.info("Starting Redis -> Supabase rag_vectors migration") |
| logger.info(f"Supabase URL: {_get_url()}") |
| logger.info(f"Redis: {os.environ.get('REDIS_HOST', 'rmi-redis')}:{os.environ.get('REDIS_PORT', '6379')}") |
| logger.info(f"Target vector dimension: {TARGET_DIM}") |
| logger.info(f"Collections: {COLLECTIONS}") |
|
|
| if not _get_url() or not _get_key(): |
| logger.error("SUPABASE_URL and SUPABASE_SERVICE_KEY must be set") |
| sys.exit(1) |
|
|
| redis_password = _get_redis_password() |
| redis_host = os.environ.get("REDIS_HOST", "rmi-redis") |
| redis_port = int(os.environ.get("REDIS_PORT", "6379")) |
|
|
| |
| r = aioredis.Redis( |
| host=redis_host, |
| port=redis_port, |
| password=redis_password, |
| decode_responses=True, |
| ) |
| await r.ping() |
| logger.info("Redis connected") |
|
|
| |
| async with httpx.AsyncClient(timeout=120) as client: |
| |
| existing_ids = await get_existing_ids(client) |
|
|
| total_migrated = 0 |
| start_time = time.time() |
|
|
| for collection in COLLECTIONS: |
| try: |
| count = await migrate_collection(r, client, collection, existing_ids) |
| total_migrated += count |
| except Exception as e: |
| logger.error(f"Collection {collection} failed: {e}") |
|
|
| elapsed = time.time() - start_time |
| logger.info("=" * 60) |
| logger.info(f"Migration complete: {total_migrated} docs migrated in {elapsed:.1f}s") |
|
|
| await r.aclose() |
|
|
|
|
| if __name__ == "__main__": |
| asyncio.run(main()) |
|
|