Repository navigation
[user properties 1/2] Keep undeclared properties out of the vector store - #1702
Conversation
48951bf to
c164c56
Compare
c164c56 to
2dc8c96
Compare
The event backend wrote every property of an event into its vector record and mapped the caller's whole filter onto the vector store, so a user key that a deployment never declared was both stored and filtered there: on Qdrant and Milvus as unindexed payload a filtered query scans for. The segment store already holds every property and already receives the whole filter for the context windows, so the vector side only duplicated work the segment store does anyway. The vector record now carries the keys the collection declares: EventMemory's reserved timestamp, the `_`-prefixed system properties an adapter stamps on the event, and the keys a project's `properties_schema` declares. The vector store is queried with the conjuncts of the filter that name only such fields; a conjunct is dropped whole when any field under it is undeclared, so dropping only ever widens the vector search, and the segment store narrows it back on the windows. An undeclared key never reaches the vector store, so a tenant's undeclared properties cannot shape what it stores or scans. `filter_fields` joins the filter parser: every field name a tree addresses. Rebased onto MemMachine#1631, where the vector record no longer carries the segment uuid (the segment store maps a derivative to its segment). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
6cdbe07 to
b1c3242
Compare
marvinyu-memverge
left a comment
There was a problem hiding this comment.
One ask before I approve, reviewed at b1c3242.
Keeping undeclared keys off the vector record makes sense to me, and the conjunct split is sound: whatever reaches the vector store is implied by the full filter, and the segment store still applies the whole thing, so nothing that fails the filter gets through.
1. A filter on an undeclared key can now come back short, or empty, while matching episodes exist.
Once the m.<key> conjunct is dropped, the vector search returns the top vector_search_limit over the whole collection, and get_segment_contexts then drops every seed whose segment fails the filter. Nothing refills those slots, so matches that aren't in the unfiltered top K never come back. The event backend's collection is per session and a REST v2 session is a whole org/project, and with #1670 on top every m.<key> is undeclared, so "filter on one user's metadata in a big project" is the normal case for this. LTM's 4x over-fetch is sized for episode dedup, so it only softens it.
Repro at this head, on the existing event_memory fixture but with an embedder that separates the two texts (FakeEmbedder maps every input onto the same direction, so the current tests can't see ranking): 10 events "blue thing {i}" with color=blue, one "red thing" with color=red, query "blue thing", vector_search_limit=3, filter m.color = 'red' -> []. With feat/horizontal-scaling's event_memory.py swapped in, the same test returns ["red thing"].
The body says the segment store "narrows it back", which holds for which results come back but not for how many. Either of these would work for me:
- when a conjunct was dropped, keep fetching from the vector store until
vector_search_limitseeds survive the segment filter or the collection runs out, or - if the trade-off is intended (no payload scans on tenant keys), say in the body that a filter on an undeclared key is applied after the top K, and pin it with a test like the one above.
Verified: the record keeps _timestamp plus every declared key, and EVENT_BACKEND_SYSTEM_FIELDS are declared at collection create, so _episode_uid, _producer_id etc. still reach the record and filters on system fields still go to the vector store; field names are mapped before the declared check, so producer_id / timestamp are compared as _producer_id / _timestamp; an undeclared key under OR or NOT drops its whole conjunct, which only widens; filter_fields covers every node type map_filter_fields does. CI green at b1c3242 (35/35).
The vector store got the conjuncts of a filter's top-level AND that name only declared fields, so an OR or a NOT with an undeclared field anywhere under it was dropped whole, and the vector search ran wider than it had to. It now gets the part of the whole tree on declared fields, the way a database planner uses an index for part of a condition: an AND keeps every operand that has such a part, an OR only when every operand has one, and a NOT applies the opposite rule to its operand. So `(a = 1 AND u = 1) OR (a = 2 AND u = 2)` gives `a = 1 OR a = 2`, and `NOT (u = 1 OR a = 1)` gives `NOT a = 1`. Every record that matches the filter still matches the part, and the segment store, which gets the whole filter, narrows the result back as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Taking the part of a filter that names only some fields depends on nothing but the filter and the fields, so it moves next to `filter_fields` and `map_filter_fields` as `extract_filter`, the name PostgreSQL, DataFusion, and Spark give the same operation. `EventMemory._vector_store_filter` had one caller, so `query` now maps the filter's fields and extracts its part on the declared ones itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hout it Rewriting a filter is checked the ways database engines check a planner's rewrites, against the in-memory stores' filter evaluator: - A table of filters and their expected parts covers every node kind under both parities of negation, with each operand kept or dropped, and alone reaches every line and branch of `extract_filter`. - Random filters up to four levels deep, over allowed and other fields and every kind of leaf: every record a filter matches, its part matches, and the part names only allowed fields; a filter on allowed fields alone is its own part; and when each other field appears once, a record matches the part exactly when some values of the other fields would make it match the filter. - Random filters through EventMemory, with the vector store getting the part and with it getting no filter: when the search reaches every record, both answer the filter exactly, and when it reaches fewer, the part only ever adds hits. The random filters and records come from a shared test module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… seeds A vector search returns at most `vector_search_limit` seeds, and a condition on a field the vector store does not declare is applied to those seeds afterward, so a selective one can leave fewer seeds than the limit, or none, while matching segments exist further down the ranking. That bounds a query's cost by the limit. `query`'s docstring now says so, and a test pins it with an embedder that ranks the matching event below the limit: the condition on an undeclared field finds nothing, the same condition finds the event when the search reaches every seed, and a condition on a declared field finds it within the limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@marvinyu-memverge Thanks, the analysis is right, and the trade-off is intended. A query's cost is bounded by
Since your review at
🤖 Written by Claude Code (Claude Opus 5.5) on behalf of @edwinyyyu. |
marvinyu-memverge
left a comment
There was a problem hiding this comment.
Approving at e777aec. Thanks, the docstring, the test and the Purpose paragraph cover it.
I also went through the new extract_filter: flipping which side of an And/Or may be left out under a NOT keeps the part implied by the full filter at any depth, and the random-filter check against the in-memory evaluator is a good way to pin that.
The red unit-test and package jobs at this head aren't from this change. Every build fails in setuptools-scm with "choosing custom numbers for the .devX distance is not supported": the v0.4.1-dev1 tag pushed on feat/horizontal-scaling today is now the nearest tag, and setuptools-scm won't derive a version from a dev tag at distance > 0. Anything based on that branch will hit it until the tag changes.
…agree EventMemory leaves to the segment store every condition the vector store cannot evaluate, so it relies on `get_segment_contexts` leaving out a seed that fails the filter. The SQL store does, but the contract only said the filter applies to the segments, and the in-memory fake kept a rejected seed whenever a neighbor in its window matched. The contract now says a seed that does not match has no entry, the fake checks the seed, and a test pins it through EventMemory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`query` said only conditions on undeclared fields wait for the seeds, but a declared condition in an OR with an undeclared one, or in a negated AND with one, waits too: the vector search applies only the part of the filter that names declared fields alone. And the expected collection schema said declaring a property makes it efficiently filterable, when a property left undeclared is not on the vector record at all, so the search cannot filter on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Building a vector record and extracting the vector store's part of a filter both read the collection's declared schema again, once per derivative for the record. `_schema_fields` already holds those keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fixture's collection declares only system keys, so no test had a user key reaching the vector store; a wrong mapping of `m.<key>` would have dropped its condition from the search with every test passing. A test now declares `color` and checks that the record carries it and that `m.color` reaches the search as `color`. The three tests that spied on the vector store's queries share one helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@marvinyu-memverge Four commits landed after your approval at
CI's unit-test jobs had been failing at install because of the 🤖 Written by Claude Code (Claude Opus 5.5) on behalf of @edwinyyyu. |
d624d75
into
MemMachine:feat/horizontal-scaling
…ore (#1702) * Keep undeclared properties out of the vector store The event backend wrote every property of an event into its vector record and mapped the caller's whole filter onto the vector store, so a user key that a deployment never declared was both stored and filtered there: on Qdrant and Milvus as unindexed payload a filtered query scans for. The segment store already holds every property and already receives the whole filter for the context windows, so the vector side only duplicated work the segment store does anyway. The vector record now carries the keys the collection declares: EventMemory's reserved timestamp, the `_`-prefixed system properties an adapter stamps on the event, and the keys a project's `properties_schema` declares. The vector store is queried with the conjuncts of the filter that name only such fields; a conjunct is dropped whole when any field under it is undeclared, so dropping only ever widens the vector search, and the segment store narrows it back on the windows. An undeclared key never reaches the vector store, so a tenant's undeclared properties cannot shape what it stores or scans. `filter_fields` joins the filter parser: every field name a tree addresses. Rebased onto #1631, where the vector record no longer carries the segment uuid (the segment store maps a derivative to its segment). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn * Send the vector store the part of a filter it can evaluate, at any depth The vector store got the conjuncts of a filter's top-level AND that name only declared fields, so an OR or a NOT with an undeclared field anywhere under it was dropped whole, and the vector search ran wider than it had to. It now gets the part of the whole tree on declared fields, the way a database planner uses an index for part of a condition: an AND keeps every operand that has such a part, an OR only when every operand has one, and a NOT applies the opposite rule to its operand. So `(a = 1 AND u = 1) OR (a = 2 AND u = 2)` gives `a = 1 OR a = 2`, and `NOT (u = 1 OR a = 1)` gives `NOT a = 1`. Every record that matches the filter still matches the part, and the segment store, which gets the whole filter, narrows the result back as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Extract a filter's part on allowed fields in the filter module Taking the part of a filter that names only some fields depends on nothing but the filter and the fields, so it moves next to `filter_fields` and `map_filter_fields` as `extract_filter`, the name PostgreSQL, DataFusion, and Spark give the same operation. `EventMemory._vector_store_filter` had one caller, so `query` now maps the filter's fields and extracts its part on the declared ones itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test filter extraction against an evaluator, and queries with and without it Rewriting a filter is checked the ways database engines check a planner's rewrites, against the in-memory stores' filter evaluator: - A table of filters and their expected parts covers every node kind under both parities of negation, with each operand kept or dropped, and alone reaches every line and branch of `extract_filter`. - Random filters up to four levels deep, over allowed and other fields and every kind of leaf: every record a filter matches, its part matches, and the part names only allowed fields; a filter on allowed fields alone is its own part; and when each other field appears once, a record matches the part exactly when some values of the other fields would make it match the filter. - Random filters through EventMemory, with the vector store getting the part and with it getting no filter: when the search reaches every record, both answer the filter exactly, and when it reaches fewer, the part only ever adds hits. The random filters and records come from a shared test module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * State that a condition on an undeclared field applies to the searched seeds A vector search returns at most `vector_search_limit` seeds, and a condition on a field the vector store does not declare is applied to those seeds afterward, so a selective one can leave fewer seeds than the limit, or none, while matching segments exist further down the ranking. That bounds a query's cost by the limit. `query`'s docstring now says so, and a test pins it with an embedder that ranks the matching event below the limit: the condition on an undeclared field finds nothing, the same condition finds the event when the search reaches every seed, and a condition on a declared field finds it within the limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Say that a seed the filter rejects has no context, and make the fake agree EventMemory leaves to the segment store every condition the vector store cannot evaluate, so it relies on `get_segment_contexts` leaving out a seed that fails the filter. The SQL store does, but the contract only said the filter applies to the segments, and the in-memory fake kept a rejected seed whenever a neighbor in its window matched. The contract now says a seed that does not match has no entry, the fake checks the seed, and a test pins it through EventMemory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Say what the vector search filters on, in EventMemory's docstrings `query` said only conditions on undeclared fields wait for the seeds, but a declared condition in an OR with an undeclared one, or in a negated AND with one, waits too: the vector search applies only the part of the filter that names declared fields alone. And the expected collection schema said declaring a property makes it efficiently filterable, when a property left undeclared is not on the vector record at all, so the search cannot filter on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Use the declared fields EventMemory already holds Building a vector record and extracting the vector store's part of a filter both read the collection's declared schema again, once per derivative for the record. `_schema_fields` already holds those keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test a declared user key on the record and in the search The fixture's collection declares only system keys, so no test had a user key reaching the vector store; a wrong mapping of `m.<key>` would have dropped its condition from the search with every test passing. A test now declares `color` and checks that the record carries it and that `m.color` reaches the search as `color`. The three tests that spied on the vector store's queries share one helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Purpose of the change
The event backend wrote every property of an event into its vector record and mapped the caller's whole filter onto the vector store, so a user key a deployment never declared was both stored and filtered there: on Qdrant and Milvus as unindexed payload a filtered query scans for. Now an undeclared key stays off the vector store, so a tenant's undeclared properties cannot shape what it stores or scans; #1670, stacked on this, removes the per-project declaration, so no tenant key reaches it at all.
The vector record now carries the keys the collection declares: EventMemory's reserved timestamp, the
_-prefixed system properties an adapter stamps on the event, and the keys a project'sproperties_schemadeclares, until #1670 removes that option (collection.config.indexed_properties_schema). The vector store is queried with the part of the filter on such fields, the way a database planner uses an index for part of a condition: an AND keeps every operand that has such a part, an OR only when every operand has one, and a NOT applies the opposite rule to its operand, at any depth. So(a = 1 AND u = 1) OR (a = 2 AND u = 2)givesa = 1 OR a = 2. Every record that matches the filter matches the part, so the vector search is only ever wider, and the segment store narrows it back on the windows. A condition the vector store cannot evaluate is applied to the seeds its search returns, so a selective filter on an undeclared field can return fewer hits thanvector_search_limit, or none, while matching episodes exist further down the ranking. A query's cost stays bounded by the limit; filtering on a key during the search needs the key declared on the store. An undeclared key never reaches the vector store.filter_fieldsandextract_filterjoin the filter parser: every field name a tree addresses, and the part of a filter that names only allowed fields, the name PostgreSQL, DataFusion, and Spark give that operation. The segment store'sget_segment_contextscontract now says that a seed failing the filter has no entry, which EventMemory relies on for every condition the vector store cannot evaluate. Tests: an undeclared property stays on the segment and off the record; a conjunct on an undeclared property reaches only the segment store; anORwith an arm on undeclared fields alone leaves the vector search unfiltered; the vector store gets the declared part of anORofANDs, a negatedOR, a negatedAND, and a negation under a negatedOR;filter_fieldsnames every field under every node.extract_filteris checked against the in-memory stores' filter evaluator: a table of expected parts that alone reaches every line and branch; 2,000 random filters up to four levels deep, every record each one matches also matching its part; a filter on allowed fields alone coming back unchanged; and the part being the tightest possible when each other field appears once. Random filters through EventMemory answer exactly, with and without the vector store's part, when the search reaches every record, and the part only adds hits when the search reaches fewer. With an embedder that ranks the matching event below the limit, a filter on an undeclared field finds nothing, the same filter finds the event when the search reaches every seed, and a filter on a declared field finds it within the limit. A seed the filter rejects is left out even when a neighbor in its context matches, and a user key the collection declares is on the record and reaches the vector search under its bare name.This is the routing half of what was #1628 on
speedkick(its store-side half, the stores rejecting undeclared keys, is deferred to after #1627); it is re-derived onto #1736's collection shape from the same commit, where the segment store, not the vector record, maps a derivative to its segment.Part of the fix for #1781.
Stack
25 open PRs: three independent PRs, and the vector store tree of short parallel branches. Every PR in the tree has
feat/horizontal-scalingas its GitHub base, and the independent PRs havemain. The branches are in a fork, and a pull request can target only this repository's branches, so the on column gives the order the PRs build on each other. A stacked PR's diff on GitHub includes the PRs under it until they merge.Independent of the vector store tree, directly on
main:mainmainmainThe vector store tree. Each PR builds on the one in its on column; PRs on the same parent are parallel branches and do not depend on each other. #1631 is closed, superseded by #1733–#1736, which hold its changes split in four, with review changes since. #1702 and #1670 sit beneath #1627, whose code depends on them. #1684 heads the event memory stack, which its own PRs index; it is listed here because #1828 builds on it. Until the PRs under it merge, their changes show in a stacked PR's diff.
mainfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scalingfeat/horizontal-scalingmemmachine-server migrate, which creates every configured store's schema before servers startThis PR is its 9 commits,
b1c3242fe,e5c2372d9,bbc9c1b8e,8f53209b0,e777aec48,3c68d451c,a82df2c3b,e681e983d,6631b5c77, directly onfeat/horizontal-scaling. #1670 is stacked on it.Verification
At this PR's head
6631b5c77, on 2026-10-13:ruff checkandruff format --checkclean;ty checkclean as CI runs it (uv run --frozen --all-extras ty check --project packages/server);uv lock --checkclean; the episodic memory and filter tests pass, 483 tests (1 skipped). At8f53209b0, each of six deliberately broken versions of the extraction failed those tests. The full suites last passed, with #1670 beneath it, at9031d25f4, on 2026-10-08: the server suite without integration tests, 2166 tests, and the integration tests of the vector stores, the resource manager, episodic memory, and semantic storage, 689 tests, against PostgreSQL 16, Neo4j, Qdrant 1.19.1, and Milvus 2.6.24 in containers.Before the routing at any depth: At this PR's head
b1c3242fe, on 2026-10-09, onfeat/horizontal-scalingwith #1813 merged:ruff checkandruff format --checkclean;ty checkclean as CI runs it (uv run --frozen --all-extras ty check --project packages/server);uv lock --checkclean.🤖 Generated with Claude Code
https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn