samchun-gemini / utils /embedding_generator.py
JHyeok5's picture
Upload folder using huggingface_hub
3418695 verified
Raw
History Blame Contribute Delete
15.8 kB
"""
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())