Repository navigation
[hotfix][integrations][python][docs] Align vector stores with their declared contracts - #1280
Conversation
…didates Elasticsearch requires num_candidates to be at least k for a KNN search, but the constructor rejected exactly that combination and accepted num_candidates below k, which the server then refuses at query time. A store-level k also never took effect: getStoreKwargs exposed it as top_k while queryEmbedding reads k from the merged arguments, so the configured default was silently dropped in favour of the query limit. AI-Contributed/Feature: 0/8 AI-Contributed/UT: 0/72
Mem0VectorStore calls store.search(query="", vectors=[embedding], ...), but VectorStoreBase.search only takes a vectors argument from 0.1.74 onward. The declared floor of 0.1.43 admits versions where every vector query raises TypeError, while insert, update, get, list, delete and delete_col are unaffected because their signatures are identical on both sides of that boundary. Generated-by: Qoder 0.4.4 (Qwen 3.8-Max) Co-Authored-By: Qoder <noreply@qoder.com> AI-Contributed/Feature: 2/2 AI-Contributed/UT: 0/0
The Filter DSL section promised that any implementation receiving an unsupported operator raises NotImplementedError or UnsupportedOperationException. Only Chroma and Milvus check the filter before building a request. Elasticsearch, OpenSearch, S3Vectors and Mem0 forward the value, so a caller writing the documented catch block never reaches it, and S3Vectors stringifies the value rather than failing. Describe what each backend actually does instead of a uniform exception. Generated-by: Qoder 0.4.4 (Qwen 3.8-Max)
sangkyoonnam
left a comment
There was a problem hiding this comment.
Thanks for these fixes. Exporting the store-level k lets it override the query's limit in Java query(): a store with k=8 queried with limit=3 returns 8 documents. Details inline. The Mem0 floor checks out against the published wheels: 0.1.73 declares search(self, query, limit=5, filters=None) and 0.1.74 adds vectors. I also left a wording suggestion on the Filter DSL paragraph.
| // Expose as top_k (preferred name); keep legacy key for compatibility if referenced | ||
| m.put("top_k", this.k); | ||
| // queryEmbedding() reads this under "k"; expose it under the same key | ||
| m.put("k", this.k); |
There was a problem hiding this comment.
queryEmbedding prefers args.k over limit, and the search request sets no size, so exporting the store default here lets it exceed the query's bound. Against Elasticsearch 8.19.0 from tools/docker/elasticsearch with 12 indexed documents, a store with k=8 queried through VectorStoreQuery with limit=3 returns 8 documents at 3921212 and 3 at bc62aa8, where the exported top_k entry was ignored. VectorStoreQuery documents limit as the maximum number of documents to return.
Could this PR keep that bound? The change is one line in queryEmbedding, outside the changed hunks:
SearchRequest.Builder builder =
new SearchRequest.Builder()
.index(index)
+ .size(Math.min(limit, k))
.knn(That leaves k in charge of neighbor selection and bounds the returned hits. It also fixes an older truncation: Elasticsearch defaults size to 10, so a query with no store k and limit=12 returns 10 documents at both commits. With that line added locally, the two cases return 3 and 12, and the four new tests and ElasticsearchVectorStoreTest still pass. A test through query() for each case would cover them.
The constructor check doesn't validate the effective pair. With no store k, num_candidates=5 and a query limit=8 fail at search time with [num_candidates] cannot be less than [k] at both commits. That predates this PR and can be a follow-up.
There was a problem hiding this comment.
Fixed — queryEmbedding now sets .size(Math.min(limit, k)), so k picks neighbours and limit bounds the hits. Added both cases through query(): store k=8 with limit=3 expects 3, no store k with limit=12 expects 12. Not run locally (no Elasticsearch here); CI executes that class against the compose server. Effective-pair validation left as a follow-up.
| ``` | ||
|
|
||
| `None` / `null` means "no filter". Richer operators (ranges, set membership, OR, NOT, etc.) are out of scope here. Callers needing backend-specific operators should pass them through `extra_args` (Python `VectorStoreQuery.extra_args` or `**kwargs`) or `extraArgs` (Java) — for example, ChromaDB's native `where` dict. Implementations that receive an unsupported operator via `filters` raise `NotImplementedError` (Python) or `UnsupportedOperationException` (Java). | ||
| `None` / `null` means "no filter". Richer operators (ranges, set membership, OR, NOT, etc.) are out of scope here. Callers needing backend-specific operators should pass them through `extra_args` (Python `VectorStoreQuery.extra_args` or `**kwargs`) or `extraArgs` (Java) — for example, ChromaDB's native `where` dict. An unsupported operator is not portable. Chroma and Milvus reject it locally with `NotImplementedError` (Python) or `UnsupportedOperationException` (Java); the other backends forward the value, where the service may reject the query or it may silently match nothing. Do not rely on catching a particular exception. |
There was a problem hiding this comment.
Both checks cover only some shapes. Milvus rejects null and map values, so {"$or": [...]} passes and becomes an equality clause against the stringified list. Chroma's helper lets a list value under a plain key through to the ChromaDB client.
| `None` / `null` means "no filter". Richer operators (ranges, set membership, OR, NOT, etc.) are out of scope here. Callers needing backend-specific operators should pass them through `extra_args` (Python `VectorStoreQuery.extra_args` or `**kwargs`) or `extraArgs` (Java) — for example, ChromaDB's native `where` dict. An unsupported operator is not portable. Chroma and Milvus reject it locally with `NotImplementedError` (Python) or `UnsupportedOperationException` (Java); the other backends forward the value, where the service may reject the query or it may silently match nothing. Do not rely on catching a particular exception. | |
| `None` / `null` means "no filter". Richer operators (ranges, set membership, OR, NOT, etc.) are out of scope here. Callers needing backend-specific operators should pass them through `extra_args` (Python `VectorStoreQuery.extra_args` or `**kwargs`) or `extraArgs` (Java) — for example, ChromaDB's native `where` dict. An unsupported operator is not portable. Chroma and Milvus reject some operator forms locally with `NotImplementedError` (Python) or `UnsupportedOperationException` (Java); other forms, and the other backends, can fail later or silently match nothing. Do not rely on catching a particular exception. |
There was a problem hiding this comment.
Applied. Confirmed both gaps: Milvus checks only null and Map, Chroma only a $-prefixed key or a dict value.
…y limit queryEmbedding built the KNN search without a size, so Elasticsearch defaulted it to 10 while k decided how many hits came back. A limit above 10 was truncated, and exporting the store-level k let it widen the result past the caller's limit. Set size to min(limit, k), so k selects the neighbours and limit bounds what is returned. Also narrow the Filter DSL note: Chroma and Milvus reject only some operator shapes, so the previous wording promised a local exception that a list value never triggers. Generated-by: Qoder 0.4.4 (Qwen 3.8-Max) Co-Authored-By: Qoder <noreply@qoder.com> AI-Contributed/Feature: 4/4 AI-Contributed/UT: 88/88
wenjin272
left a comment
There was a problem hiding this comment.
Thanks @yunfengzhou-hub for the fixes, and @sangkyoonnam for the careful review. LGTM
Linked issue: none, every commit here is a hotfix.
Purpose of change
Three vector store defects where the code does not do what its own declaration promises.
num_candidates >= k, which Elasticsearch requires, and accepted the reverse. A store-levelknever took effect, although its javadoc and the parameter table call it an overridable default. Exporting it exposed a third defect: the KNN search set nosize, so hit counts came fromkand Elasticsearch's default of 10, not fromlimit.pyproject.tomldeclaresmem0ai>=0.1.43, but_query_embeddingcallsstore.search(query=..., vectors=[...])andVectorStoreBase.searchonly takesvectorsfrom 0.1.74 on, so an install inside[0.1.43, 0.1.74)fails every vector query withTypeError.NotImplementedErrororUnsupportedOperationException. Chroma and Milvus reject only some shapes; the other four backends do not check.Runtime flow
These two sub-headings cover the Elasticsearch change.
The constructor reads
kandnum_candidatesand rejects one combination. At query timeBaseVectorStore.querymergesgetStoreKwargs()with the query's arguments and passes them toqueryEmbedding, which resolveskagainstlimitand builds one KNN request. A store-levelktherefore reaches a query only under the keyqueryEmbeddingreads, andlimitbounds hits only if the request carries asize.Key decisions
The validation now rejects
num_candidates < k, the combination Elasticsearch documents as invalid.getStoreKwargsexposes the store-levelkaskrather thantop_k, which onlyS3VectorsVectorStorereads, from its own arguments.The KNN request sets
sizetomin(limit, k), sokpicks the neighbours andlimitbounds the result. The siblinggetDocuments(index, filters, extraArgs)already sets asize.Mem0 raises the floor rather than adding a compatibility branch, since only
searchdiffers across the boundary. The Filter DSL documentation is corrected rather than the four backends, since local validation would change which exception a caller catches across four modules in two languages.Behavioral Semantics
Applies to the Elasticsearch change; the Mem0 floor is packaging metadata and the Filter DSL edit is prose.
Interaction decisions
knum_candidateslimitkfalls back tolimit, at most 3 hits>= 8k8 picks the neighbours, at most 3 hits< 8IllegalArgumentExceptionsizeof 10Row 4 is the gap the constructor cannot close: with no store
k, the effective value comes fromlimitat search time.Behavioral contracts
num_candidates >= kconstructs successfully.num_candidates < kis rejected at construction.kreachesqueryEmbeddingwhen the query does not override it.kin the query'sextraArgsoverrides the store-level one.getStoreKwargsno longer containstop_k.limit.limitabove 10 is not truncated to Elasticsearch's defaultsize.Failure behavior
Contract 2 raises
IllegalArgumentExceptionfrom the constructor, the same point in the lifecycle as before with the condition inverted, so an invalid combination fails when the resource is built rather than as an Elasticsearch 400 at query time. Nothing else raises, falls back or retries.Compatibility impact
A configuration that previously failed at query time now fails at construction, and the reverse now works. A caller who set a store-level
kand saw it ignored now gets it applied. Alimitabove 10 now returns up tolimitdocuments instead of 10, so a caller relying on the implicit cap sees more results.getStoreKwargsis public, so readingtop_kfrom the Elasticsearch store now yields nothing; no code here did.An install that would resolve mem0ai inside
[0.1.43, 0.1.74)now fails dependency resolution instead of failing at query time; installs on 0.1.74 or later are unaffected.Tests
num_candidates > kacceptsNumCandidatesGreaterThanKnum_candidates == kacceptsNumCandidatesEqualToKrejectsNumCandidatesLessThanKexposesStoreLevelKUnderTheKeyQueryReadstestStoreLevelKDoesNotExceedQueryLimittestQueryLimitAboveElasticsearchDefaultSizeIsHonoredContracts 1 to 3 and 6 ran against the unfixed source first: the two acceptance tests and the store-kwargs test failed, and the rejection test reported no throwable. Restoring
top_kfails only the last.The Elasticsearch module runs 12 tests and
api769, both with 0 failures. Spotless, Apache RAT and a Hugo 0.110.0 build pass.Not verified:
ElasticsearchVectorStoreTest, gated onES_HOST. They compile and are registered but were not executed here; CI runs that class against the compose server.queryEmbedding, which needs a live cluster. No query ran against a server, so thenum_candidates < kclaim rests on the documented requirement, not an observed 400, and no install was resolved against a mem0ai in[0.1.43, 0.1.74).Supporting evidence
VectorStoreBase.search(self, query, limit=5, filters=None); 0.1.74 and 1.0.11 declaresearch(self, query, vectors, limit=5, filters=None). The base class is the right layer to check because the integration resolves a configurableproviderthroughVectorStoreFactory. Onlysearchdiffers: the six other methods the integration calls, andVectorStoreFactoryitself, are identical on both sides, so a compatibility branch would wrap one call to support versions where vector search cannot work.num_candidates: the vendor documentation states it must be greater than or equal tok.size: omitted from a search request it defaults to 10, andqueryEmbeddingreturns the hits it iterates, sosizebounds the result.NotImplementedErrorfor a$-prefixed key or a dict value, so a list under a plain key passes. Milvus raisesUnsupportedOperationExceptionfor a null orMapvalue and never inspects the key, so a list value becomes an equality clause against its stringified form. Elasticsearch and OpenSearch serialise the value into atermquery without inspecting it. S3Vectors appliesString.valueOfto it, so an operator form becomes a literal that silently matches nothing. Mem0 passesfiltersthrough to a provider the caller chooses, so its semantics are not ours to enforce.filtersToBoolMustJsonbuildsMap.of(field, value), which throwsNullPointerExceptionon a null value. Not addressed here, and independent of the operator-form contract.API
No public API signatures change.
getStoreKwargskeeps its signature and returns one different key for the Elasticsearch store.Documentation
doc-neededdoc-not-neededdoc-includedWas this patch authored or co-authored using generative AI tooling?
Generated-by: Qoder 0.4.4 (Qwen 3.8-Max)