Add a portable storage and CRUD layer for embeddings - #976
Conversation
Adds the WordPress\AI\Embeddings namespace: a wpai_embeddings table (created on first write) and a repository that records provider, model and dimensions alongside every vector, so an index can never be queried with vectors from a different model. Persistence only, per WordPress#962 - generation stays in the AI Client and similarity search builds on top. See WordPress#962.
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## develop #976 +/- ##
=============================================
+ Coverage 75.18% 75.37% +0.19%
- Complexity 3227 3357 +130
=============================================
Files 133 138 +5
Lines 12488 12976 +488
=============================================
+ Hits 9389 9781 +392
- Misses 3099 3195 +96
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Wraps the sprintf-built exception messages in esc_html(), matching the existing pattern in Admin\Upgrades\V1_3_0.
Changes: - Moved embedding docs to docs/experiments/embeddings.md per maintainer request - Changed all @SInCE n.e.x.t to @SInCE 1.4.0 (version consistency) - Fixed embedding_norm truncation: use %s instead of %f in prepared statement - Fixed delete error handling: throw RuntimeException on DB failure (not silent 0) - Fixed schema byte limit: use prefix indexes to stay under 767-byte key limit - Fixed schema upgrade logic: properly handle table creation and version tracking - Removed esc_like from SHOW TABLES (table_name is constant, not user input) - Added reference to embeddings.md in DEVELOPER_GUIDE Additional Resources - All 54 tests passing Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
Addressed all review feedback: On length checks: Kept provider(64) and model(128) — conservative but covers all real-world values; early validation prevents DB errors On %f truncation: Fixed — using %s format so MySQL handles precision naturally On delete errors: Fixed — now throws RuntimeException on failure instead of silent 0 On byte limit: Fixed — prefix indexes on VARCHAR columns keeps key under 767-byte limit On esc_like: Removed from table_exists() — table_name is constructed from constant + prefix, not user input, so escaping not needed On schema upgrade: Fixed — logic now properly handles table creation and version tracking Docs split: Done — moved to docs/experiments/embeddings.md All 54 tests passing locally; ready for re-review. |
dkotter
left a comment
There was a problem hiding this comment.
Sorry for multiples pings, was doing a further review after submitting my last review (and I know changes came in during that time as well)
|
Fixed two issues from your review: @SInCE versions: Reverted to x.x.x (I had incorrectly filled in 1.4.0). Understood — maintainers update those to the actual version at release time, not during PR review. Infinite loop in iterate(): Found and fixed a real bug. If all rows in a batch are corrupt (hydrate_rows skips them), the cursor never advances, causing infinite loop. Now cursor advances regardless of corrupt rows. On the design questions — these all seem like scope decisions, so want to flag them for your input: On object_subtype: WordPress Core uses subtypes (post/page, term/post_tag). Should we add an optional column now to align with that pattern? Or scope as v2 enhancement? On dimension handling: Current design assumes dimensions are stable per model. If dimensions change, sync layer calls delete_for_model() first. Does that approach work, or should we handle dimension changes differently? On save_many performance: For large-scale sync (50k+ items), batch INSERT would be much faster than looping individual saves. Should we implement multi-row insert in this PR, or defer until sync layer lands? On finding objects without vectors: This feels like application-layer concern — caller queries get_object_ids() to find indexed objects, diffs against full set to find gaps. Is that approach OK, or should storage layer provide batch probe? On schema performance at scale: Current indexes handle common queries. For sites with 50k+ items, should we add more targeted indexes now, or optimize when we hit bottlenecks? On quantized embeddings: embedding_coarse would be great for fast similarity filtering — full + quantized vectors, filter coarse then compute full similarity on top candidates. Is similarity search in-scope for this PR, or definitely follow-up work? On stale chunks: If dimensions change but provider/model stay same, old vectors coexist. This is by design — sync layer should call delete_for_model() first to clear them explicitly, not silently corrupt on re-index. Which of these should we tackle in this PR vs plan for follow-up work? |
Most of these are questions / things I flagged specifically so we (ideally as humans) could discuss and decide on the right approach (or even decide that nothing is needed). This is a large feature and ideally we get this schema right from the beginning to avoid messy future upgrades.
If we want this it should go in this PR, not a v2 enhancement as this is a change to the schema. I personally think this makes sense and matches what Core does so I'd suggest we add it.
I had posed this as a question to ensure we were thinking through all scenarios. As an example (this may or may not end up how it's built) say we have a filter around the dimensions value that allows a 3rd party to change that. If embeddings are generated with a certain dimension value and later someone uses that filter to generate embeddings with a different dimensions value, we won't necessarily know to run
I'd suggest making that change here, though could punt to that sync PR if you think that's better (just don't want to lose that item between PRs)
Maybe fine for now, likely something to consider once we have the sync layer in place and can more easily test this with large datasets (though this may be solved/improved by some of the other items in this list being solved)
If there are ways to optimize now that will be ideal. Maybe the
I don't think building out similarity search is in-scope here but
Similar to an answer above, not sure we can always assume everyone will be calling |
|
Implementing dkotter scope guidance (2026-08-31 21:14): ✅ DONE:
📝 ON DIMENSIONS:
Ready to discuss batch INSERT optimization strategy or proceed with test fixes? |
… unique key per scope guidance - object_subtype column for Core-aligned object segmentation (post/page, term/post_tag) - embedding_coarse column for quantized vector filtering - dimensions added to unique key to prevent cross-dimension vector mixing - pack_coarse() method in Vector_Codec - save() and save_many() updated for new columns - Embedding_Record handles object_subtype Tests need column-order updates; batch INSERT optimization deferred to MVP+.
6403356 to
d28ad57
Compare
- hydrate_rows() now passes object_subtype when constructing Embedding_Record - Test assertion updated to expect new column order: object_subtype, embedding_coarse in correct positions
|
@ColinM-sys the speed with which you're responding to feedback and pushing code changes makes it appear as though we're directly interacting with an agent, but I hope (and am assuming positive intent) that you're reviewing the feedback and the code output (even if using an agent to assist) before pushing commits or code. If that's not the case, please do take the time to personally review the feedback from code review/testing and ensure you're reviewing what's committed back to PRs (again, even if using AI to assist). TLDR; don't be a meat proxy. |
|
I have been working on 2 other github projects all day so im on here and always checking my email. I use AI to code but review it before im pushing. If you dont want the help thats cool just let me know. |
@ColinM-sys Is this a comment for me? Seems more of an agent leaving a comment for you but let me know if you're blocked on next steps here and there's things that need discussing. Thanks! |
|
it was written for you but i get it sounds like an internal note? I am not using any agents im not sure why you all keep saying that. I do use them for other things but not this. Also an LLM is different than an agent. Besides the facts: I was trying to respond to what you asked on the 31st. My question: should we implement batch INSERT now or defer to sync layer? |
I think I still stand by my initial comment on that:
If you think it's better to handle that as part of the sync management PR, I'm fine with skipping here, just want to ensure that's captured somewhere so we don't forget |
|
I struggled getting the syntax right earlier. Multi-row placeholders are tricky. A lot more testing would be needed. My preference would be to pass and handle it fresh in sync layer context later in a separate PR. Want me to open a dedicated PR for batch INSERT once this merges? I can handle that separately and you can tag me to track it |
… add/embedding-storage
…n wouldn't be created properly. Remove subtype and dimensions from the key and all prefixes
…ur vectors validate to the range we support. Better error handling when something goes wrong on insert
…n in a few places. Ensure we escape properly in our query
Automated review (high effort)Reviewed the diff against Correctness
Efficiency
Reuse
Simplification / dead code
Altitude
🤖 Generated with Claude Code |
…test the storage of vectors
dkotter
left a comment
There was a problem hiding this comment.
I've done another pass of testing and review, pushing up some changes but I think this is in a good spot now. Likely some adjustments we'll make as we actually wire this up to data but I'm happy with where we're at for now. Thanks for all the effort here @ColinM-sys!
|
@dkotter saw you pushed new fixes while I was working on the same issues. I've got changes addressing the correctness findings too. They're ready locally but conflicting with your recent commits. I can rebase on top of your new code and we can align the approaches. let me know whatt you want me to do. |
|
@ColinM-sys I'm assuming you're referring to the things mentioned in this comment? That comment came in while I was already working on things so it was not something I considered. It appears to just be an automated review so not something I would worry about diving in deep on. Reading through it, I think most of the actual problems I've already addressed and some of the other things it flags I don't think need fixed here. So from my perspective, this is still good to merge as-is |
|
Thanks for making those changes @dkotter. Sounds good! |
What?
See #962 — this is the "storage and CRUD layer for embeddings" checklist item, as a standalone PR off
develop.Adds a
WordPress\AI\Embeddingsnamespace with a portablewpai_embeddingstable and a repository for storing, reading, iterating and deleting embedding vectors. Persistence only: no generation (that's the AI Client) and no similarity search (the next checklist item, built on top of this).Why?
Embedding vectors are only comparable to other vectors produced by the same model (the reason behind php-ai-client#274). The existing experiment PRs each brought their own storage — postmeta in #891/#943, a RAG-specific MariaDB/memory pair in #683 — with the model recorded loosely or assumed from config. #962 asks for a shared foundation that individual features (semantic search #844, frontend chat #142) can build on instead of re-implementing.
This layer makes the model part of every vector's identity: every row carries
provider,modelanddimensions, every read is scoped to a provider + model, and the unique key is(object_type, object_id, provider, model, chunk_index). An index can never be queried with vectors from a different model by accident, and switching models is an explicit re-index (delete_for_model()), not a silent corruption.How?
Embedding_Schema— createswpai_embeddingsviadbDelta, followingAI_Request_Log_Schema(version option,maybe_upgrade_table(), idempotent). Portable column types only; vectors areMEDIUMBLOBof packed float32 with a cachedembedding_norm(so cosine similarity later needs no second pass). Created on the first write, never by a read, so sites that never store an embedding pay nothing.Embedding_Record— immutable value object: object type/ID, chunk index, provider, model, vector, optional content hash. Validates identity fields and the vector.Embedding_Repository_Interface— the contract:save/save_many(upsert, replaces in place),get(chunks in order),get_by_id,get_content_hash(cheap staleness check for the future sync layer),get_object_ids(bounded, newest-first — the lookup from Semantic (vector) search for the AI plugin — fixes, tests & validation atop #891 #943 generalised),count_objects,iterate(keyset-paginated batches for a PHP-side scan),delete_for_object,delete_for_model.Embedding_Repository—$wpdbimplementation of the above. Corrupt rows are skipped rather than fatal.Vector_Codec— pack/unpack little-endian float32, the same byte layout as MariaDB'sVECTORtype, so a native-index backend (Add native vector search #683'sMariaDB_Index_*) can implement the same interface against the same bytes. @artpi — shaped with your backend/repository split in mind; happy to adjust the contract so the MariaDB backend slots in cleanly.Uninstalldrops the table (thewpai_*version option is already covered);docs/DEVELOPER_GUIDE.mdgets a "Storing Embeddings" section.nomic-embed-textvectors (768-dim) and 3072-dim vectors (gemini-embedding-001size).Not in this PR, by design: the
generate_embeddings()wrapper update for php-ai-client#274 (separate, as noted on #962), chunking/sync, similarity utilities.Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Fable 5
Used for: Drafting the classes, tests and docs from a design I specified (table shape, model-scoped identity, interface split vs. #683); every file was reviewed and run by me, and the test/lint runs below were executed locally.
Testing Instructions
npm run wp-env:test startthennpm run test:php -- --filter "Embedding_|Vector_Codec"— 51 tests covering: table creation/idempotence/drop, first-write-creates / reads-don't, save + read round trip, in-place replace on re-index, model and object-type isolation, chunk ordering, content-hash lookup, bounded newest-first IDs with offset, batched iteration, scoped deletes, corrupt-row skipping, 3072-dim round trip, codec byte layout and validation.composer lintandcomposer phpstan— clean for the new files (phpstan's only remaining error on a fresh checkout is the pre-existingRequirements.phpbuild/build.phprequire, which needsnpm run build).wp shellor a snippet,( new \WordPress\AI\Embeddings\Embedding_Repository() )->save( new \WordPress\AI\Embeddings\Embedding_Record( 'post', 1, 'ollama', 'nomic-embed-text:latest', array( 0.1, 0.2, 0.3 ) ) );then checkwp db query "SELECT object_type, object_id, provider, model, dimensions, LENGTH(embedding) FROM wp_wpai_embeddings"→ one row, 12 bytes.Changelog Entry