8d7c8e7c4e
- Add embedding_model config setting (replaces hardcoded text-embedding-3-small) - Add compute_document_embedding Celery task for ingestion-time embedding - Chain embedding task into finalize_document_storage pipeline - Add backfill_missing_embeddings periodic task (every 5 min) for legacy files - Add debug API endpoints: embedding-status, compute-embedding, diagnostic/embeddings, diagnostic/compute-all-embeddings - Refactor find_similar_documents to only use pre-computed embeddings (no lazy API calls) - Use yield_per(500) and column-only queries for 100K+ scale - Add embedding status indicator and recompute button in file detail UI Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
149 lines
5.3 KiB
Python
149 lines
5.3 KiB
Python
"""Celery task for pre-computing document text embeddings.
|
||
|
||
Runs after document processing to ensure embeddings are available for
|
||
the similarity feature without requiring a user to trigger them on first
|
||
access.
|
||
"""
|
||
|
||
import logging
|
||
|
||
from app.celery_app import celery
|
||
from app.database import SessionLocal
|
||
from app.models import FileRecord
|
||
from app.tasks.retry_config import BaseTaskWithRetry
|
||
from app.utils import log_task_progress
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@celery.task(base=BaseTaskWithRetry, bind=True, name="compute_document_embedding")
|
||
def compute_document_embedding(self, file_id: int) -> dict:
|
||
"""Compute and cache the text embedding for a single document.
|
||
|
||
Skips silently when the file has no OCR text or already has a cached
|
||
embedding. The result is stored in ``FileRecord.embedding`` for
|
||
subsequent similarity queries.
|
||
|
||
Args:
|
||
file_id: Primary key of the :class:`~app.models.FileRecord`.
|
||
|
||
Returns:
|
||
A dict with ``status`` (``"success"`` / ``"skipped"`` / ``"error"``)
|
||
and optional ``detail`` message.
|
||
"""
|
||
task_id = self.request.id
|
||
logger.info("[%s] Computing embedding for file %s", task_id, file_id)
|
||
log_task_progress(
|
||
task_id,
|
||
"compute_embedding",
|
||
"in_progress",
|
||
f"Computing text embedding for file {file_id}",
|
||
file_id=file_id,
|
||
)
|
||
|
||
with SessionLocal() as db:
|
||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||
if not file_record:
|
||
logger.warning("[%s] File %s not found, skipping embedding", task_id, file_id)
|
||
return {"status": "skipped", "detail": "File not found"}
|
||
|
||
# Already has a cached embedding – nothing to do
|
||
if file_record.embedding:
|
||
logger.info("[%s] File %s already has a cached embedding", task_id, file_id)
|
||
log_task_progress(
|
||
task_id,
|
||
"compute_embedding",
|
||
"success",
|
||
"Embedding already cached",
|
||
file_id=file_id,
|
||
)
|
||
return {"status": "skipped", "detail": "Embedding already cached"}
|
||
|
||
if not file_record.ocr_text or not file_record.ocr_text.strip():
|
||
logger.info("[%s] File %s has no OCR text, skipping embedding", task_id, file_id)
|
||
log_task_progress(
|
||
task_id,
|
||
"compute_embedding",
|
||
"skipped",
|
||
"No OCR text available",
|
||
file_id=file_id,
|
||
)
|
||
return {"status": "skipped", "detail": "No OCR text available"}
|
||
|
||
try:
|
||
from app.utils.similarity import compute_and_store_embedding
|
||
|
||
embedding = compute_and_store_embedding(db, file_record)
|
||
if embedding:
|
||
log_task_progress(
|
||
task_id,
|
||
"compute_embedding",
|
||
"success",
|
||
f"Embedding computed ({len(embedding)} dimensions)",
|
||
file_id=file_id,
|
||
)
|
||
return {
|
||
"status": "success",
|
||
"detail": f"Embedding computed ({len(embedding)} dimensions)",
|
||
}
|
||
else:
|
||
log_task_progress(
|
||
task_id,
|
||
"compute_embedding",
|
||
"failure",
|
||
"Embedding computation returned None",
|
||
file_id=file_id,
|
||
)
|
||
return {"status": "error", "detail": "Embedding computation returned None"}
|
||
except Exception as exc:
|
||
logger.exception("[%s] Embedding computation failed for file %s: %s", task_id, file_id, exc)
|
||
log_task_progress(
|
||
task_id,
|
||
"compute_embedding",
|
||
"failure",
|
||
f"Exception: {exc}",
|
||
file_id=file_id,
|
||
)
|
||
return {"status": "error", "detail": str(exc)}
|
||
|
||
|
||
@celery.task(bind=True, name="backfill_missing_embeddings")
|
||
def backfill_missing_embeddings(self) -> dict:
|
||
"""Periodic task that computes embeddings for documents that lack them.
|
||
|
||
Iterates over all ``FileRecord`` rows that have OCR text but no
|
||
cached embedding and queues a :func:`compute_document_embedding`
|
||
task for each one. A configurable ``batch_size`` caps the number
|
||
of tasks queued per run to avoid overwhelming the worker or the
|
||
embedding API.
|
||
|
||
Returns:
|
||
A dict with the number of tasks ``queued``.
|
||
"""
|
||
batch_size = 50 # max files to queue per run
|
||
task_id = self.request.id
|
||
logger.info("[%s] Backfill: scanning for files missing embeddings (batch_size=%d)", task_id, batch_size)
|
||
|
||
with SessionLocal() as db:
|
||
candidates = (
|
||
db.query(FileRecord.id)
|
||
.filter(
|
||
FileRecord.ocr_text.isnot(None),
|
||
FileRecord.ocr_text != "",
|
||
(FileRecord.embedding.is_(None)) | (FileRecord.embedding == ""),
|
||
)
|
||
.limit(batch_size)
|
||
.all()
|
||
)
|
||
|
||
queued = 0
|
||
for (file_id,) in candidates:
|
||
try:
|
||
compute_document_embedding.delay(file_id)
|
||
queued += 1
|
||
except Exception as exc:
|
||
logger.warning("[%s] Could not queue embedding for file %s: %s", task_id, file_id, exc)
|
||
|
||
logger.info("[%s] Backfill: queued %d embedding tasks", task_id, queued)
|
||
return {"queued": queued}
|