feat(similarity): add embedding pipeline, debug endpoints, backfill task, and scalable similarity search
- 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>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"""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}
|
||||
@@ -76,6 +76,16 @@ def finalize_document_storage(self, original_file: str, processed_file: str, met
|
||||
# We pass 'True' (delete_after) and 'file_id' as per Main branch requirements
|
||||
send_to_all_destinations.delay(processed_file, True, file_id)
|
||||
|
||||
# 3a. Queue embedding computation so similarity scores are ready for queries
|
||||
if file_id is not None:
|
||||
try:
|
||||
from app.tasks.compute_embedding import compute_document_embedding
|
||||
|
||||
compute_document_embedding.delay(file_id)
|
||||
logger.info(f"[{task_id}] Queued embedding computation for file {file_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{task_id}] Could not queue embedding task: {e}")
|
||||
|
||||
# 4. Send Notification (From Copilot)
|
||||
# Note: This notification is sent after processing is complete but while uploads
|
||||
# are being queued.
|
||||
|
||||
Reference in New Issue
Block a user