@@ -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
6270end
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
0 commit comments