MyPal / app /embeddings /service_sync.py
KhaledSalehKL1's picture
Deploy 9XAIPal: Gradio+FastAPI app, backend, React build
1086e43 verified
Raw
History Blame Contribute Delete
3.71 kB
"""Synchronous embedding service: generate and store embeddings for chunks in committed batches."""
from uuid import UUID
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.logging import get_logger
from app.core.config import settings
from app.embeddings.model import active_embedding_model_sync, get_embeddings_batch_sync
logger = get_logger(__name__)
# Embedding input sizing: Ollama's /api/embed returns a hard 400 ("input
# length exceeds the context length") rather than truncating — even with
# truncate=true. Dense tables tokenize heavily, so the cap is conservative.
# Configurable via EMBED_MAX_CHARS (cloud embedders allow much more).
def get_chunks_without_embeddings_sync(session: Session, document_id: UUID, limit: int = 20) -> list[dict]:
"""Retrieve chunks without embeddings synchronously."""
result = session.execute(
text("""
SELECT c.id, c.plain_text, c.chunk_type FROM chunks c
LEFT JOIN chunk_embeddings ce ON ce.chunk_id = c.id
WHERE c.document_id = :document_id AND ce.chunk_id IS NULL
ORDER BY c.sequence_id
LIMIT :limit
"""),
{"document_id": document_id, "limit": limit},
)
return [dict(r) for r in result.mappings().all()]
def _embed_text_for_chunk(chunk: dict) -> str:
"""Build a safe, non-empty, length-capped text to embed for a chunk.
Empty plain_text (e.g. figures with no caption) would make Ollama's
/api/embed return 400 and stall the whole batch, so substitute a small
placeholder; oversized text is truncated to stay within the model's window.
"""
txt = (chunk.get("plain_text") or "").strip()
if not txt:
txt = f"[{chunk.get('chunk_type') or 'content'}]"
return txt[:settings.embed_max_chars]
def embed_document_chunks_sync(
session: Session, document_id: UUID, batch_size: int = 20
) -> int:
"""Generate embeddings for all un-embedded chunks of a document in committed batches."""
total_embedded = 0
while True:
chunks = get_chunks_without_embeddings_sync(session, document_id, limit=batch_size)
if not chunks:
break
texts = [_embed_text_for_chunk(c) for c in chunks]
embeddings = get_embeddings_batch_sync(texts)
model_name = active_embedding_model_sync()
if not embeddings or len(embeddings) != len(chunks):
raise ValueError(
f"Generated embedding count ({len(embeddings) if embeddings else 0}) "
f"does not match chunk count ({len(chunks)})"
)
# Prepare payloads with explicit casting of elements to python float
payloads = []
for chunk, embedding in zip(chunks, embeddings):
cast_embedding = [float(v) for v in embedding]
payloads.append({
"chunk_id": chunk["id"],
"embedding": cast_embedding, # Explicit list of floats matching pgvector extension dialect
"model": model_name,
})
# Insert batch into database
session.execute(
text("""
INSERT INTO chunk_embeddings (chunk_id, embedding, embedding_model)
VALUES (:chunk_id, :embedding, :model)
ON CONFLICT (chunk_id) DO UPDATE
SET embedding = EXCLUDED.embedding,
embedding_model = EXCLUDED.embedding_model,
created_at = NOW()
"""),
payloads,
)
total_embedded += len(chunks)
session.commit()
logger.info(f"Embedded {total_embedded} chunks synchronously for document {document_id}")
return total_embedded