Skip to content

Commit 0a04a76

Browse files
authored
Fix search (and vector search) index errors (#6153)
* make sure collection is created before creating indexes * Fix filter error in vector search * Normalize index definitions for comparison * address Copilot feedback
1 parent 91f4e15 commit 0a04a76

3 files changed

Lines changed: 191 additions & 22 deletions

File tree

lib/mongoid/search_indexable.rb

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,18 @@ def vector_search(index: nil, path: nil, limit: 10, num_candidates: nil, filter:
8585
"#{resolved_path} is nil on this document; cannot perform vector search"
8686
end
8787

88-
self_filter = { '_id' => { '$ne' => _id } }
89-
combined_filter = filter ? { '$and' => [ self_filter, filter ] } : self_filter
88+
self_exclusion = { '$match' => { '_id' => { '$ne' => _id } } }
89+
post_pipeline = [ self_exclusion, { '$limit' => limit }, *Array(pipeline) ]
90+
effective_candidates = num_candidates || (limit * 10)
9091

9192
self.class.vector_search(
9293
query_vector,
9394
index: index,
9495
path: path,
95-
limit: limit,
96-
num_candidates: num_candidates,
97-
filter: combined_filter,
98-
pipeline: pipeline
96+
limit: limit + 1,
97+
num_candidates: effective_candidates,
98+
filter: filter,
99+
pipeline: post_pipeline
99100
)
100101
end
101102

@@ -129,19 +130,20 @@ def auto_embed_search(index: nil, path: nil, limit: 10, num_candidates: nil, fil
129130
"#{resolved_path} is nil on this document; cannot perform auto-embed search"
130131
end
131132

132-
self_filter = { '_id' => { '$ne' => _id } }
133-
combined_filter = filter ? { '$and' => [ self_filter, filter ] } : self_filter
133+
self_exclusion = { '$match' => { '_id' => { '$ne' => _id } } }
134+
post_pipeline = [ self_exclusion, { '$limit' => limit }, *Array(pipeline) ]
135+
effective_candidates = num_candidates || (limit * 10)
134136

135137
self.class.auto_embed_search(
136138
text,
137139
index: index,
138140
path: path,
139-
limit: limit,
140-
num_candidates: num_candidates,
141-
filter: combined_filter,
141+
limit: limit + 1,
142+
num_candidates: effective_candidates,
143+
filter: filter,
142144
exact: exact,
143145
model: model,
144-
pipeline: pipeline
146+
pipeline: post_pipeline
145147
)
146148
end
147149

spec/mongoid/search_indexable_spec.rb

Lines changed: 172 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ def collection
1717

1818
# Wait for all of the indexes with the given names to be ready; then return
1919
# the list of index definitions corresponding to those names.
20-
def wait_for(*names, &condition)
20+
def wait_for(*names, timeout: 300, &condition)
2121
names.flatten!
2222

23-
timeboxed_wait do
23+
timeboxed_wait(max: timeout) do
2424
result = collection.search_indexes
2525
return filter_results(result, names) if names.all? { |name| ready?(result, name, &condition) }
2626
end
@@ -36,6 +36,14 @@ def wait_for_absence_of(*names)
3636
end
3737
end
3838

39+
# Atlas normalizes latestDefinition by adding "fields"=>{} inside mappings
40+
# even when no fields were declared. Strip it before comparing against specs.
41+
def normalize_definition(defn)
42+
return defn unless defn.is_a?(Hash) && defn['mappings'].is_a?(Hash)
43+
44+
defn.merge('mappings' => defn['mappings'].reject { |k, v| k.to_s == 'fields' && v == {} })
45+
end
46+
3947
private
4048

4149
def timeboxed_wait(step: 5, max: 300)
@@ -57,7 +65,7 @@ def ready?(list, name, &condition)
5765
end
5866

5967
def filter_results(result, names)
60-
result.select { |index| names.include?(index['name']) }
68+
names.filter_map { |name| result.find { |index| index['name'] == name } }
6169
end
6270
end
6371

@@ -309,6 +317,153 @@ def filter_results(result, names)
309317
end
310318
end
311319

320+
describe '#vector_search pipeline construction' do
321+
let(:model) do
322+
Class.new do
323+
include Mongoid::Document
324+
325+
store_in collection: BSON::ObjectId.new.to_s
326+
field :embedding, type: Array
327+
vector_search_index fields: [ { type: 'vector', path: 'embedding', numDimensions: 3, similarity: 'cosine' } ]
328+
end
329+
end
330+
331+
let(:fake_collection) { instance_double(Mongo::Collection) }
332+
let(:fake_cursor) { double(map: []) }
333+
let(:doc) { model.new(embedding: [ 0.1, 0.2, 0.3 ]) }
334+
335+
before do
336+
allow(model).to receive(:collection).and_return(fake_collection)
337+
allow(fake_collection).to receive(:aggregate).and_return(fake_cursor)
338+
end
339+
340+
it 'passes limit + 1 to $vectorSearch so the post-filter never short-counts' do
341+
expect(fake_collection).to receive(:aggregate) do |pipeline|
342+
vs = pipeline.find { |s| s['$vectorSearch'] }
343+
expect(vs['$vectorSearch']['limit']).to eq 6
344+
fake_cursor
345+
end
346+
347+
doc.vector_search(limit: 5)
348+
end
349+
350+
it 'does not use filter in $vectorSearch for self-exclusion' do
351+
expect(fake_collection).to receive(:aggregate) do |pipeline|
352+
vs = pipeline.find { |s| s['$vectorSearch'] }
353+
expect(vs['$vectorSearch']).not_to have_key('filter')
354+
fake_cursor
355+
end
356+
357+
doc.vector_search(limit: 5)
358+
end
359+
360+
it 'adds a $match stage after $vectorSearch to exclude self' do
361+
expect(fake_collection).to receive(:aggregate) do |pipeline|
362+
match = pipeline.find { |s| s['$match'] }
363+
expect(match).to eq({ '$match' => { '_id' => { '$ne' => doc.id } } })
364+
fake_cursor
365+
end
366+
367+
doc.vector_search(limit: 5)
368+
end
369+
370+
it 'adds a $limit stage after $match to cap results at the requested limit' do
371+
expect(fake_collection).to receive(:aggregate) do |pipeline|
372+
match_idx = pipeline.index { |s| s['$match'] }
373+
limit_stage = pipeline[match_idx + 1]
374+
expect(limit_stage).to eq({ '$limit' => 5 })
375+
fake_cursor
376+
end
377+
378+
doc.vector_search(limit: 5)
379+
end
380+
381+
it 'passes a user-provided filter through to $vectorSearch' do
382+
user_filter = { 'status' => 'published' }
383+
384+
expect(fake_collection).to receive(:aggregate) do |pipeline|
385+
vs = pipeline.find { |s| s['$vectorSearch'] }
386+
expect(vs['$vectorSearch']['filter']).to eq user_filter
387+
fake_cursor
388+
end
389+
390+
doc.vector_search(limit: 5, filter: user_filter)
391+
end
392+
end
393+
394+
describe '#auto_embed_search pipeline construction' do
395+
let(:model) do
396+
Class.new do
397+
include Mongoid::Document
398+
399+
store_in collection: BSON::ObjectId.new.to_s
400+
auto_embed_field :description, model: 'voyage-4'
401+
end
402+
end
403+
404+
let(:fake_collection) { instance_double(Mongo::Collection) }
405+
let(:fake_cursor) { double(map: []) }
406+
let(:doc) { model.new(description: 'hello world') }
407+
408+
before do
409+
allow(model).to receive(:collection).and_return(fake_collection)
410+
allow(fake_collection).to receive(:aggregate).and_return(fake_cursor)
411+
end
412+
413+
it 'passes limit + 1 to $vectorSearch so the post-filter never short-counts' do
414+
expect(fake_collection).to receive(:aggregate) do |pipeline|
415+
vs = pipeline.find { |s| s['$vectorSearch'] }
416+
expect(vs['$vectorSearch']['limit']).to eq 6
417+
fake_cursor
418+
end
419+
420+
doc.auto_embed_search(limit: 5)
421+
end
422+
423+
it 'does not use filter in $vectorSearch for self-exclusion' do
424+
expect(fake_collection).to receive(:aggregate) do |pipeline|
425+
vs = pipeline.find { |s| s['$vectorSearch'] }
426+
expect(vs['$vectorSearch']).not_to have_key('filter')
427+
fake_cursor
428+
end
429+
430+
doc.auto_embed_search(limit: 5)
431+
end
432+
433+
it 'adds a $match stage after $vectorSearch to exclude self' do
434+
expect(fake_collection).to receive(:aggregate) do |pipeline|
435+
match = pipeline.find { |s| s['$match'] }
436+
expect(match).to eq({ '$match' => { '_id' => { '$ne' => doc.id } } })
437+
fake_cursor
438+
end
439+
440+
doc.auto_embed_search(limit: 5)
441+
end
442+
443+
it 'adds a $limit stage after $match to cap results at the requested limit' do
444+
expect(fake_collection).to receive(:aggregate) do |pipeline|
445+
match_idx = pipeline.index { |s| s['$match'] }
446+
limit_stage = pipeline[match_idx + 1]
447+
expect(limit_stage).to eq({ '$limit' => 5 })
448+
fake_cursor
449+
end
450+
451+
doc.auto_embed_search(limit: 5)
452+
end
453+
454+
it 'passes a user-provided filter through to $vectorSearch' do
455+
user_filter = { 'status' => 'published' }
456+
457+
expect(fake_collection).to receive(:aggregate) do |pipeline|
458+
vs = pipeline.find { |s| s['$vectorSearch'] }
459+
expect(vs['$vectorSearch']['filter']).to eq user_filter
460+
fake_cursor
461+
end
462+
463+
doc.auto_embed_search(limit: 5, filter: user_filter)
464+
end
465+
end
466+
312467
# Atlas integration tests — skipped when ATLAS_URI is not set.
313468

314469
context 'Atlas integration' do
@@ -332,7 +487,7 @@ def filter_results(result, names)
332487
let(:requested_definitions) { model.search_index_specs.map { |spec| spec[:definition].with_indifferent_access } }
333488
let(:index_names) { model.create_search_indexes }
334489
let(:actual_indexes) { helper.wait_for(*index_names) }
335-
let(:actual_definitions) { actual_indexes.map { |i| i['latestDefinition'] } }
490+
let(:actual_definitions) { actual_indexes.map { |i| helper.normalize_definition(i['latestDefinition']) } }
336491

337492
describe '.create_search_indexes' do
338493
it 'creates the indexes' do
@@ -343,10 +498,10 @@ def filter_results(result, names)
343498
describe '.search_indexes' do
344499
before { actual_indexes } # wait for the indices to be created
345500

346-
let(:queried_definitions) { model.search_indexes.map { |i| i['latestDefinition'] } }
501+
let(:queried_definitions) { model.search_indexes.map { |i| helper.normalize_definition(i['latestDefinition']) } }
347502

348503
it 'queries the available search indexes' do
349-
expect(queried_definitions).to eq requested_definitions
504+
expect(queried_definitions).to match_array(requested_definitions)
350505
end
351506
end
352507

@@ -390,11 +545,19 @@ def filter_results(result, names)
390545
let(:vector_helper) { SearchIndexHelper.new(vector_model) }
391546

392547
# Three orthogonal unit vectors as a minimal, predictable dataset.
393-
let!(:doc_a) { vector_model.create!(embedding: [ 1.0, 0.0, 0.0 ]) }
394-
let!(:doc_b) { vector_model.create!(embedding: [ 0.0, 1.0, 0.0 ]) }
395-
let!(:doc_c) { vector_model.create!(embedding: [ 0.0, 0.0, 1.0 ]) }
548+
let(:doc_a) { vector_model.create!(embedding: [ 1.0, 0.0, 0.0 ]) }
549+
let(:doc_b) { vector_model.create!(embedding: [ 0.0, 1.0, 0.0 ]) }
550+
let(:doc_c) { vector_model.create!(embedding: [ 0.0, 0.0, 1.0 ]) }
396551

397552
before do
553+
vector_helper # force collection to be dropped, created
554+
555+
# once the collection has been recreated, populate it with documents
556+
doc_a
557+
doc_b
558+
doc_c
559+
560+
# prepare the search indexes
398561
names = vector_model.create_search_indexes
399562
vector_helper.wait_for(*names)
400563
end

spec/spec_helper.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,11 @@ def test_model(name: 'TestModel', &block)
101101
heartbeat_frequency: 180,
102102
user: SpecConfig.instance.uri.client_options[:user] || MONGOID_ROOT_USER.name,
103103
password: SpecConfig.instance.uri.client_options[:password] || MONGOID_ROOT_USER.password,
104-
auth_source: Mongo::Database::ADMIN
104+
auth_source: Mongo::Database::ADMIN,
105+
106+
# so that we can run an Atlas cluster locally, and run tests with Atlas
107+
# support enabled.
108+
direct_connection: SpecConfig.instance.uri.uri_options[:direct_connection]
105109
)
106110
}
107111
},

0 commit comments

Comments
 (0)