Skip to content

Commit 56a2d57

Browse files
MB-27666: Fix nested mode handling for field-implicit queries (#2272)
- Fix nested-mode search for field-implicit queries (`match_all`, `docID`): These queries previously filtered nested documents at the searcher level, but since `ExtractFields` returned no fields, the nested collector was never activated. In compound queries (e.g., Boolean filters), this caused a level mismatch—searcher filtering vs nested-level execution—resulting in zero matches. - Unify filtering at collector level: Introduced `HasID()` and `HasAll()` flags in `FieldSet` to track field-implicit queries. Removed searcher-level filtering from `MatchAllSearcher`. - Nested collector now correctly activates, ensuring consistent behavior regardless of query composition. - Fix an edge case where usage of `match_all query` as a sub-clause in a `NestedConjunctionSearcher` could cause a panic. - Fix a case where root document fields would get excluded from the `_all` field even when included. - Fix outdated information in `hierarchy.md` - Add unit tests for Hierarchical Nested Vector Search.
1 parent ca7d4ab commit 56a2d57

11 files changed

Lines changed: 454 additions & 75 deletions

File tree

docs/hierarchy.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Hierarchical nested search
22

3-
* *v2.6.0* (and after) will come with support for **Array indexing and hierarchy search**.
3+
* *v2.6.0* (and after) will come with support for **Array indexing and hierarchical nested search**.
44
* We've achieved this by embedding nested documents within our bleve (scorch) indexes.
55
* Usage of zap file format: [v17](https://github.com/blevesearch/zapx/blob/master/zap.md). Here we preserve hierarchical document relationships within segments, continuing to conform to the segmented architecture of *scorch*.
66

@@ -146,7 +146,7 @@
146146
}
147147
```
148148

149-
* Any Bleve query (e.g., match, phrase, term, fuzzy, numeric/date range etc.) can be executed against fields within nested documents, with no special handling required. The query processor will automatically traverse the nested structures to find matches. Additional search constructs
149+
* Any Bleve query (e.g., `match`, `phrase`, `term`, `fuzzy`, `numeric/date range` etc.) can be executed against fields within nested documents, with no special handling required. The query processor will automatically traverse the nested structures to find matches. Additional search constructs
150150
like vector search, synonym search, hybrid and pre-filtered vector search integrate seamlessly with hierarchy search.
151151

152152
* Conjunction Queries (AND queries) and other queries that depend on term co-occurrence within the same hierarchical context will respect the boundaries of nested documents. This means that terms must appear within the same nested object to be considered a match. For example, a conjunction query searching for an employee named "Alice" with the role "Engineer" within the "Engineering" department will only return results where both name and role terms are found within the same employee object, which is itself within a "Engineering" department object.
@@ -156,11 +156,11 @@ like vector search, synonym search, hybrid and pre-filtered vector search integr
156156

157157
* Nested Faceting / Aggregations: Facets are computed within matched nested objects, producing context-aware buckets. E.g., a facet on `departments.projects.status` returns ongoing or completed only for projects in matched departments.
158158

159-
* Sorting by Nested Fields: Sorting can use fields from the relevant nested object, e.g., ordering companies by `departments.budget sorts` based on the budget of the specific matched department, not unrelated departments.
159+
* Sorting by Nested Fields: Sorting can use fields from the relevant nested object, e.g., ordering companies by `departments.budget` sorts based on the budget of the specific matched department, not unrelated departments.
160160

161-
* Vector Search (KNN / Multi-KNN): When an array of objects is marked as nested and contains vector fields, each vector is treated as belonging to its own nested document. Vector similarity is computed only within the same nested object, not across siblings. For example, if `departments.employees` is a nested array where each employee has a `skills_vector`, a KNN search using the embedding of `machine learning engineer` will match only employees whose own `skills_vector` is similar; other employees vectors within the same department or document do not contribute to the score or match. This also means that a vector search query for `K = 3` will return the top 3 most similar employees across all departments and all companies, and may return multiple employees from the same department or company if they rank among the top 3 most similar overall.
161+
* Vector Search (KNN / Multi-KNN): When a document contains an array of objects with vector/multi-vector fields, the final document score and ranking are identical whether or not the array is marked as `nested`. In both cases, the highest-scoring vector is selected; either directly from the array (non-nested) or from the best-matching nested object with its score bubbled up to the parent document.
162162

163-
* Pre-Filtered Vector Search: When vector search is combined with filters on fields inside a nested array, the filters are applied first to pick which nested items are eligible. The vector search then runs only on those filtered items. For example, if `departments.employees` is a `nested` array, a pre-filtered KNN query for employees with the role `Manager` in the `Sales` department will first narrow the candidate set to only employees who meet those field conditions, and then compute vector similarity on the `skills_vector` of that filtered subset. This ensures that vector search results come only from the employees that satisfy the filter, while still treating each employee as an independent vector candidate.
163+
* Pre-Filtered Vector Search: When vector search is combined with filters on fields inside a nested array, the filters are applied first to pick which nested items are eligible. Vector similarity is then computed only on the vector fields of those filtered nested objects. For example, if `departments.employees` is a `nested` array, a pre-filtered KNN query for employees with a `skills_vector` matching `machine learning engineer`, a role of `Manager`, and belonging to the `Sales` department will first narrow the candidate set to only employees who meet the requirement, and then compute vector similarity on the `skills_vector` of that filtered subset. This ensures that vector search results come only from the employees that satisfy the filter, and not from unrelated employees in other departments.
164164

165165
## Indexing
166166

document/document.go

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import (
1818
"fmt"
1919
"reflect"
2020

21-
"github.com/blevesearch/bleve/v2/search"
2221
"github.com/blevesearch/bleve/v2/size"
2322
index "github.com/blevesearch/bleve_index_api"
2423
)
@@ -164,27 +163,6 @@ func (d *Document) AddNestedDocument(doc *Document) {
164163
d.NestedDocuments = append(d.NestedDocuments, doc)
165164
}
166165

167-
func (d *Document) NestedFields() search.FieldSet {
168-
if len(d.NestedDocuments) == 0 {
169-
return nil
170-
}
171-
fieldSet := search.NewFieldSet()
172-
var collectFields func(index.Document)
173-
collectFields = func(doc index.Document) {
174-
// Add all field names from this nested document
175-
doc.VisitFields(func(field index.Field) {
176-
fieldSet.AddField(field.Name())
177-
})
178-
// Recursively collect from this document's nested documents
179-
if nd, ok := doc.(index.NestedDocument); ok {
180-
nd.VisitNestedDocuments(collectFields)
181-
}
182-
}
183-
// Start collection from nested documents only (not root document)
184-
d.VisitNestedDocuments(collectFields)
185-
return fieldSet
186-
}
187-
188166
func (d *Document) VisitNestedDocuments(visitor func(doc index.Document)) {
189167
for _, doc := range d.NestedDocuments {
190168
visitor(doc)

index_impl.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1569,7 +1569,7 @@ func (i *indexImpl) buildTopNCollector(ctx context.Context, req *SearchRequest,
15691569
if err != nil {
15701570
return nil, err
15711571
}
1572-
if nm.IntersectsPrefix(fs) {
1572+
if fs.HasID() || nm.IntersectsPrefix(fs) {
15731573
return newNestedCollector(nr), nil
15741574
}
15751575
}

mapping/index.go

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -376,13 +376,7 @@ func (im *IndexMappingImpl) MapDocument(doc *document.Document, data interface{}
376376
// see if the _all field was disabled
377377
allMapping, _ := docMapping.documentMappingForPath("_all")
378378
if allMapping == nil || allMapping.Enabled {
379-
excludedFromAll := walkContext.excludedFromAll
380-
nf := doc.NestedFields()
381-
if nf != nil {
382-
// if the document has any nested fields, exclude them from _all
383-
excludedFromAll = append(excludedFromAll, nf.Slice()...)
384-
}
385-
field := document.NewCompositeFieldWithIndexingOptions("_all", true, []string{}, excludedFromAll, index.IndexField|index.IncludeTermVectors)
379+
field := document.NewCompositeFieldWithIndexingOptions("_all", true, []string{}, walkContext.excludedFromAll, index.IndexField|index.IncludeTermVectors)
386380
doc.AddField(field)
387381
}
388382
doc.SetIndexed()

search/query/conjunction.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ func (q *ConjunctionQuery) Searcher(ctx context.Context, i index.IndexReader, m
106106
// if we have common depth == max depth then we can just use
107107
// the normal conjunction searcher, as all fields share the same
108108
// nested context, otherwise we need to use the nested conjunction searcher
109+
// also, if we are querying the _all or _id fields, we need to use
110+
// the nested conjunction searcher as well, with common depth 0
111+
// indicating matches happen only at the root level
112+
if qfs.HasAll() || qfs.HasID() {
113+
commonDepth = 0
114+
}
109115
if commonDepth < maxDepth {
110116
return searcher.NewNestedConjunctionSearcher(ctx, i, ss, commonDepth, options)
111117
}

search/query/query.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,11 @@ func ExtractFields(q Query, m mapping.IndexMapping, fs search.FieldSet) (search.
502502
break
503503
}
504504
}
505+
case *DocIDQuery, *MatchAllQuery:
506+
if fs == nil {
507+
fs = search.NewFieldSet()
508+
}
509+
fs.AddField("_id")
505510
}
506511
return fs, err
507512
}

search/query/query_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,18 @@ func TestExtractFields(t *testing.T) {
10171017
}`,
10181018
expFields: []string{"text"},
10191019
},
1020+
{
1021+
query: `{
1022+
"match_all": {}
1023+
}`,
1024+
expFields: []string{"_id"},
1025+
},
1026+
{
1027+
query: `{
1028+
"ids": ["a", "b", "c"]
1029+
}`,
1030+
expFields: []string{"_id"},
1031+
},
10201032
}
10211033

10221034
m := mapping.NewIndexMapping()

search/searcher/search_match_all.go

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ type MatchAllSearcher struct {
3636
reader index.DocIDReader
3737
scorer *scorer.ConstantScorer
3838
count uint64
39-
nested bool
40-
ancestors []index.AncestorID
4139
}
4240

4341
func NewMatchAllSearcher(ctx context.Context, indexReader index.IndexReader, boost float64, options search.SearcherOptions) (*MatchAllSearcher, error) {
@@ -52,15 +50,11 @@ func NewMatchAllSearcher(ctx context.Context, indexReader index.IndexReader, boo
5250
}
5351
scorer := scorer.NewConstantScorer(1.0, boost, options)
5452

55-
// check if we are in nested mode
56-
nested, _ := ctx.Value(search.NestedSearchKey).(bool)
57-
5853
return &MatchAllSearcher{
5954
indexReader: indexReader,
6055
reader: reader,
6156
scorer: scorer,
6257
count: count,
63-
nested: nested,
6458
}, nil
6559
}
6660

@@ -82,23 +76,6 @@ func (s *MatchAllSearcher) SetQueryNorm(qnorm float64) {
8276
s.scorer.SetQueryNorm(qnorm)
8377
}
8478

85-
func (s *MatchAllSearcher) isNested(id index.IndexInternalID) bool {
86-
// if not running in nested mode, always return false
87-
if !s.nested {
88-
return false
89-
}
90-
var err error
91-
// check if this doc has ancestors, if so it is nested
92-
if nr, ok := s.reader.(index.NestedReader); ok {
93-
s.ancestors, err = nr.Ancestors(id, s.ancestors[:0])
94-
if err != nil {
95-
return false
96-
}
97-
return len(s.ancestors) > 1
98-
}
99-
return false
100-
}
101-
10279
func (s *MatchAllSearcher) Next(ctx *search.SearchContext) (*search.DocumentMatch, error) {
10380
id, err := s.reader.Next()
10481
if err != nil {
@@ -109,11 +86,6 @@ func (s *MatchAllSearcher) Next(ctx *search.SearchContext) (*search.DocumentMatc
10986
return nil, nil
11087
}
11188

112-
if s.isNested(id) {
113-
// if nested then skip and get next
114-
return s.Next(ctx)
115-
}
116-
11789
// score match
11890
docMatch := s.scorer.Score(ctx, id)
11991
// return doc match
@@ -131,11 +103,6 @@ func (s *MatchAllSearcher) Advance(ctx *search.SearchContext, ID index.IndexInte
131103
return nil, nil
132104
}
133105

134-
if s.isNested(id) {
135-
// if nested then return next
136-
return s.Next(ctx)
137-
}
138-
139106
// score match
140107
docMatch := s.scorer.Score(ctx, id)
141108

search/util.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -291,11 +291,14 @@ func (fs FieldSet) AddField(field string) {
291291
fs[field] = struct{}{}
292292
}
293293

294-
// Slice returns the fields in this set as a slice of strings.
295-
func (fs FieldSet) Slice() []string {
296-
rv := make([]string, 0, len(fs))
297-
for field := range fs {
298-
rv = append(rv, field)
299-
}
300-
return rv
294+
// HasID returns true if the field set contains the "_id" field.
295+
func (fs FieldSet) HasID() bool {
296+
_, ok := fs["_id"]
297+
return ok
298+
}
299+
300+
// HasAll returns true if the field set contains the "_all" field.
301+
func (fs FieldSet) HasAll() bool {
302+
_, ok := fs["_all"]
303+
return ok
301304
}

0 commit comments

Comments
 (0)