Skip embedding tasks and Qdrant writes for unchanged content files - #3646
Conversation
OpenAPI ChangesNo changes detected Unexpected changes? Ensure your branch is up-to-date with |
dc6908e to
4186bdc
Compare
There was a problem hiding this comment.
Pull request overview
This PR implements “Phase 2” of the embedding-load reduction by making Qdrant content-file embedding proportional to ETL changes (embed only new/changed/republished files instead of re-embedding the entire run on every load), while preserving legacy behavior when change details are unknown.
Changes:
- Add change detection in
load_content_filesto compute a changed subset of content files and pass it through the plugin hook (with a broker-size cap fallback). - Extend the
content_files_loadedhook to optionally carrycontent_file_idsandremoved_unpublished, and update the search/indexing plugin logic accordingly (including tri-state removal behavior). - Update
embed_run_content_filesto embed only published files and optionally restrict embedding to a provided ID list; add/extend tests for the new behavior.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| vector_search/tasks.py | Allow embedding only a provided subset of published content files for a run. |
| vector_search/tasks_test.py | Add coverage for subset embedding, skipping unpublished, and empty-id behavior. |
| main/settings.py | Add CONTENT_FILE_EMBED_ID_CAP to limit broker message size for changed-id lists. |
| learning_resources/utils.py | Extend content_files_loaded_actions to pass content_file_ids and removed_unpublished through the plugin hook. |
| learning_resources/hooks.py | Update the content_files_loaded hookspec signature/documentation to include new parameters. |
| learning_resources/etl/loaders.py | Snapshot prior file state, compute changed IDs, log change ratio, and pass subset/cap-fallback into the hook. |
| learning_resources/etl/loaders_test.py | Add unit tests for change detection and cap behavior reaching the hook. |
| learning_resources_search/plugins.py | Update hook implementation to embed only changed files (or all when None) and apply tri-state removal behavior. |
| learning_resources_search/plugins_test.py | Update existing expectations and add tests for removed_unpublished tri-state behavior. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
learning_resources/etl/loaders.py:1110
_PAYLOAD_METADATA_FIELDSonly tracks ContentFile columns, but the Qdrant content-file payload includes run/resource-derived fields (e.g.,run_title,platform,offered_by,course_number, etc.). If those change on an ETL run while file checksums/metadata stay the same,changed_idscan be empty and no Qdrant task runs, leaving payload fields stale for all content-file points.
# Scalar, ETL-settable ContentFile columns whose Qdrant payload isn't covered by
# `checksum` (content-only). A deliberate subset of QDRANT_CONTENT_FILE_PARAM_MAP
# (which also holds non-columns, run-level, and AI-generated fields).
_PAYLOAD_METADATA_FIELDS = (
"title",
"description",
"url",
"file_type",
d5e2541 to
e63d607
Compare
| if course_run.learning_resource.resource_type == LearningResourceType.course.name: | ||
| # Snapshot existing files so we can embed only what actually changed this | ||
| # load, rather than re-embedding the whole run every time. | ||
| prior_files = { |
There was a problem hiding this comment.
This checks against the database for changes and not qdrant. The database etl job does not wait for the qdrant tasks to finish. The embedding is supposed to have 3 retries but if it does not go through for whatever reason (like celery becoming overwhelmed) qdrant will never be updated
There was a problem hiding this comment.
I think vector_search/utils.py#should_generate_content_embeddings already checks the checksome and does not do the expensive part of the embedding if there is no change
e63d607 to
a492860
Compare
|
@abeglova ready for another look |
| .distinct() | ||
| ) | ||
| stale_published_files.update(published=False) | ||
| removed_unpublished = stale_published_files.update(published=False) > 0 |
There was a problem hiding this comment.
So i think this means that if a file is unpublished but the qdrant unpublish action fails the orphaned content file in qdrant will never be removed. I think you can just leave the loader file unchanged? I don't think the delete action is costly if the point does not exist
73b23f5 to
8526807
Compare
abeglova
left a comment
There was a problem hiding this comment.
This looks good but the pr title is no longer what the pr does - please change it
…mbed Follow-up to the embed change-detection PR (#3646): - Exclude content-less files from the embed_run_content_files pre-pass: they never produce Qdrant points, so they were permanently flagged as stale and re-dispatched on every load, defeating the zero-task fast path for unchanged runs. - Retry transient Qdrant errors (DEADLINE_EXCEEDED/UNAVAILABLE) in the pre-pass with the existing jittered backoff instead of failing the run's embedding outright. - Reorder the content_files_loaded chain to purge unpublished files' points before embedding, so a failed embed task can never strand unpublished points in Qdrant. The two tasks touch disjoint sets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 2: make content-file embedding work proportional to change
instead of re-embedding a run's entire file set on every load.
- load_content_files snapshots {key: (checksum, published)} before the
per-file loop and computes the changed subset (new, checksum differs,
or unpublished->published republish) via _changed_content_file_ids.
- The content_files_loaded hook gains content_file_ids and
removed_unpublished params (both default None = legacy whole-run
behavior, keeping the republish path and data migration working).
- embed_run_content_files(run_id, content_file_ids=None) embeds only the
named files when given a list, all published files when None.
- Above CONTENT_FILE_EMBED_ID_CAP changed files, pass None so the task
re-queries rather than serializing a huge id list into the broker.
- removed_unpublished is tri-state in the plugin: True and None append
the remove-unpublished task, only an explicit False skips it.
Addresses mitodl/hq#12454
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Merge the two CONTENT_FILE_EMBED_ID_CAP tests into one parametrized test and reuse the existing _embedded_content_file_ids helper in the embed_run_content_files id tests. No coverage change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Align the hookspec signature with its documented contract and every implementation, where content_file_ids/removed_unpublished are optional (None means backfill / unknown for legacy callers). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
checksum hashes only file content, so metadata-only changes (title, url, edx_module_id, etc.) to an identical-content file were skipped by change detection and their Qdrant payload went stale with no self-healing path. Snapshot the scalar payload columns and flag a file as changed when any differ; such files hit the cheap payload-refresh path without re-embedding. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review: the DB-diff approach compared pre/post-load DB state, but the ETL doesn't wait for the async embed chain, so a permanently-failed embed left Qdrant stale forever (DB already showed the new checksum). Qdrant's stored payload checksum is the correct source of truth and self-heals: missing or stale points re-qualify on the next load. - Remove _changed_content_file_ids, the prior-files snapshot, the content_file_ids hook plumbing, and CONTENT_FILE_EMBED_ID_CAP; keep the removed_unpublished tri-state and published-only embed filtering. - Batch the Qdrant payload lookups (_stored_content_payloads): one retrieve per batch serves the existence filter, summary-change check, and embed gate, replacing the per-file retrieves. - Add a run-level pre-pass to embed_run_content_files: compare DB checksums and payload metadata columns (title, summary, flashcards, etc.) against the stored Qdrant payload from lightweight DB fields before serializing anything. Metadata-only drift is dispatched but exits via the cheap payload-only update path; a fully-unchanged 1,770-file run drops from ~31s (full serialization + per-file payload rewrites) to ~0.09s and dispatches no embedding tasks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drops the redundant exists() query; the row count also reflects what was actually unpublished rather than what existed a moment earlier. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gating the removal task on this run's unpublish count meant a failed removal (Qdrant outage, failed embed task earlier in the chain) was never retried: the next load sees zero newly-unpublished rows and skips the purge, leaving orphaned points in Qdrant indefinitely. Restore the unconditional purge so removals self-heal, and drop the now-dead removed_unpublished plumbing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8526807 to
0eaef52
Compare
…mbed Follow-up to the embed change-detection PR (#3646): - Exclude content-less files from the embed_run_content_files pre-pass: they never produce Qdrant points, so they were permanently flagged as stale and re-dispatched on every load, defeating the zero-task fast path for unchanged runs. - Retry transient Qdrant errors (DEADLINE_EXCEEDED/UNAVAILABLE) in the pre-pass with the existing jittered backoff instead of failing the run's embedding outright. - Reorder the content_files_loaded chain to purge unpublished files' points before embedding, so a failed embed task can never strand unpublished points in Qdrant. The two tasks touch disjoint sets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mbed Follow-up to the embed change-detection PR (#3646): - Exclude content-less files from the embed_run_content_files pre-pass: they never produce Qdrant points, so they were permanently flagged as stale and re-dispatched on every load, defeating the zero-task fast path for unchanged runs. - Retry transient Qdrant errors (DEADLINE_EXCEEDED/UNAVAILABLE) in the pre-pass with the existing jittered backoff instead of failing the run's embedding outright. - Reorder the content_files_loaded chain to purge unpublished files' points before embedding, so a failed embed task can never strand unpublished points in Qdrant. The two tasks touch disjoint sets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
What are the relevant tickets?
Closes mitodl/hq#12454 (companion to mitodl/hq#12453 mechanics and mitodl/hq#12452 checksum).
Description (What does it do?)
When a course is re-ingested, skip re-embedding content files that are already up to date in Qdrant.
Every embedded file stores its content checksum in its Qdrant payload. Before doing any expensive work,
embed_run_content_filesnow fetches those stored checksums in a few batched requests and compares them (plus metadata fields like title, description, and summary) against the database:Because the check reads Qdrant rather than the database, it recovers from failures automatically: if an embedding task dies partway (deploy, autoscaling), the unfinished files still look stale on the next ingest and get retried, while completed files are skipped. Nothing is silently lost.
This replaces the earlier version of this branch, which detected changes by comparing database state before/after the load. As review pointed out, that couldn't recover from a failed embed: the database already said "done," so the file would never be retried.
Also in this PR:
content_files_loadedhook gainsremoved_unpublished, so the remove-unpublished-files task only runs when a file was actually unpublished.embed_run_content_filesonly embeds published files.Numbers (1,770-file course, unchanged re-ingest): 31s of serialization and Qdrant writes on
main→ 0.09s and zero tasks on this branch. With 10% of files changed: 18 tasks instead of 177. A brand-new course does the same embedding work asmainwith fewer Qdrant calls. The branch never does more work thanmainin any scenario.Known limits:
main.run_title,course_number,offered_by) refresh when a file changes or via the embedding backfill — roughly the same freshness asmain, which only refreshed them when the course archive changed.summary) — payload writes only, no re-embedding.ContentFilecolumns; a test fails if someone adds a transformed field (which would re-flag every file on every load).How can this be tested?
Use run
course-v1:MITxT+15.356.1x+3T2025(300 content files, each with content and a checksum). If it's not in your local DB yet, ingest it first:Queue the task for the run's pk and watch the pre-pass ratio in the celery logs:
Expected sequence (verified on a 34-file run; the pre-pass on the 300-file run above logs
210 of 300against a partially stale local Qdrant, matching step 1):N of N files need embedding— stale/missing files embed. If the run was already fully embedded, you'll see0 of Nimmediately.0 of N— no embedding tasks dispatched, nothing written.N of N(all titles changed); the files take the payload-only path — their Qdrant payload shows the new title, with no re-embedding. Re-queueing again logs0 of N.contentand call.save()(which recomputes the checksum), re-queue: logs1 of Nand re-embeds just that file.Testing via a real course re-ingest works too, but note the embed task is only queued from the ETL hook when
QDRANT_ENABLE_INDEXING_PLUGIN_HOOKS=Trueis set — with it off (the local default), a re-ingest logs nothing.🤖 Generated with Claude Code