Arag / app /services /embeddings.py
AuthorBot
Fix analytics accuracy, tenant isolation, and event-loop blocking.
ff0847b
Raw
History Blame Contribute Delete
5.31 kB
"""Author RAG Chatbot SaaS — Document Embedder.
Generates vector embeddings using OpenAI text-embedding-3-small.
Batches chunks to minimize API calls. Stores vectors in ChromaDB.
RULE: Always batch embeddings — never embed one chunk at a time.
RULE: Always namespace ChromaDB collections by author_id + book_id.
"""
import asyncio
from typing import Any
import structlog
from openai import AsyncOpenAI
from app.config import get_settings
from app.core.chroma_client import get_chroma_client
from app.services.chunker import TextChunk
logger = structlog.get_logger(__name__)
cfg = get_settings()
_openai_client: AsyncOpenAI | None = None
def _get_openai() -> AsyncOpenAI:
"""Lazily create and cache OpenAI async client."""
global _openai_client
if _openai_client is None:
_openai_client = AsyncOpenAI(api_key=cfg.OPENAI_API_KEY)
return _openai_client
def _get_chroma():
"""Return the shared ChromaDB client."""
return get_chroma_client()
def get_collection_name(author_id: str, book_id: str) -> str:
"""Build the ChromaDB collection name for an author's book.
Format: author_{short_id}_book_{short_id} (ChromaDB name limits apply).
Args:
author_id: UUID of the author.
book_id: UUID of the book.
Returns:
Collection name string.
"""
# SEC-4 fix: use the full 32-char hex UUID for both segments instead of [:12]
# prefix. Truncation to 12 chars risked cross-tenant prefix collision between
# authors whose UUIDs share the same time-based prefix. Full UUID guarantees
# tenant isolation. Both creator (embed_and_store) and reader (vector_store.py)
# call this function, so one change fixes the entire naming contract.
full_author = author_id.replace("-", "")
full_book = book_id.replace("-", "")
return f"a{full_author}_b{full_book}"
async def embed_and_store(
chunks: list[TextChunk],
author_id: str,
book_id: str,
book_title: str,
) -> str:
"""Generate embeddings for all chunks and store them in ChromaDB.
Processes chunks in batches of EMBEDDING_BATCH_SIZE.
Args:
chunks: List of TextChunk objects from the chunker.
author_id: UUID of the author (for namespacing).
book_id: UUID of the book (for collection naming).
book_title: Title of the book (stored as metadata).
Returns:
ChromaDB collection name (stored on the book record).
"""
collection_name = get_collection_name(author_id, book_id)
chroma = _get_chroma()
# Create or get collection
collection = chroma.get_or_create_collection(
name=collection_name,
metadata={"author_id": author_id, "book_id": book_id, "book_title": book_title},
)
# Delete any existing embeddings (re-processing case)
existing = collection.count()
if existing > 0:
collection.delete(where={"book_id": {"$eq": book_id}})
logger.info("Cleared existing embeddings for re-processing", collection=collection_name, count=existing)
# Process in batches
batch_size = cfg.EMBEDDING_BATCH_SIZE
total_embedded = 0
for batch_start in range(0, len(chunks), batch_size):
batch = chunks[batch_start: batch_start + batch_size]
texts = [chunk.text for chunk in batch]
# Generate embeddings
embeddings = await _generate_embeddings(texts)
# Prepare ChromaDB documents
ids = [f"{book_id}_chunk_{chunk.chunk_index}" for chunk in batch]
metadatas = [
{
"author_id": author_id,
"book_id": book_id,
"book_title": book_title,
"chunk_index": chunk.chunk_index,
"char_start": chunk.char_start,
"char_end": chunk.char_end,
"token_count": chunk.token_count,
}
for chunk in batch
]
collection.add(
ids=ids,
embeddings=embeddings,
documents=texts,
metadatas=metadatas,
)
total_embedded += len(batch)
logger.debug("Embedded batch", batch_size=len(batch), total=total_embedded)
logger.info("Embedding complete", collection=collection_name, total_chunks=total_embedded)
return collection_name
async def _generate_embeddings(texts: list[str]) -> list[list[float]]:
"""Call OpenAI Embeddings API for a batch of texts.
Args:
texts: List of strings to embed.
Returns:
List of embedding vectors (floats).
"""
client = _get_openai()
response = await client.embeddings.create(
model=cfg.OPENAI_EMBEDDING_MODEL,
input=texts,
)
return [item.embedding for item in response.data]
def delete_book_embeddings(author_id: str, book_id: str) -> None:
"""Delete all embeddings for a book from ChromaDB.
Args:
author_id: UUID of the author.
book_id: UUID of the book.
"""
collection_name = get_collection_name(author_id, book_id)
chroma = _get_chroma()
try:
chroma.delete_collection(collection_name)
logger.info("Deleted ChromaDB collection", collection=collection_name)
except Exception as e:
logger.warning("Could not delete collection (may not exist)", collection=collection_name, error=str(e))