Spaces:
Runtime error
Runtime error
| """ | |
| Spot Embedding Generator | |
| Gemini text-embedding-004 ๋ชจ๋ธ์ ์ฌ์ฉํ์ฌ story_spots์ ๋ฒกํฐ ์๋ฒ ๋ฉ์ ์์ฑํ๊ณ | |
| Supabase spot_embeddings ํ ์ด๋ธ์ ์ ์ฅํฉ๋๋ค. | |
| Usage: | |
| from utils.embedding_generator import generate_all_embeddings, update_changed_embeddings | |
| # ์ ์ฒด ์์ฑ (์ด๊ธฐ 1ํ) | |
| stats = await generate_all_embeddings() | |
| # ๋ณ๊ฒฝ๋ถ๋ง ์ ๋ฐ์ดํธ (์ฃผ๊ธฐ์ ) | |
| stats = await update_changed_embeddings() | |
| # CLI ์ง์ ์คํ | |
| python -m utils.embedding_generator [--force] | |
| """ | |
| import asyncio | |
| import hashlib | |
| import json | |
| import logging | |
| import os | |
| import time | |
| from typing import Any, Optional | |
| from google import genai | |
| from google.genai import types | |
| logger = logging.getLogger(__name__) | |
| # ============ Constants ============ | |
| EMBEDDING_MODEL = "text-embedding-004" | |
| EMBEDDING_DIMENSIONS = 768 | |
| BATCH_SIZE = 100 # Gemini batch embed limit | |
| RATE_LIMIT_DELAY = 0.5 # seconds between batch calls | |
| # Gemini task types for embedding | |
| TASK_TYPE_RETRIEVAL_DOCUMENT = "RETRIEVAL_DOCUMENT" | |
| TASK_TYPE_RETRIEVAL_QUERY = "RETRIEVAL_QUERY" | |
| # story_spots columns needed for embedding text construction | |
| SPOT_SELECT_COLUMNS = ( | |
| "id, name, name_en, name_zh, category, lat, lng, address, " | |
| "story_title, story_content, story_source, tips, " | |
| "tags_tier1, tags_tier2, meta, " | |
| "main_image_url, thumbnail_url, generated_image_url, " | |
| "priority_score, status, zone, cluster_id, " | |
| "village, source_book, historical_period" | |
| ) | |
| # ============ Gemini Client ============ | |
| def _get_gemini_client() -> genai.Client: | |
| """Get Gemini API client (lazy init).""" | |
| api_key = os.getenv("GEMINI_API_KEY") | |
| if not api_key: | |
| raise ValueError("GEMINI_API_KEY environment variable is required") | |
| return genai.Client(api_key=api_key) | |
| # ============ Content Hash ============ | |
| def compute_content_hash(spot: dict[str, Any]) -> str: | |
| """ | |
| ์คํ ๋ฐ์ดํฐ์ ์ฝํ ์ธ ํด์ ์์ฑ. | |
| ์๋ฒ ๋ฉ์ ์ฌ์ฉ๋๋ ํ๋๋ง ํฌํจํ์ฌ, | |
| ๊ด๋ จ ์๋ ํ๋ ๋ณ๊ฒฝ์ผ๋ก ๋ถํ์ํ ์ฌ์์ฑ ๋ฐฉ์ง. | |
| """ | |
| hash_fields = { | |
| "name": spot.get("name", ""), | |
| "category": spot.get("category", ""), | |
| "story_title": spot.get("story_title", ""), | |
| "story_content": spot.get("story_content", ""), | |
| "tips": spot.get("tips", ""), | |
| "tags_tier1": json.dumps(spot.get("tags_tier1") or {}, sort_keys=True, ensure_ascii=False), | |
| "tags_tier2": json.dumps(spot.get("tags_tier2") or [], sort_keys=True, ensure_ascii=False), | |
| "village": spot.get("village", ""), | |
| "historical_period": spot.get("historical_period", ""), | |
| } | |
| content_str = json.dumps(hash_fields, sort_keys=True, ensure_ascii=False) | |
| return hashlib.sha256(content_str.encode("utf-8")).hexdigest()[:16] | |
| # ============ Embedding Text Construction ============ | |
| def build_embedding_text(spot: dict[str, Any]) -> str: | |
| """ | |
| ์คํ ๋ฐ์ดํฐ๋ฅผ ์๋ฒ ๋ฉ์ฉ ํ ์คํธ๋ก ๋ณํ. | |
| ๊ตฌ์ฑ: | |
| - ์ด๋ฆ (ํ๊ตญ์ด + ์์ด/์ค๊ตญ์ด) | |
| - ์นดํ ๊ณ ๋ฆฌ | |
| - ์คํ ๋ฆฌ ์ ๋ชฉ + ๋ด์ฉ (ํต์ฌ ์๋งจํฑ ์ ๋ณด) | |
| - ํ๊ทธ (ํ ๋ง, ๋ถ์๊ธฐ, ํ๋์ฑ) | |
| - ํ | |
| - ๋ง์, ์๋ ์ ๋ณด | |
| """ | |
| parts: list[str] = [] | |
| # ์ด๋ฆ (๋ค๊ตญ์ด) | |
| name = spot.get("name", "") | |
| parts.append(f"์ฅ์: {name}") | |
| if spot.get("name_en"): | |
| parts.append(f"({spot['name_en']})") | |
| if spot.get("name_zh"): | |
| parts.append(f"({spot['name_zh']})") | |
| # ์นดํ ๊ณ ๋ฆฌ | |
| category = spot.get("category", "") | |
| if category: | |
| parts.append(f"๋ถ๋ฅ: {category}") | |
| # ๋ง์/์๋ (ํฅํ ์ง ๋ฉํ๋ฐ์ดํฐ) | |
| if spot.get("village"): | |
| parts.append(f"๋ง์: {spot['village']}") | |
| if spot.get("historical_period"): | |
| parts.append(f"์๋: {spot['historical_period']}") | |
| # ์คํ ๋ฆฌ (ํต์ฌ ์๋งจํฑ) | |
| story_title = spot.get("story_title", "") | |
| story_content = spot.get("story_content", "") | |
| if story_title: | |
| parts.append(f"์ด์ผ๊ธฐ: {story_title}") | |
| if story_content and "์นดํ ๊ณ ๋ฆฌ์ ์ํฉ๋๋ค" not in story_content: | |
| # ์๋ ์์ฑ๋ ์๋ฏธ์๋ ์ฝํ ์ธ ์ ์ธ | |
| # ์๋ฒ ๋ฉ ํ ํฐ ์์ฐ: ~500์๋ก ์ ํ | |
| content_trimmed = story_content[:500] | |
| parts.append(content_trimmed) | |
| # ํ๊ทธ | |
| tags_tier1 = spot.get("tags_tier1") or {} | |
| themes = tags_tier1.get("theme", []) | |
| moods = tags_tier1.get("mood", []) | |
| activity = tags_tier1.get("activity_level", "") | |
| time_of_day = tags_tier1.get("time_of_day", []) | |
| tag_parts: list[str] = [] | |
| if themes: | |
| tag_parts.append(f"ํ ๋ง: {', '.join(themes)}") | |
| if moods: | |
| tag_parts.append(f"๋ถ์๊ธฐ: {', '.join(moods)}") | |
| if activity: | |
| tag_parts.append(f"ํ๋: {activity}") | |
| if time_of_day: | |
| tag_parts.append(f"์๊ฐ๋: {', '.join(time_of_day)}") | |
| if tag_parts: | |
| parts.append(" | ".join(tag_parts)) | |
| # tier2 ํ๊ทธ | |
| tags_tier2 = spot.get("tags_tier2") or [] | |
| if tags_tier2: | |
| parts.append(f"ํค์๋: {', '.join(tags_tier2)}") | |
| # ํ | |
| tips = spot.get("tips", "") | |
| if tips: | |
| parts.append(f"ํ: {tips[:200]}") | |
| return "\n".join(parts) | |
| # ============ Embedding API ============ | |
| async def generate_embeddings_batch( | |
| texts: list[str], | |
| client: Optional[genai.Client] = None, | |
| task_type: str = TASK_TYPE_RETRIEVAL_DOCUMENT, | |
| ) -> list[list[float]]: | |
| """ | |
| Gemini text-embedding-004๋ก ํ ์คํธ ๋ฐฐ์น ์๋ฒ ๋ฉ ์์ฑ. | |
| Args: | |
| texts: ์๋ฒ ๋ฉํ ํ ์คํธ ๋ฆฌ์คํธ (์ต๋ BATCH_SIZE) | |
| client: Gemini ํด๋ผ์ด์ธํธ (None์ด๋ฉด ์๋ ์์ฑ) | |
| task_type: RETRIEVAL_DOCUMENT (์ ์ฅ์ฉ) ๋๋ RETRIEVAL_QUERY (๊ฒ์์ฉ) | |
| Returns: | |
| ์๋ฒ ๋ฉ ๋ฒกํฐ ๋ฆฌ์คํธ (๊ฐ 768์ฐจ์) | |
| """ | |
| if not texts: | |
| return [] | |
| if client is None: | |
| client = _get_gemini_client() | |
| if len(texts) > BATCH_SIZE: | |
| raise ValueError(f"Batch size {len(texts)} exceeds limit {BATCH_SIZE}") | |
| try: | |
| response = await asyncio.to_thread( | |
| client.models.embed_content, | |
| model=EMBEDDING_MODEL, | |
| contents=texts, | |
| config=types.EmbedContentConfig( | |
| task_type=task_type, | |
| output_dimensionality=EMBEDDING_DIMENSIONS, | |
| ), | |
| ) | |
| embeddings = [] | |
| for emb in response.embeddings: | |
| embeddings.append(list(emb.values)) | |
| logger.info( | |
| f"[embedding] Generated {len(embeddings)} embeddings " | |
| f"(dim={len(embeddings[0]) if embeddings else 0})" | |
| ) | |
| return embeddings | |
| except Exception as e: | |
| logger.error(f"[embedding] Gemini embed_content failed: {e}") | |
| raise | |
| async def generate_query_embedding( | |
| query_text: str, | |
| client: Optional[genai.Client] = None, | |
| ) -> list[float]: | |
| """ | |
| ๊ฒ์ ์ฟผ๋ฆฌ๋ฅผ ์๋ฒ ๋ฉ์ผ๋ก ๋ณํ. | |
| RETRIEVAL_QUERY task type ์ฌ์ฉ (๋ฌธ์ ์๋ฒ ๋ฉ๊ณผ ์์ ์ด๋ฃธ). | |
| Args: | |
| query_text: ๊ฒ์ ์ฟผ๋ฆฌ ํ ์คํธ | |
| Returns: | |
| 768์ฐจ์ ์๋ฒ ๋ฉ ๋ฒกํฐ | |
| """ | |
| embeddings = await generate_embeddings_batch( | |
| [query_text], | |
| client=client, | |
| task_type=TASK_TYPE_RETRIEVAL_QUERY, | |
| ) | |
| return embeddings[0] | |
| # ============ DB Operations ============ | |
| def _load_active_spots() -> list[dict[str, Any]]: | |
| """Supabase์์ active ์คํ ๋ก๋ (์๋ฒ ๋ฉ ์์ฑ์ ํ์ํ ์ปฌ๋ผ๋ง).""" | |
| from db import get_supabase | |
| supabase = get_supabase() | |
| result = supabase.table("story_spots") \ | |
| .select(SPOT_SELECT_COLUMNS) \ | |
| .eq("status", "active") \ | |
| .execute() | |
| return result.data or [] | |
| def _load_existing_embeddings() -> dict[str, str]: | |
| """๊ธฐ์กด ์๋ฒ ๋ฉ์ spot_id -> content_hash ๋งคํ ๋ก๋.""" | |
| from db import get_supabase | |
| supabase = get_supabase() | |
| result = supabase.table("spot_embeddings") \ | |
| .select("spot_id, content_hash") \ | |
| .execute() | |
| return {row["spot_id"]: row["content_hash"] for row in (result.data or [])} | |
| def _upsert_embeddings(records: list[dict[str, Any]]) -> int: | |
| """์๋ฒ ๋ฉ ๋ ์ฝ๋ ๋ฐฐ์น upsert. ์ฑ๊ณต ๊ฑด์ ๋ฐํ.""" | |
| if not records: | |
| return 0 | |
| from db import get_supabase | |
| supabase = get_supabase() | |
| # Supabase upsert: spot_id UNIQUE constraint๋ก ON CONFLICT | |
| result = supabase.table("spot_embeddings") \ | |
| .upsert(records, on_conflict="spot_id") \ | |
| .execute() | |
| return len(result.data) if result.data else 0 | |
| def _delete_orphan_embeddings(active_spot_ids: set[str]) -> int: | |
| """๋ ์ด์ active๊ฐ ์๋ ์คํ์ ์๋ฒ ๋ฉ ์ญ์ .""" | |
| from db import get_supabase | |
| supabase = get_supabase() | |
| # ํ์ฌ ์๋ฒ ๋ฉ์ด ์๋ spot_id ์กฐํ | |
| result = supabase.table("spot_embeddings") \ | |
| .select("spot_id") \ | |
| .execute() | |
| orphan_ids = [ | |
| row["spot_id"] for row in (result.data or []) | |
| if row["spot_id"] not in active_spot_ids | |
| ] | |
| if not orphan_ids: | |
| return 0 | |
| for orphan_id in orphan_ids: | |
| supabase.table("spot_embeddings") \ | |
| .delete() \ | |
| .eq("spot_id", orphan_id) \ | |
| .execute() | |
| logger.info(f"[embedding] Deleted {len(orphan_ids)} orphan embeddings") | |
| return len(orphan_ids) | |
| # ============ Main Functions ============ | |
| async def generate_all_embeddings(force: bool = False) -> dict[str, Any]: | |
| """ | |
| ์ ์ฒด ์คํ ์๋ฒ ๋ฉ ์์ฑ. | |
| Args: | |
| force: True๋ฉด content_hash ๋ฌด์ํ๊ณ ์ ๋ถ ์ฌ์์ฑ | |
| Returns: | |
| { | |
| "total_spots": int, | |
| "generated": int, | |
| "skipped": int, | |
| "deleted_orphans": int, | |
| "errors": int, | |
| "duration_seconds": float | |
| } | |
| """ | |
| start = time.monotonic() | |
| stats = { | |
| "total_spots": 0, | |
| "generated": 0, | |
| "skipped": 0, | |
| "deleted_orphans": 0, | |
| "errors": 0, | |
| "duration_seconds": 0.0, | |
| } | |
| try: | |
| # 1. Active ์คํ ๋ก๋ | |
| spots = await asyncio.to_thread(_load_active_spots) | |
| stats["total_spots"] = len(spots) | |
| logger.info(f"[embedding] Loaded {len(spots)} active spots") | |
| if not spots: | |
| return stats | |
| # 2. ๊ธฐ์กด ์๋ฒ ๋ฉ ํด์ ๋ก๋ | |
| existing_hashes = await asyncio.to_thread(_load_existing_embeddings) | |
| logger.info(f"[embedding] Found {len(existing_hashes)} existing embeddings") | |
| # 3. ๋ณ๊ฒฝ ๊ฐ์ง: ์๋ก์ด/๋ณ๊ฒฝ๋ ์คํ๋ง ํํฐ๋ง | |
| spots_to_embed: list[dict[str, Any]] = [] | |
| for spot in spots: | |
| content_hash = compute_content_hash(spot) | |
| spot["_content_hash"] = content_hash | |
| if force or spot["id"] not in existing_hashes: | |
| spots_to_embed.append(spot) | |
| elif existing_hashes[spot["id"]] != content_hash: | |
| spots_to_embed.append(spot) | |
| else: | |
| stats["skipped"] += 1 | |
| logger.info( | |
| f"[embedding] To embed: {len(spots_to_embed)}, " | |
| f"skipped (unchanged): {stats['skipped']}" | |
| ) | |
| # 4. ๋ฐฐ์น ์๋ฒ ๋ฉ ์์ฑ | |
| client = _get_gemini_client() | |
| for batch_start in range(0, len(spots_to_embed), BATCH_SIZE): | |
| batch = spots_to_embed[batch_start:batch_start + BATCH_SIZE] | |
| batch_texts = [build_embedding_text(s) for s in batch] | |
| try: | |
| embeddings = await generate_embeddings_batch( | |
| batch_texts, client=client | |
| ) | |
| # DB upsert ๋ ์ฝ๋ ์ค๋น | |
| records = [] | |
| for spot, embedding in zip(batch, embeddings): | |
| records.append({ | |
| "spot_id": spot["id"], | |
| "embedding": embedding, | |
| "embedding_model": EMBEDDING_MODEL, | |
| "content_hash": spot["_content_hash"], | |
| "updated_at": "now()", | |
| }) | |
| upserted = await asyncio.to_thread(_upsert_embeddings, records) | |
| stats["generated"] += upserted | |
| logger.info( | |
| f"[embedding] Batch {batch_start // BATCH_SIZE + 1}: " | |
| f"embedded {upserted}/{len(batch)} spots" | |
| ) | |
| except Exception as e: | |
| stats["errors"] += len(batch) | |
| logger.error( | |
| f"[embedding] Batch {batch_start // BATCH_SIZE + 1} failed: {e}" | |
| ) | |
| # Rate limiting | |
| if batch_start + BATCH_SIZE < len(spots_to_embed): | |
| await asyncio.sleep(RATE_LIMIT_DELAY) | |
| # 5. ๊ณ ์ ์๋ฒ ๋ฉ ์ ๋ฆฌ | |
| active_ids = {s["id"] for s in spots} | |
| stats["deleted_orphans"] = await asyncio.to_thread( | |
| _delete_orphan_embeddings, active_ids | |
| ) | |
| except Exception as e: | |
| logger.error(f"[embedding] generate_all_embeddings failed: {e}") | |
| stats["errors"] += 1 | |
| stats["duration_seconds"] = round(time.monotonic() - start, 2) | |
| logger.info(f"[embedding] Complete: {stats}") | |
| return stats | |
| async def update_changed_embeddings() -> dict[str, Any]: | |
| """ | |
| ๋ณ๊ฒฝ๋ ์คํ๋ง ์๋ฒ ๋ฉ ์ ๋ฐ์ดํธ. | |
| content_hash ๋น๊ต๋ก ์ค์ ๋ณ๊ฒฝ๋ถ๋ง ์ฒ๋ฆฌ. | |
| generate_all_embeddings(force=False)์ ํธ์ ๋ํผ. | |
| """ | |
| return await generate_all_embeddings(force=False) | |
| # ============ CLI Entry Point ============ | |
| async def _main(): | |
| """CLI ์คํ์ฉ ๋ฉ์ธ ํจ์.""" | |
| import argparse | |
| import sys | |
| # backend/ ๋๋ ํ ๋ฆฌ๋ฅผ Python path์ ์ถ๊ฐ | |
| from pathlib import Path | |
| backend_dir = str(Path(__file__).parent.parent) | |
| if backend_dir not in sys.path: | |
| sys.path.insert(0, backend_dir) | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| ) | |
| parser = argparse.ArgumentParser(description="Generate spot embeddings") | |
| parser.add_argument( | |
| "--force", action="store_true", | |
| help="Force regenerate all embeddings (ignore content_hash)" | |
| ) | |
| parser.add_argument( | |
| "--dry-run", action="store_true", | |
| help="Show what would be embedded without actually calling API" | |
| ) | |
| args = parser.parse_args() | |
| if args.dry_run: | |
| spots = _load_active_spots() | |
| existing = _load_existing_embeddings() | |
| changed = 0 | |
| new = 0 | |
| for spot in spots: | |
| h = compute_content_hash(spot) | |
| if spot["id"] not in existing: | |
| new += 1 | |
| print(f" NEW: {spot['id']} ({spot['name']})") | |
| elif existing[spot["id"]] != h: | |
| changed += 1 | |
| print(f" CHANGED: {spot['id']} ({spot['name']})") | |
| print(f"\nTotal: {len(spots)} active spots") | |
| print(f" New: {new}") | |
| print(f" Changed: {changed}") | |
| print(f" Unchanged: {len(spots) - new - changed}") | |
| return | |
| stats = await generate_all_embeddings(force=args.force) | |
| print(f"\nEmbedding generation complete:") | |
| for k, v in stats.items(): | |
| print(f" {k}: {v}") | |
| if __name__ == "__main__": | |
| asyncio.run(_main()) | |