Add optional turbopuffer-style object-storage vector search backend#2138
Add optional turbopuffer-style object-storage vector search backend#2138JSv4 wants to merge 16 commits into
Conversation
Introduces a pluggable vector search backend behind the new VECTOR_SEARCH_BACKEND setting (default: pgvector, unchanged). When set to object_storage, similarity ranking is served from a WAL + centroid-ANN index kept in the default Django file storage (local disk, S3/MinIO, GCS) under VECTOR_INDEX_STORAGE_PREFIX, following turbopuffer's architecture: object storage as index source of truth, strongly consistent reads via WAL-tail overlay, async k-means compaction into immutable segment generations, and a per-process LRU for warm queries. - opencontractserver/vector_search/: engine, object-store adapter over django-storages, backend router, write-path hooks - Integration seam: VectorSearchViaEmbeddingMixin.search_by_embedding (single pgvector call site); candidate ids are re-filtered through the caller's queryset so all permission/visibility scoping is preserved; unindexed namespaces and engine errors fall back to pgvector - Write fan-out from Embedding.objects.store_embedding (single chokepoint) via Celery tasks; auto-compaction at WAL threshold with cache-lock serialisation - manage.py rebuild_object_vector_index replays Postgres embeddings into the object index (Postgres stays ground truth; toggle is reversible) - System check opencontracts.E002 fails startup on invalid backend values - 18 new tests (engine + Django integration); verified against MinIO via the real S3 API (docs/test_scripts/object_storage_vector_backend_minio.md) - Design doc: docs/architecture/object_storage_vector_search.md
Review: Object-storage vector search backendNicely scoped, opt-in feature with a clear single integration seam ( Correctness:
|
Review: Optional object-storage vector search backendSolid piece of engineering — the mixin-level integration seam ( A few things worth a look before/after merge: Correctness / design risks
Minor
None of the above block merging given the default-off posture and universal fallback-on-error behavior, but #1 and #2 seem worth at least a doc update, and #1 might be worth a small follow-up fix (delayed GC) if compaction ends up running frequently in practice. |
Addresses the correctness findings from PR #2138 review: - search()/compact() now list the WAL BEFORE reading the manifest. Compaction commits the new manifest strictly before GC'ing folded WAL files, so manifest-first readers could pair an old manifest with an already-GC'd WAL tail and silently drop those writes. - The manifest now records folded_wals (WAL files folded into its generation); readers and compactors skip them when building the overlay, so a lingering folded file (failed/late GC) can never replay stale entries over newer segment state or resurrect tombstoned ids. - GC is deferred one compaction cycle: committing generation N+1 deletes only what generation N superseded (its folded WAL files and prior generation's segment blobs), so in-flight readers holding manifest N no longer hit ObjectNotFound (and a spurious pgvector fallback) mid-race. - wal_tail_count() counts only unfolded files so deferred GC cannot re-trigger auto-compaction forever. - put_bytes() collision recovery tolerates a third writer deleting the uniquified blob mid-recovery (falls back to the payload in hand). - New system check opencontracts.W003 warns when the object-storage backend runs over a process-local cache (LocMemCache/dummy), where the compaction cache-lock cannot serialise compactors across workers. - Docs: consistency/GC sections updated; limitations now cover the async write-visibility window (Celery lag vs turbopuffer's synchronous ack), wall-clock WAL ordering under clock skew, and sequential tail GETs. - Tests: +7 (read-order interleaving regression, partial-GC resurrection regression, wal-tail accounting, deferred-GC reclamation, E002/W003 checks); suite now 25 tests, all regression suites still green.
|
Both reviews addressed in the latest commits. The blocking finding — the manifest/WAL read-order race — was real and is fixed, along with the adjacent GC races: Fixed
Documented as accepted limitations (design doc "Current limitations"): the async write-visibility window (WAL PUT happens in an on-commit Celery task, unlike turbopuffer's synchronous ack), wall-clock WAL ordering under worker clock skew, and sequential WAL-tail GETs (concurrent fetch listed as follow-up). Not taken: consolidating the parent-kind literals across Suite is now 25 tests; all 154 pre-existing search/embedding regression tests and the MinIO real-S3 smoke run still pass. Generated by Claude Code |
Review: Object-storage vector search backend (turbopuffer-style)Overall this is a well-scoped, well-documented, opt-in feature. Default behavior is unchanged ( Correctness
Performance
Test coverage
Security
Nits
Nice work overall — the design doc is unusually thorough (including the "why not build on turbopuffer directly" analysis and prior-art comparison), the fallback-to-pgvector safety net is well-reasoned, and the WAL/manifest ordering tests show real care about the hard concurrency edges. The |
…tfall Third review round on PR #2138: - put_bytes: replace the single-level uniquify recovery (which could itself be raced by a third writer and silently strand stale content at the key) with a bounded delete-then-save retry loop — each attempt discards our stray uniquified blob and retries until the save lands at the exact key; after OBJECT_STORE_PUT_OVERWRITE_MAX_ATTEMPTS we concede last-writer-wins to the racing writer and log. Also drops the unnecessary read-back GET. Direct test pins the recovery behavior. - Router shortfall rule: post-ANN permission filtering has a recall cliff for very selective querysets. If re-filtering consumes a TRUNCATED candidate set (engine returned a full fetch_n) without filling top_k, fall back to pgvector; a short result from an exhausted namespace is complete and returned as-is. This makes the documented 'enabling the backend never returns worse results than pgvector' claim actually hold; regression test covers the swamped-candidates case. - Drop redundant max() in fetch_n; comment ties the compaction lock TTL to the ~1M-vectors-per-namespace recluster scaling note. - Suite now 28 tests; regression suites still green.
|
Third-round findings addressed:
Suite is now 28 tests; all regression suites remain green. Generated by Claude Code |
Review: Object-storage vector search backendWent through Security -- vector blobs bypass the permission layer if the storage bucket is ever exposed
Correctness -- Embedding-row deletes aren't tombstoned (documented, but worth double-checking)
Performance -- per-embedding write cost
Nit
What looks solid
Nothing here should block merge -- the storage-exposure point is the one I'd actually want a confirmation on before flipping the flag on in production. |
Fourth review round on PR #2138: - New system check opencontracts.W004: warns at boot when VECTOR_SEARCH_BACKEND=object_storage runs over an AWS storage config showing public-read signals (AWS_DEFAULT_ACL=public-read*, AWS_QUERYSTRING_AUTH=False, AWS_S3_CUSTOM_DOMAIN). The index blobs carry no ACL of their own — a publicly readable bucket would leak raw vectors + parent ids outside Django auth. Design doc's Permissions section now states the non-public-storage requirement explicitly. - EmbeddingManager docstring + design doc now flag the deletion trap for future code: deleting an Embedding row independent of its parent must tombstone the namespace entry or trigger a rebuild (the query-time ORM refilter only protects against parent deletion). - Design doc: per-write cost called out (~3 object-store roundtrips per store_embedding); bulk backfills should use rebuild_object_vector_index which batches. - k-means empty-cluster reseed now uses successive worst-fit points so multiple empty clusters in one iteration don't collapse onto one seed. - Suite now 29 tests.
|
Fourth-round findings addressed:
Suite is now 29 tests; regression suites remain green. Generated by Claude Code |
|
Review: Optional turbopuffer-style object-storage vector search backend This is a large, well-scoped addition (~2200 lines) that's opt-in and defaults to the existing pgvector path. I read through Correctness
Minor issues / things worth a second look
Security
Test coverage Coverage is strong for a first PR of this scope: WAL-only consistency, compaction/brute-force parity, tombstones, overwrite-race handling in
Style / conventions
Nothing here blocks merging; the flagged items are mostly hardening/operability suggestions for a backend that's disabled by default and falls back safely on any failure. |
Review: Object-storage vector search backendReviewed the diff (origin/main...PR head, 19 files, ~2200 additions) — Strengths
Minor observations (none blocking)
Security: Test coverage: 18 new tests split cleanly between pure-engine (no DB) and Django-integration layers; both the default-off and toggled-on paths assert the other backend's call site raises No blocking issues found. This is a large, subtle feature (WAL/compaction/ANN over object storage) landed with race-condition test coverage well above what I'd expect for a first pass, and it's fully opt-in with automatic fallback, so the blast radius of shipping it is low. |
Review rounds 5-6 on PR #2138: - sync_embedding_to_object_index now claims a pending-marker (cache.add) before enqueueing compaction, so a sustained write burst past the WAL threshold enqueues ONE compaction task instead of one per write. The marker clears when compaction finishes (or by TTL if the queued task is lost). Test: test_compaction_enqueued_once_per_threshold_crossing. - opencontracts.W004 now also covers GCS public-read signals (GS_DEFAULT_ACL public values, GS_QUERYSTRING_AUTH=False), with tests; previously AWS-only, silently passing on a public GCS bucket. - rebuild_object_vector_index: --dry-run (report namespaces/counts without writing), progress line every OBJECT_INDEX_REBUILD_PROGRESS_EVERY embeddings, and a help-text runtime expectation (full WAL replay + full recluster per namespace). - Docs: GCS marked unverified (listdir semantics are load-bearing for the WAL overlay); limitations note that deployment-wide namespaces can make narrow corpus-scoped queries lean on the shortfall fallback at large multi-tenant scale (corpus-sharded namespaces as follow-up). - Integration tests now clear the cache in setUp (compaction lock/pending markers are cache-backed and would leak between tests). - Suite now 30 tests.
|
Rounds 5–6 addressed in the latest commit: Fixed
Documented
Accepted as documented trade-offs (round 6): the 600s lock TTL vs. compaction duration coupling (a portable compare-and-swap doesn't exist in the abstract Django Storage API — S3 conditional PUTs would be a backend-specific enhancement; the constant's comment ties the two limits together), and Suite is now 30 tests; regression suites remain green. Generated by Claude Code |
Review: Object-storage vector search backendOverall this is a very well-engineered addition — opt-in, reversible by construction (a single string setting), with a clear write chokepoint ( One correctness concern worth a look before this ships with the backend actually enabled in production: Manifest read/write race can silently under-return results (no fallback safety net)
The only key this repeatedly overwrites is
Net effect: a search landing in this window returns a severely truncated candidate set (recent WAL tail only) instead of the full indexed namespace — and in The window is narrow (one delete+save round trip, only during compaction commits) and self-healing on the next query, so this may be an acceptable tradeoff in practice — but it seems worth either an explicit call-out in the design doc's limitations section (it currently documents write-visibility lag but not this read-during-compaction-commit gap) or a small mitigation, e.g. retrying Minor / non-blocking observations
Test coverageStrong. Engine-level tests cover the interesting concurrency edge cases directly (WAL/manifest read ordering, deferred GC, partial-GC-failure-can't-resurrect-tombstones), and the Django integration tests cover the toggle, fallback paths (unindexed namespace, engine exception, filter shortfall), queryset scoping, and the rebuild command. The one gap is the manifest overwrite race described above, which isn't currently exercised (the existing race test interleaves two full No security concerns beyond what's already been proactively addressed (post-ANN ORM re-filtering preserves permission scoping; W004 warns on public-bucket misconfiguration). No DB migrations needed since this only adds a parallel index, consistent with "Postgres remains ground truth." Default-off behavior and the |
Review round 7 on PR #2138: - put_bytes commits the manifest via delete-then-save (Storage.save never overwrites), so a reader could catch the sub-second window where the key is missing — indistinguishable from 'never compacted', silently dropping ALL segment data from that query's results, and slipping past the shortfall rule (small hit count reads as 'namespace exhausted'). search() and wal_tail_count() now retry the manifest read once (50ms) when the namespace demonstrably holds data (non-empty WAL) before accepting None. Residual window documented in the design doc, with S3 conditional-PUT named as the backend-native fix. Test: test_search_retries_manifest_read_hit_mid_overwrite. - W004's hint now states the check is best-effort (Django settings signals only; bucket policies set outside Django are invisible). - Suite now 31 tests.
|
Round 7's manifest read/write race is confirmed and fixed. You were right that it was worse than a doc-note case: a reader catching the delete-then-save window got Fix: Documented: the design doc's limitations now describe the non-atomic manifest commit, the retry mitigation, the residual risk (retry also landing in the window degrades one query to WAL-tail-only and self-heals), and names S3 conditional PUTs as the backend-native way to remove the window entirely. Also taken: W004's runtime hint now states it's best-effort (Django settings signals only — bucket policies set outside Django are invisible to it), matching the docstring's honesty. The Suite is now 31 tests; regression suites remain green. Generated by Claude Code |
Review: Optional turbopuffer-style object-storage vector search backendOverall this is a well-scoped, well-documented, opt-in feature. Default behavior is unchanged ( A few things worth a look before/after merge: Correctness / concurrency
Minor / DRY
Test coverageThe 18 new tests are solid at the engine/router/task boundary. Given the PR's headline claim is "GraphQL semantic search, MCP Nothing above blocks merging given the feature is opt-in and defaults to unchanged behavior — the rebuild-command lock gap is the one I'd treat as worth fixing before anyone relies on |
…ites Review round 8 on PR #2138: - rebuild_object_vector_index now acquires the same per-namespace cache lock as the auto-compaction Celery task before compacting (shared compact_lock_key helper); if a compaction is already running it replays the WAL and defers folding to the next cycle instead of racing engine.compact(), which is not concurrency-safe with itself. - put_bytes now raises ObjectStoreWriteError after overwrite retries are exhausted instead of logging and returning: a compaction whose manifest never persisted must fail visibly (Celery error / command failure), not report success. The racing writer's valid manifest remains in place. - sync_embedding_to_object_index gains autoretry with backoff (3 tries): transient object-store errors no longer silently drop one embedding from the index until the next rebuild; the upsert is idempotent. - fetch_n capped by OBJECT_INDEX_MAX_FETCH_CANDIDATES independent of caller top_k (bounds the in-memory ranking and pk__in re-filter); cap-truncated shortfalls take the pgvector fallback. - Cross-reference comments on PARENT_FK_TO_KIND / PARENT_KIND_BY_MODEL_NAME (same taxonomy, two key spaces — kind values must match exactly), and a comment documenting the accepted exactly-fetch_n false positive in the shortfall rule. - New tests: rebuild-command lock respect, put_bytes raise on exhausted retries, and an end-to-end CoreAnnotationVectorStore search (the store behind GraphQL/MCP/agents) served from the object index with pgvector provably untouched. Suite now 34 tests.
|
Round 8 addressed — all items taken:
Suite is now 34 tests; regression suites (142 in this round's run) remain green. Generated by Claude Code |
OverviewThis PR adds an opt-in, reversible object-storage vector search backend (turbopuffer-style: WAL + async k-means compaction + centroid-ANN, all on top of Django's default file storage), routed entirely through the single existing call site This is a large, well-engineered change (~2,500 lines) with unusually thorough documentation ( Strengths
Issues / RisksCorrectness (minor, already largely self-flagged)
Performance (documented, worth surfacing to reviewers who won't read the design doc)
Security
Test coverage
Suggestions
Overall this is a careful, well-tested, and honestly-documented piece of infrastructure that keeps the blast radius small (opt-in, pgvector remains ground truth and default, fails safe). The concerns above are mostly things the PR author already anticipated and documented — I'd call those out for reviewer awareness rather than treat them as blockers. |
Review round 9 on PR #2138: - New EMBEDDABLE_PARENT_KINDS constant (model_name -> (fk_attr, kind)) is the single source of truth for the embeddable parent taxonomy; the router's model-name map and the task module's FK-attr map are now both derived from it, eliminating the hand-synced-dicts divergence risk (divergent kind values would silently route reads and writes into different namespaces). Its comment also records the integer-PK assumption baked into the engine's int64 id representation. - Constant comment notes the manifest-retry sleep can run on a request-serving thread (rare mid-overwrite window only). - New test covers rebuild_object_vector_index --dry-run output and that it writes nothing. Suite now 35 tests.
|
Round 9 addressed:
On the flagged-not-blocking items: the lock-TTL-expiry double-compactor scenario stays a documented trade-off (the constants comment ties the TTL to the namespace-size ceiling; same-generation cluster interleaving resolves at the manifest PUT via last-writer-wins + Suite is now 35 tests; regression suites remain green. Generated by Claude Code |
Review: Object-storage vector search backend (turbopuffer-style)Overall this is a very well-engineered, carefully documented PR. The design doc, in-code rationale comments (especially around WAL/manifest read ordering, deferred GC, and the compaction lock), and the 32-test suite (`opencontractserver/tests/test_object_storage_vector_backend.py`) all show real attention to the hard parts of building an eventually-async index on top of blob storage. The feature is genuinely opt-in (`VECTOR_SEARCH_BACKEND=pgvector` default) and fails safe (`search_via_object_index` falls back to pgvector on any exception, unindexed namespace, or filtered-shortfall). Strengths
Issues / suggestions
Test coverageCoverage is thorough — engine-level tests cover strong consistency, compaction/brute-force parity, tombstones, overwrite ordering, determinism, the manifest mid-overwrite race, and the deferred-GC/partial-GC-failure edge cases; integration tests cover the toggle, queryset scoping, both fallback paths, the rebuild command (including dry-run and lock contention), and auto-compaction triggering. I didn't spot an explicit test for the WAL-clock-skew scenario in point 3 above, but that's a hard thing to test deterministically and I wouldn't block on it. Security
Nice work overall — no blocking issues, just the dead-code cleanup (#1) and the documentation nit (#3) worth a look before merge. |
Review round 10 on PR #2138: - ObjectStorageVectorEngine.namespace_exists() had no callers (search() returning None already serves that purpose) — removed per the no-dead-code rule. - search_via_object_index() returns None (pgvector path) for top_k <= 0 so degenerate inputs stay byte-identical to the default backend. - Clock-skew LWW tradeoff was already documented in the design doc's limitations (WAL ordering across workers uses wall-clock names).
|
Round 10 addressed:
Suite remains 35 tests, all green. Generated by Claude Code |
Review: Object-storage vector search backend (turbopuffer-style)This is a large, well-engineered addition — opt-in, defaults preserved, and the design doc + 10 rounds of self-review already closed out a long list of real WAL/manifest/GC race conditions (deferred GC, One correctness bug survived that self-review, though, and it undercuts a guarantee the PR explicitly documents. Bug: the
|
…on manual compaction Review round 11 on PR #2138: - The round-8 cap formulation min(top_k * OVERSAMPLE, max(top_k, CAP)) collapsed to top_k whenever top_k > OBJECT_INDEX_MAX_FETCH_CANDIDATES — i.e. the cap had no effect in exactly the case it exists for, leaving the in-memory ranking and pk__in re-filter unbounded for caller-controlled top_k (reachable unvalidated from GraphQL, e.g. searchConversations). Now fetch_n = min(top_k * OVERSAMPLE, CAP); when the cap truncates below top_k the existing shortfall rule falls back to pgvector, whose SQL LIMIT has no such exposure. Regression test test_fetch_cap_binds_for_abusive_top_k spies on the engine call and asserts the bound with top_k = 10x the cap. - rebuild_object_vector_index now clears the auto-compaction pending marker after a manual compaction (it just serviced it), so a stale marker can no longer suppress the next threshold-crossing enqueue for up to the marker TTL; compact_pending_key made shared for this. - Suite now 36 tests.
|
Round 11's cap-bypass finding is confirmed and fixed — good catch, and correct in every detail: The minor pending-marker interaction was also closed while fresh: Suite is now 36 tests; regression suites remain green. Generated by Claude Code |
Closes the 'GCS unverified' gap: the full engine lifecycle now runs green against the real GCS JSON API through django-storages GoogleCloudStorage (fake-gcs-server emulator) — WAL-only strong consistency over GCS's prefix-emulated listdir, compaction with the manifest delete-then-save overwrite on a mutable GCS key, full-probe parity with numpy brute force, tombstones, three generations of deferred GC (N-2 blobs reclaimed, N-1 retained), wal_tail_count bookkeeping, and the expected key layout. No engine or adapter code changes were required: GoogleCloudStorage's delete is NotFound-tolerant and _open raises FileNotFoundError, matching DjangoStorageObjectStore's contract. - docs/test_scripts/object_storage_vector_backend_gcs.md records the verification steps (mirrors the MinIO script) - design doc storage-backends section updated: all three backends (LOCAL / AWS via MinIO / GCP via fake-gcs-server) are now verified, with a note to run the recorded smoke once against a production bucket - changelog fragment updated accordingly
Review: Object-storage vector search backend (turbopuffer-style)This is a well-documented, carefully reasoned addition — the design doc, deferred-GC/WAL-ordering rationale, and the limitations section are genuinely good engineering hygiene. Comments below are ordered by importance. 🔴 Read-path coverage doesn't match the "single call site" claimThe PR description states the integration seam is
None of these three call Suggested fix: either (a) make Other observations (minor / already well-handled)
Security
Overall: solid opt-in, reversible architecture with real attention to consistency races and fallback safety. The main actionable item is closing (or explicitly scoping down/documenting) the gap between the "every producer/consumer inherits the toggle" claim and the three parent kinds whose reads still bypass it entirely. |
Review round 12 on PR #2138: conversation/message/relationship vector search never routed through VectorSearchViaEmbeddingMixin — those reads use separate inline CosineDistance implementations with a different result contract (QuerySet + distance-valued similarity_score in conversations/models.py; inlined join in core_relationship_vector_store) — so indexing their writes was pure write amplification with no read benefit, and a behavior gap from the PR's 'single call site' claim. - EMBEDDABLE_PARENT_KINDS scoped to document/annotation/note (the kinds whose querysets inherit the mixin), with the exclusion rationale and the migration recipe (route the read path through search_via_object_index first) documented on the constant. - enqueue_embedding_index_sync now skips non-indexed parent kinds before queuing any Celery task. - Design doc: indexed kinds stated in the architecture section; new limitations bullet naming the conversation/message/relationship follow-up. - New test: conversation embedding writes do not fan out; document embedding writes still do (read/write symmetry). Suite now 37 tests; conversation + subtree-group search suites verified green.
|
Round 12's read-path coverage finding is confirmed and addressed — you were right, and the original "single Taken option (b), scoped down for symmetry:
Separately (from the session's ongoing goal): GCS is no longer unverified — the full engine lifecycle now runs green against the GCS JSON API via fake-gcs-server + django-storages Suite is now 37 tests; regression suites remain green. Generated by Claude Code |
Review: Object-storage vector search backendOverall this is an impressively engineered, well-isolated feature. The opt-in flag, pgvector-as-permanent-fallback design, and the extensive self-review history (WAL/manifest read ordering, deferred GC, overwrite races, fetch-cap bypass, public-storage exposure checks) already cover most of the sharp edges I'd normally flag in an object-storage LSM-ish engine. I read Correctness — the "single chokepoint" claim doesn't hold for all producersThe design doc and PR description state the write path is a single chokepoint: "Write path: fan-out from Effect: documents/annotations ingested through the external-worker upload pipeline will never call Correctness — write/read taxonomy mismatch for 3 of 6 "embeddable parent kinds"
For Given the "zero caller changes" framing in the PR description, worth either (a) scoping Everything else I checked
Minor / non-blocking
Nice work on the fallback-safety framing throughout — "enabling the flag can never make search worse than pgvector" is a good design invariant, and it's actually upheld by the code (shortfall rule, engine-error catch-all, and unindexed-namespace check all funnel to the same fallback). |
Review round 13 on PR #2138: worker_uploads/tasks.py writes Embedding rows via bulk_create (annotation embeddings) and update_or_create (document embedding), bypassing EmbeddingManager.store_embedding — so documents/annotations ingested through the external-worker pipeline silently never reached the object index (namespace non-empty, so no fallback triggered; only a manual rebuild would repair the gap). - _store_embeddings and _store_single_embedding now call enqueue_embedding_index_sync explicitly per row (no-op unless the backend is enabled; skips non-indexed kinds). - hooks.py module docstring and the design doc's write-path diagram corrected: store_embedding is the in-app pipeline chokepoint, with the worker-upload bulk path hooked explicitly; any new direct Embedding write path must do the same. - New test test_worker_upload_direct_writes_fan_out covers both the bulk_create and update_or_create paths end-to-end into the index. Suite now 38 tests; worker-upload regression suites (132 tests) green.
|
Round 13: Finding 1 (worker-upload bypass) — confirmed and fixed. Genuinely new and the more serious of the two: Finding 2 (six-kind taxonomy mismatch) — already fixed in round 12, which the checked-out branch appears to predate: The multi-dimension-per-row observation is correct and pre-existing; the rebuild command indeed iterates all Suite is now 38 tests; all regression suites remain green. Generated by Claude Code |
Review: Object-storage vector search backend (turbopuffer-style)This is a genuinely impressive piece of engineering — the design doc, the deferred-GC/WAL-ordering reasoning, the 37-test suite, and the real MinIO/fake-gcs-server verification scripts all show unusual rigor for an opt-in, off-by-default feature. I traced through Things that look solid (worth calling out, not just "no bugs")
Minor observations (non-blocking)
SecurityThe Nothing else stood outNo dead code, no magic numbers outside the new |
Round 14 suggestion on PR #2138: eager Celery cannot re-execute retries, so the write-path resilience contract (autoretry_for/max_retries/ retry_backoff/retry_jitter on sync_embedding_to_object_index) is pinned by a declarative test instead. Suite now 39 tests.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Overview
Adds an opt-in, reversible vector search backend that keeps its index entirely in object storage, following turbopuffer's architecture: object storage as index source of truth, per-namespace WAL with strongly consistent reads (unindexed tail scanned as an overlay), async compaction into immutable centroid-ANN segment generations, and tiered caching for warm queries. Default behavior is unchanged —
VECTOR_SEARCH_BACKEND=pgvectorremains the default, and the object-storage path falls back to pgvector on unindexed namespaces, engine errors, or post-filter shortfalls.Design doc:
docs/architecture/object_storage_vector_search.md(includes the build-on-turbopuffer vs from-scratch analysis and prior-art comparison — OpenSearch searchable snapshots, Quickwit — from the referenced HN discussion).Flag on feasibility (as requested): turbopuffer is closed-source SaaS, so there is nothing to self-host; this is a from-scratch implementation of the same architecture (~500 lines of numpy over blob-store primitives). A hosted-turbopuffer API driver could later slot into the same
search_via_object_index()/enqueue_embedding_index_sync()seam.How it works
VectorSearchViaEmbeddingMixin.search_by_embedding(opencontractserver/shared/mixins.py). Scope: document, annotation, and note search — the parent kinds whose querysets inherit the mixin — so GraphQL semantic search, Discover, MCPsearch_corpuspassages, agent tools, and hybrid RRF over those kinds inherit the toggle with zero caller changes. Conversation/ChatMessage/Relationship vector search deliberately stays on pgvector for now (their reads use separate inline implementations with a different result contract; documented follow-up — seeEMBEDDABLE_PARENT_KINDS), and their writes are correspondingly not fanned out to the index.(parent_pk, vector); candidate ids are re-filtered through the caller's already-scoped queryset (visible_to_user, MIN(document, corpus), structural rules), with capped oversampling and a shortfall rule that falls back to pgvector rather than under-filling. Deleted rows drop out at the same re-filter. The index blobs carry no ACL — system checkopencontracts.W004warns when the backend is enabled over storage with public-read signals (AWS + GCS).Embedding.objects.store_embedding(the in-app pipeline chokepoint) plus explicit hooks inworker_uploads/tasks.py's bulk writes, viaopencontractserver/tasks/vector_index_tasks.py, with autoretry and pending-marker-gated auto-compaction (cache-lock serialised, deferred GC by one generation); fire-and-forget — an index sync failure never breaks the Postgres write.manage.py rebuild_object_vector_index(batched,--dry-run, progress, lock-aware) replays Postgres embeddings into the index; system checksopencontracts.E002(invalid backend value) andW003(process-local cache vs compaction lock); tuning constants inopencontractserver/constants/search.py.Testing
opencontractserver/tests/test_object_storage_vector_backend.py): engine-level (WAL-only strong consistency, compaction parity with numpy brute force, tombstones, overwrite ordering, determinism, recall, manifest mid-overwrite retry, WAL/manifest read-ordering race, deferred/partial GC, put_bytes overwrite races) and Django integration (toggle both ways, queryset scoping, all fallback paths incl. filter shortfall and fetch-cap, end-to-endCoreAnnotationVectorStorecaller, worker-upload direct-write fan-out, rebuild command incl. dry-run + lock contention, auto-compaction gating, read/write kind symmetry, E002/W003/W004 checks).docs/test_scripts/object_storage_vector_backend_minio.md) and fake-gcs-server via the GCS JSON API through django-storagesGoogleCloudStorage(docs/test_scripts/object_storage_vector_backend_gcs.md) — all three supported storage backends (LOCAL/AWS/GCP) verified.Limitations / future work (documented in the design doc)
Vector arm only (FTS/hybrid RRF unchanged in Postgres); document/annotation/note kinds only (conversation/message/relationship read paths are the named follow-up); async write-visibility window (Celery lag vs turbopuffer's synchronous ack); no group commit (~3 object-store roundtrips per online write — bulk jobs should use the rebuild command); lazy deletes; wall-clock WAL ordering under worker clock skew; non-atomic manifest overwrite (retry-mitigated; S3 conditional PUT named as the permanent fix); full recluster per compaction; per-process cache only; deployment-wide (not corpus-sharded) namespaces.