-
Type:
Improvement
-
Resolution: Unresolved
-
Priority:
Major - P3
-
None
-
Affects Version/s: None
-
Component/s: ABX
TL;DR
In Mastra's MongoDB store, metadata filters on keyword (text) searches are applied only after the search finds its matches. Apply them inside the Atlas Search query instead, for textQuery and both forms of hybridQuery, on managed and bring-your-own collections.
Context
Mastra is a TypeScript framework for building AI agents. Its MongoDB store searches by meaning (vector search), by keywords (text search), or both combined (hybrid search). Every search accepts a metadata filter, such as price < 1500 and inStock = true.
The store either keeps data in a collection it manages, where filterable values sit under metadata, or indexes a collection you already own (bring-your-own collection, or BYOC), where fields sit at the top level.
Native pre-filtering means the database applies the filter while it searches. The alternative is that the search runs first and non-matching results are removed afterward.
- Vector search pre-filters fields declared in filterFields (BYOC support is tracked in NODE-7967).
- Text search runs the keyword search first and then filters the results. Atlas Search supports filtering during the search through the filter clause of its compound operator, but Mastra doesn't use it.
Today the database scores and loads every keyword match, even when the filter discards most of them. MongoDB's docs recommend avoiding this pattern for performance.
Desired User Experience
Before: filters on text search run after the keyword search, on every surface.
// Managed collection: fields are stored under `metadata`. await store.createSearchIndex({ indexName: 'productCatalog', fields: ['metadata.name', 'metadata.description'], // Only text fields can be declared. }); const filter = { price: { $lt: 1500 }, inStock: true }; // 1) Text search await store.textQuery({ indexName: 'productCatalog', query: 'laptop video editing', paths: ['metadata.name', 'metadata.description'], filter, // Applied after the keyword search. topK: 10, }); // 2) Hybrid search, flat form await store.hybridQuery({ indexName: 'productCatalog', queryText: 'laptop for video editing', // Vector side. query: 'laptop video editing', // Text side. paths: ['metadata.name', 'metadata.description'], filter, // Vector side: during the search. Text side: after. topK: 10, }); // 3) Hybrid search, branch form await store.hybridQuery({ indexName: 'productCatalog', vector: { query: 'laptop for video editing' }, text: { query: 'laptop video editing', paths: ['metadata.name', 'metadata.description'] }, filter, // Vector side: during the search. Text side: after. topK: 10, });
After: the same calls filter during the text search, once the filter fields are declared.
// Managed collection await store.createSearchIndex({ indexName: 'productCatalog', fields: ['metadata.name', 'metadata.description'], filterFields: ['price', 'inStock'], // Proposed option, name not final. Registered under `metadata.`. }); // BYOC: fields at the top level, declared as written. await store.createSearchIndex({ indexName: 'products', fields: ['name', 'description'], filterFields: ['price', 'inStock'], // Proposed option. Registered as `price`, `inStock`. }); // 1, 2 and 3 above are unchanged, and the filter is now applied during the text search. // 4) BYOC with an existing full-text index passed through `text.searchIndexName` await store.hybridQuery({ indexName: 'products', vector: { query: 'laptop for video editing' }, text: { query: 'laptop video editing', paths: ['name', 'description'], searchIndexName: 'products_text', // Created outside Mastra. }, filter, // Applied during the search if this index maps `price` and `inStock`, otherwise after. topK: 10, });
Filters that can't run during the search, such as an undeclared field or an operator Atlas Search can't express, still return correct results by filtering afterward, as they do today.
Task
Goal: Text search and the text side of hybrid search apply metadata filters while searching, matching how vector search already behaves.
Where: textQuery, hybridQuery and createSearchIndex in stores/mongodb/src/vector/index.ts. The text-search index that createIndex creates by default may also need to support declared filter fields.
Acceptance Criteria
- [ ] On an Atlas cluster, the examples above run with the filter applied inside the text search, on both a managed and a BYOC collection.
- [ ] Results are the same as the current behavior for the same data and filter, including when most keyword matches fail the filter.
- [ ] Undeclared fields and unsupported operators still return correct results, with no error and no dropped matches.
- [ ] Existing text indexes, and calls without a filter, keep working unchanged.
- [ ] A follow-up ticket exists to document the new option and the pre-filter behavior for text and hybrid search.
Additional Context
- Why use compound.filter: MongoDB recommends it for conditions that don't affect the score, such as equals, range and in, rather than filtering after $search. See the compound operator and query performance docs.
- Why filter fields must be declared: a field can only be used in compound.filter if the text index maps it with a matching type, for example a number for price. That's why the example declares them on the index.
- Filter syntax: Atlas Search filters use different syntax from the MongoDB query filters Mastra accepts. Caller filters must still be treated only as values, never as query structure.
- Related: NODE-7967 and PENGINT-282 cover the vector side for BYOC.