noiseless 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE.txt +28 -0
- data/README.md +214 -0
- data/lib/application_search.rb +15 -0
- data/lib/noiseless/adapter.rb +313 -0
- data/lib/noiseless/adapters/elasticsearch.rb +70 -0
- data/lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb +188 -0
- data/lib/noiseless/adapters/execution_modules/opensearch_execution.rb +377 -0
- data/lib/noiseless/adapters/execution_modules/pgvector_support.rb +219 -0
- data/lib/noiseless/adapters/execution_modules/postgresql_execution.rb +461 -0
- data/lib/noiseless/adapters/execution_modules/typesense_execution.rb +472 -0
- data/lib/noiseless/adapters/open_search.rb +208 -0
- data/lib/noiseless/adapters/postgresql.rb +171 -0
- data/lib/noiseless/adapters/typesense.rb +70 -0
- data/lib/noiseless/adapters.rb +14 -0
- data/lib/noiseless/ast/aggregation.rb +56 -0
- data/lib/noiseless/ast/bool.rb +16 -0
- data/lib/noiseless/ast/bulk.rb +18 -0
- data/lib/noiseless/ast/collapse.rb +16 -0
- data/lib/noiseless/ast/combined_fields.rb +33 -0
- data/lib/noiseless/ast/conversation.rb +29 -0
- data/lib/noiseless/ast/filter.rb +15 -0
- data/lib/noiseless/ast/hybrid.rb +35 -0
- data/lib/noiseless/ast/image_query.rb +29 -0
- data/lib/noiseless/ast/join.rb +31 -0
- data/lib/noiseless/ast/match.rb +15 -0
- data/lib/noiseless/ast/multi_match.rb +24 -0
- data/lib/noiseless/ast/paginate.rb +15 -0
- data/lib/noiseless/ast/prefix.rb +15 -0
- data/lib/noiseless/ast/range.rb +18 -0
- data/lib/noiseless/ast/root.rb +69 -0
- data/lib/noiseless/ast/search_after.rb +14 -0
- data/lib/noiseless/ast/sort.rb +15 -0
- data/lib/noiseless/ast/vector.rb +27 -0
- data/lib/noiseless/ast/wildcard.rb +15 -0
- data/lib/noiseless/ast.rb +30 -0
- data/lib/noiseless/bulk_importer.rb +195 -0
- data/lib/noiseless/callbacks.rb +138 -0
- data/lib/noiseless/connection_manager.rb +26 -0
- data/lib/noiseless/document_manager.rb +137 -0
- data/lib/noiseless/dsl.rb +107 -0
- data/lib/noiseless/generators/application_search_generator.rb +24 -0
- data/lib/noiseless/instrumentation.rb +174 -0
- data/lib/noiseless/introspection/console.rb +228 -0
- data/lib/noiseless/introspection/query_visualizer.rb +533 -0
- data/lib/noiseless/introspection.rb +221 -0
- data/lib/noiseless/mapping.rb +253 -0
- data/lib/noiseless/mapping_definition_processor.rb +231 -0
- data/lib/noiseless/model.rb +111 -0
- data/lib/noiseless/model_registry.rb +77 -0
- data/lib/noiseless/multi_search.rb +244 -0
- data/lib/noiseless/pagination.rb +375 -0
- data/lib/noiseless/query_builder.rb +284 -0
- data/lib/noiseless/railtie.rb +35 -0
- data/lib/noiseless/response/aggregations.rb +46 -0
- data/lib/noiseless/response/empty.rb +20 -0
- data/lib/noiseless/response/records.rb +94 -0
- data/lib/noiseless/response/results.rb +110 -0
- data/lib/noiseless/response/suggestions.rb +55 -0
- data/lib/noiseless/response.rb +98 -0
- data/lib/noiseless/response_factory.rb +32 -0
- data/lib/noiseless/runtime_reset_middleware.rb +15 -0
- data/lib/noiseless/search_index_update_job.rb +84 -0
- data/lib/noiseless/test_case.rb +230 -0
- data/lib/noiseless/test_helper.rb +295 -0
- data/lib/noiseless/version.rb +2 -2
- data/lib/noiseless.rb +130 -2
- data/lib/tasks/benchmark.rake +35 -0
- data/lib/tasks/release.rake +22 -0
- data/lib/tasks/test.rake +11 -0
- metadata +260 -14
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Noiseless
|
|
4
|
+
module Pagination
|
|
5
|
+
DEFAULT_PER_PAGE = 20
|
|
6
|
+
MAX_PER_PAGE = 100
|
|
7
|
+
|
|
8
|
+
# Simple paginated array wrapper - no external dependencies
|
|
9
|
+
class PaginatedArray < Array
|
|
10
|
+
attr_accessor :current_page, :per_page, :total_count
|
|
11
|
+
|
|
12
|
+
def initialize(records, current_page:, per_page:, total_count:)
|
|
13
|
+
super(records)
|
|
14
|
+
@current_page = current_page
|
|
15
|
+
@per_page = per_page
|
|
16
|
+
@total_count = total_count
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def total_pages
|
|
20
|
+
return 1 if total_count.zero? || per_page.zero?
|
|
21
|
+
|
|
22
|
+
(total_count.to_f / per_page).ceil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def next_page
|
|
26
|
+
current_page < total_pages ? current_page + 1 : nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def prev_page
|
|
30
|
+
current_page > 1 ? current_page - 1 : nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def first_page?
|
|
34
|
+
current_page == 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def last_page?
|
|
38
|
+
current_page >= total_pages
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def out_of_range?
|
|
42
|
+
current_page > total_pages
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def offset_value
|
|
46
|
+
(current_page - 1) * per_page
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def limit_value
|
|
50
|
+
per_page
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# JSON serialization for API responses
|
|
54
|
+
def pagination_metadata
|
|
55
|
+
{
|
|
56
|
+
current_page: current_page,
|
|
57
|
+
per_page: per_page,
|
|
58
|
+
total_count: total_count,
|
|
59
|
+
total_pages: total_pages,
|
|
60
|
+
next_page: next_page,
|
|
61
|
+
prev_page: prev_page
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Keyset pagination cursor
|
|
67
|
+
class Cursor
|
|
68
|
+
attr_reader :field, :value, :direction
|
|
69
|
+
|
|
70
|
+
def initialize(field:, value:, direction: :asc)
|
|
71
|
+
@field = field.to_s
|
|
72
|
+
@value = value
|
|
73
|
+
@direction = direction.to_sym
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Encode cursor for API response
|
|
77
|
+
def encode
|
|
78
|
+
Base64.urlsafe_encode64(JSON.generate({ f: field, v: value, d: direction }))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Decode cursor from API request
|
|
82
|
+
def self.decode(encoded)
|
|
83
|
+
return nil if encoded.blank?
|
|
84
|
+
|
|
85
|
+
data = JSON.parse(Base64.urlsafe_decode64(encoded))
|
|
86
|
+
new(field: data["f"], value: data["v"], direction: data["d"]&.to_sym || :asc)
|
|
87
|
+
rescue StandardError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Build next cursor from last record
|
|
92
|
+
def self.from_record(record, field:, direction: :asc)
|
|
93
|
+
value = record.respond_to?(field) ? record.send(field) : record[field.to_s]
|
|
94
|
+
new(field: field, value: value, direction: direction)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Keyset paginated result
|
|
99
|
+
class KeysetResult
|
|
100
|
+
attr_reader :records, :next_cursor, :has_more
|
|
101
|
+
|
|
102
|
+
def initialize(records, next_cursor: nil, has_more: false)
|
|
103
|
+
@records = records
|
|
104
|
+
@next_cursor = next_cursor
|
|
105
|
+
@has_more = has_more
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def each(&)
|
|
109
|
+
records.each(&)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
include Enumerable
|
|
113
|
+
|
|
114
|
+
def to_a
|
|
115
|
+
records
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
delegate :size, to: :records
|
|
119
|
+
|
|
120
|
+
delegate :empty?, to: :records
|
|
121
|
+
|
|
122
|
+
# JSON serialization for API responses
|
|
123
|
+
def pagination_metadata
|
|
124
|
+
{
|
|
125
|
+
has_more: has_more,
|
|
126
|
+
next_cursor: next_cursor&.encode
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Search paginator - builds and executes paginated queries
|
|
132
|
+
class SearchPaginator
|
|
133
|
+
include Enumerable
|
|
134
|
+
|
|
135
|
+
def initialize(model_class, page: 1, per_page: nil)
|
|
136
|
+
@model_class = model_class
|
|
137
|
+
@current_page = [page.to_i, 1].max
|
|
138
|
+
@per_page = [(per_page || DEFAULT_PER_PAGE).to_i, MAX_PER_PAGE].min
|
|
139
|
+
@query_builder = QueryBuilder.new(model_class)
|
|
140
|
+
@executed = false
|
|
141
|
+
@results = nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def page(num)
|
|
145
|
+
SearchPaginator.new(@model_class, page: num, per_page: @per_page)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def per(num)
|
|
149
|
+
SearchPaginator.new(@model_class, page: @current_page, per_page: num)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Pagination info
|
|
153
|
+
attr_reader :current_page
|
|
154
|
+
|
|
155
|
+
def limit_value
|
|
156
|
+
@per_page
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def total_count
|
|
160
|
+
execute_search unless @executed
|
|
161
|
+
@total_count || 0
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def total_pages
|
|
165
|
+
return 1 if total_count.zero?
|
|
166
|
+
|
|
167
|
+
(total_count.to_f / @per_page).ceil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def next_page
|
|
171
|
+
current_page < total_pages ? current_page + 1 : nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def prev_page
|
|
175
|
+
current_page > 1 ? current_page - 1 : nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def first_page?
|
|
179
|
+
current_page == 1
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def last_page?
|
|
183
|
+
current_page >= total_pages
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def out_of_range?
|
|
187
|
+
current_page > total_pages
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def offset_value
|
|
191
|
+
(@current_page - 1) * @per_page
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
delegate :size, to: :to_a
|
|
195
|
+
|
|
196
|
+
def length
|
|
197
|
+
size
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def empty?
|
|
201
|
+
size.zero?
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Enumerable interface
|
|
205
|
+
def each(&)
|
|
206
|
+
return enum_for(__method__) unless block_given?
|
|
207
|
+
|
|
208
|
+
to_a.each(&)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def to_a
|
|
212
|
+
execute_search unless @executed
|
|
213
|
+
@results.to_a
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Query building delegation
|
|
217
|
+
def match(field, value, **)
|
|
218
|
+
@query_builder.match(field, value, **)
|
|
219
|
+
self
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def multi_match(query, fields, **)
|
|
223
|
+
@query_builder.multi_match(query, fields, **)
|
|
224
|
+
self
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def filter(field, value, **)
|
|
228
|
+
@query_builder.filter(field, value, **)
|
|
229
|
+
self
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def sort(field, direction = :asc, **)
|
|
233
|
+
@query_builder.sort(field, direction, **)
|
|
234
|
+
self
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def aggregation(name, type, **)
|
|
238
|
+
@query_builder.aggregation(name, type, **)
|
|
239
|
+
self
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def geo_distance(field, lat:, lon:, distance:, **)
|
|
243
|
+
@query_builder.geo_distance(field, lat: lat, lon: lon, distance: distance, **)
|
|
244
|
+
self
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def vector(field, embedding, **)
|
|
248
|
+
@query_builder.vector(field, embedding, **)
|
|
249
|
+
self
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Response access
|
|
253
|
+
def results
|
|
254
|
+
execute_search unless @executed
|
|
255
|
+
@results
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def aggregations
|
|
259
|
+
execute_search unless @executed
|
|
260
|
+
@results&.aggregations
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def suggestions
|
|
264
|
+
execute_search unless @executed
|
|
265
|
+
@results&.suggestions
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def hits
|
|
269
|
+
execute_search unless @executed
|
|
270
|
+
@results&.hits || []
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def took
|
|
274
|
+
execute_search unless @executed
|
|
275
|
+
@results&.took
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Records-specific methods
|
|
279
|
+
def each_with_hit(&)
|
|
280
|
+
return enum_for(__method__) unless block_given?
|
|
281
|
+
|
|
282
|
+
execute_search unless @executed
|
|
283
|
+
if @results.respond_to?(:each_with_hit)
|
|
284
|
+
@results.each_with_hit(&)
|
|
285
|
+
else
|
|
286
|
+
to_a.each_with_index { |record, index| yield(record, hits[index]) }
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def map_with_hit(&)
|
|
291
|
+
return enum_for(__method__) unless block_given?
|
|
292
|
+
|
|
293
|
+
each_with_hit.map(&)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# JSON metadata for API responses
|
|
297
|
+
def pagination_metadata
|
|
298
|
+
{
|
|
299
|
+
current_page: current_page,
|
|
300
|
+
per_page: @per_page,
|
|
301
|
+
total_count: total_count,
|
|
302
|
+
total_pages: total_pages,
|
|
303
|
+
next_page: next_page,
|
|
304
|
+
prev_page: prev_page
|
|
305
|
+
}
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
private
|
|
309
|
+
|
|
310
|
+
def execute_search
|
|
311
|
+
@query_builder.paginate(page: @current_page, per_page: @per_page)
|
|
312
|
+
|
|
313
|
+
client = Noiseless.connections.client(@model_class.connection)
|
|
314
|
+
ast = @query_builder.to_ast
|
|
315
|
+
@results = client.search(ast, model_class: @model_class)
|
|
316
|
+
@total_count = @results.total
|
|
317
|
+
@executed = true
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Extend response classes with pagination support
|
|
322
|
+
module ResponsePagination
|
|
323
|
+
def total_pages
|
|
324
|
+
return 1 if total.zero? || @per_page.nil?
|
|
325
|
+
|
|
326
|
+
(total.to_f / @per_page).ceil
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def current_page
|
|
330
|
+
return 1 unless @from && @per_page
|
|
331
|
+
|
|
332
|
+
(@from / @per_page) + 1
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def next_page
|
|
336
|
+
current_page < total_pages ? current_page + 1 : nil
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def prev_page
|
|
340
|
+
current_page > 1 ? current_page - 1 : nil
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def first_page?
|
|
344
|
+
current_page == 1
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def last_page?
|
|
348
|
+
current_page >= total_pages
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def out_of_range?
|
|
352
|
+
current_page > total_pages
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def limit_value
|
|
356
|
+
@per_page
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def offset_value
|
|
360
|
+
@from || 0
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def pagination_metadata
|
|
364
|
+
{
|
|
365
|
+
current_page: current_page,
|
|
366
|
+
per_page: @per_page,
|
|
367
|
+
total_count: total,
|
|
368
|
+
total_pages: total_pages,
|
|
369
|
+
next_page: next_page,
|
|
370
|
+
prev_page: prev_page
|
|
371
|
+
}
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Noiseless
|
|
4
|
+
class QueryBuilder
|
|
5
|
+
def initialize(model)
|
|
6
|
+
@model = model
|
|
7
|
+
@indexes = determine_indexes(model)
|
|
8
|
+
@nodes = []
|
|
9
|
+
@aggregations = []
|
|
10
|
+
@collapse = nil
|
|
11
|
+
@search_after = nil
|
|
12
|
+
@hybrid = nil
|
|
13
|
+
@pipeline = nil
|
|
14
|
+
@image_query = nil
|
|
15
|
+
@conversation = nil
|
|
16
|
+
@joins = []
|
|
17
|
+
@remove_duplicates = nil
|
|
18
|
+
@facet_sample_slope = nil
|
|
19
|
+
@pinned_hits = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def indexes(names)
|
|
23
|
+
@indexes = Array(names).map(&:to_s)
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def match(field, value)
|
|
28
|
+
@nodes << AST::Match.new(field, value)
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def multi_match(query, fields, **)
|
|
33
|
+
@nodes << AST::MultiMatch.new(query, fields, **)
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def wildcard(field, value)
|
|
38
|
+
@nodes << AST::Wildcard.new(field, value)
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def range(field, gte: nil, lte: nil, gt: nil, lt: nil)
|
|
43
|
+
@nodes << AST::Range.new(field, gte: gte, lte: lte, gt: gt, lt: lt)
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def prefix(field, value)
|
|
48
|
+
@nodes << AST::Prefix.new(field, value)
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def filter(field, value)
|
|
53
|
+
@nodes << AST::Filter.new(field, value)
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
alias where filter
|
|
58
|
+
|
|
59
|
+
def sort(field, dir = :asc)
|
|
60
|
+
@nodes << AST::Sort.new(field, dir)
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
alias order sort
|
|
65
|
+
|
|
66
|
+
def paginate(page: 1, per_page: 20)
|
|
67
|
+
@nodes << AST::Paginate.new(page, per_page)
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def limit(size)
|
|
72
|
+
@nodes << AST::Paginate.new(1, size)
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def offset(from)
|
|
77
|
+
# Calculate page based on offset and current per_page
|
|
78
|
+
existing_paginate = @nodes.find { |n| n.is_a?(AST::Paginate) }
|
|
79
|
+
per_page = existing_paginate&.per_page || 20
|
|
80
|
+
page = (from / per_page) + 1
|
|
81
|
+
@nodes.reject! { |n| n.is_a?(AST::Paginate) }
|
|
82
|
+
@nodes << AST::Paginate.new(page, per_page)
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def aggregation(name, type, field: nil, **, &)
|
|
87
|
+
sub_aggs = []
|
|
88
|
+
if block_given?
|
|
89
|
+
sub_builder = AST::AggregationBuilder.new
|
|
90
|
+
sub_builder.instance_eval(&)
|
|
91
|
+
sub_aggs = sub_builder.aggregations
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
@aggregations << AST::Aggregation.new(name, type, field: field, sub_aggregations: sub_aggs, **)
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
alias agg aggregation
|
|
99
|
+
|
|
100
|
+
def collapse(field, inner_hits: nil, max_concurrent_group_searches: nil)
|
|
101
|
+
@collapse = AST::Collapse.new(field, inner_hits: inner_hits,
|
|
102
|
+
max_concurrent_group_searches: max_concurrent_group_searches)
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def search_after(values)
|
|
107
|
+
@search_after = AST::SearchAfter.new(values)
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def combined_fields(query, fields, operator: nil, minimum_should_match: nil, **)
|
|
112
|
+
@nodes << AST::CombinedFields.new(query, fields, operator: operator, minimum_should_match: minimum_should_match,
|
|
113
|
+
**)
|
|
114
|
+
self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def geo_distance(field, lat:, lon:, distance:, **options)
|
|
118
|
+
# Create a special geo filter node
|
|
119
|
+
geo_filter = AST::Filter.new(field, {
|
|
120
|
+
geo_distance: {
|
|
121
|
+
distance: distance,
|
|
122
|
+
"#{field}": { lat: lat, lon: lon }
|
|
123
|
+
}.merge(options)
|
|
124
|
+
})
|
|
125
|
+
@nodes << geo_filter
|
|
126
|
+
self
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Vector/semantic search using embeddings (pgvector or OpenSearch knn)
|
|
130
|
+
# @param field [Symbol] The embedding column/field
|
|
131
|
+
# @param embedding [Array<Float>] The query embedding vector
|
|
132
|
+
# @param k [Integer] Number of nearest neighbors (default: 10)
|
|
133
|
+
# @param distance_metric [Symbol] :cosine, :l2, or :inner_product
|
|
134
|
+
def vector(field, embedding, k: 10, distance_metric: :cosine)
|
|
135
|
+
@nodes << AST::Vector.new(field, embedding, k: k, distance_metric: distance_metric)
|
|
136
|
+
self
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
alias knn vector
|
|
140
|
+
alias semantic_search vector
|
|
141
|
+
|
|
142
|
+
# Hybrid search combining text query with vector search
|
|
143
|
+
# @param text_query [String] The text query for BM25 matching
|
|
144
|
+
# @param embedding [Array<Float>] The query embedding vector
|
|
145
|
+
# @param field [Symbol] The embedding field name
|
|
146
|
+
# @param text_weight [Float] Weight for text search score (default: 0.5)
|
|
147
|
+
# @param vector_weight [Float] Weight for vector search score (default: 0.5)
|
|
148
|
+
# @param k [Integer] Number of nearest neighbors (default: 10)
|
|
149
|
+
def hybrid(text_query, embedding, field:, text_weight: 0.5, vector_weight: 0.5, k: 10)
|
|
150
|
+
vector_node = AST::Vector.new(field, embedding, k: k)
|
|
151
|
+
@hybrid = AST::Hybrid.new(text_query, vector_node, text_weight: text_weight, vector_weight: vector_weight)
|
|
152
|
+
self
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Apply a search pipeline (OpenSearch only)
|
|
156
|
+
# @param pipeline_name [String] Name of the search pipeline to use
|
|
157
|
+
def pipeline(pipeline_name)
|
|
158
|
+
@pipeline = pipeline_name
|
|
159
|
+
self
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Image search using visual similarity (Typesense only)
|
|
163
|
+
# @param field [Symbol] The image embedding field name
|
|
164
|
+
# @param image_data [String] Image URL or base64 encoded image
|
|
165
|
+
# @param k [Integer] Number of nearest neighbors (default: 10)
|
|
166
|
+
def image_search(field, image_data, k: 10)
|
|
167
|
+
@image_query = AST::ImageQuery.new(field, image_data, k: k)
|
|
168
|
+
self
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Conversational/RAG search (Typesense and Elasticsearch)
|
|
172
|
+
# @param model_id [String] The LLM model identifier
|
|
173
|
+
# @param conversation_id [String, nil] ID for multi-turn conversations
|
|
174
|
+
# @param system_prompt [String, nil] Custom system prompt
|
|
175
|
+
def conversational(model_id:, conversation_id: nil, system_prompt: nil)
|
|
176
|
+
@conversation = AST::Conversation.new(
|
|
177
|
+
model_id: model_id,
|
|
178
|
+
conversation_id: conversation_id,
|
|
179
|
+
system_prompt: system_prompt
|
|
180
|
+
)
|
|
181
|
+
self
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
alias rag conversational
|
|
185
|
+
|
|
186
|
+
# Join with another collection (Typesense only)
|
|
187
|
+
# @param collection [String, Symbol] The collection to join
|
|
188
|
+
# @param on [Hash] Join conditions
|
|
189
|
+
# @param include_fields [Array] Fields to include from joined collection
|
|
190
|
+
# @param strategy [Symbol] Join strategy :left or :inner
|
|
191
|
+
def join(collection, on:, include_fields: [], strategy: :left)
|
|
192
|
+
@joins << AST::Join.new(collection, on: on, include_fields: include_fields, strategy: strategy)
|
|
193
|
+
self
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Remove duplicate documents in Typesense union search results.
|
|
197
|
+
def remove_duplicates(value: true)
|
|
198
|
+
@remove_duplicates = if value.nil?
|
|
199
|
+
nil
|
|
200
|
+
else
|
|
201
|
+
value ? true : false
|
|
202
|
+
end
|
|
203
|
+
self
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Controls dynamic facet sampling behavior in Typesense.
|
|
207
|
+
def facet_sample_slope(value)
|
|
208
|
+
@facet_sample_slope = value
|
|
209
|
+
self
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Pin specific document IDs to fixed result positions in Typesense.
|
|
213
|
+
#
|
|
214
|
+
# Supported formats:
|
|
215
|
+
# - String: "id1:1,id2:2"
|
|
216
|
+
# - Hash: { "id1" => 1, "id2" => 2 }
|
|
217
|
+
# - Array of pairs: [["id1", 1], ["id2", 2]]
|
|
218
|
+
def pinned_hits(value)
|
|
219
|
+
@pinned_hits = normalize_pinned_hits(value)
|
|
220
|
+
self
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def to_ast
|
|
224
|
+
filter_nodes = @nodes.select { |n| n.is_a?(AST::Filter) }
|
|
225
|
+
vector_nodes = @nodes.select { |n| n.is_a?(AST::Vector) }
|
|
226
|
+
must_nodes = @nodes.reject do |n|
|
|
227
|
+
n.is_a?(AST::Filter) || n.is_a?(AST::Sort) || n.is_a?(AST::Paginate) || n.is_a?(AST::Vector)
|
|
228
|
+
end
|
|
229
|
+
bool_node = AST::Bool.new(must: must_nodes, filter: filter_nodes)
|
|
230
|
+
sort_nodes = @nodes.select { |n| n.is_a?(AST::Sort) }
|
|
231
|
+
paginate_node = @nodes.find { |n| n.is_a?(AST::Paginate) }
|
|
232
|
+
AST::Root.new(
|
|
233
|
+
indexes: @indexes,
|
|
234
|
+
bool: bool_node,
|
|
235
|
+
sort: sort_nodes,
|
|
236
|
+
paginate: paginate_node,
|
|
237
|
+
vector: vector_nodes.first, # Only support one vector search per query for now
|
|
238
|
+
collapse: @collapse,
|
|
239
|
+
search_after: @search_after,
|
|
240
|
+
aggregations: @aggregations,
|
|
241
|
+
hybrid: @hybrid,
|
|
242
|
+
pipeline: @pipeline,
|
|
243
|
+
image_query: @image_query,
|
|
244
|
+
conversation: @conversation,
|
|
245
|
+
joins: @joins,
|
|
246
|
+
remove_duplicates: @remove_duplicates,
|
|
247
|
+
facet_sample_slope: @facet_sample_slope,
|
|
248
|
+
pinned_hits: @pinned_hits
|
|
249
|
+
)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
private
|
|
253
|
+
|
|
254
|
+
def normalize_pinned_hits(value)
|
|
255
|
+
case value
|
|
256
|
+
when nil
|
|
257
|
+
nil
|
|
258
|
+
when String
|
|
259
|
+
value
|
|
260
|
+
when Hash
|
|
261
|
+
value.map { |id, position| "#{id}:#{position}" }.join(",")
|
|
262
|
+
when Array
|
|
263
|
+
value.map do |entry|
|
|
264
|
+
raise ArgumentError, "pinned_hits array entries must be [id, position]" unless entry.is_a?(Array) && entry.size == 2
|
|
265
|
+
|
|
266
|
+
"#{entry[0]}:#{entry[1]}"
|
|
267
|
+
end.join(",")
|
|
268
|
+
else
|
|
269
|
+
raise ArgumentError, "pinned_hits must be a String, Hash, or Array of [id, position]"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def determine_indexes(model)
|
|
274
|
+
# Check for search_index (plural array) first
|
|
275
|
+
return Array(model.search_index) if model.respond_to?(:search_index) && model.search_index&.any?
|
|
276
|
+
|
|
277
|
+
# Check for index_name (singular string) next
|
|
278
|
+
return [model.index_name] if model.respond_to?(:index_name) && model.index_name
|
|
279
|
+
|
|
280
|
+
# Fallback to pluralized model name
|
|
281
|
+
[model.name.demodulize.underscore.pluralize]
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
require_relative "instrumentation"
|
|
5
|
+
require_relative "runtime_reset_middleware"
|
|
6
|
+
|
|
7
|
+
module Noiseless
|
|
8
|
+
class Railtie < Rails::Railtie
|
|
9
|
+
railtie_name :noiseless
|
|
10
|
+
|
|
11
|
+
config.noiseless = ActiveSupport::OrderedOptions.new
|
|
12
|
+
|
|
13
|
+
initializer "noiseless.configure" do |_app|
|
|
14
|
+
# Load configuration from config/noiseless.yml
|
|
15
|
+
Noiseless.load_configuration!
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
initializer "noiseless.instrumentation" do |app|
|
|
19
|
+
# Attach log subscriber
|
|
20
|
+
Noiseless::LogSubscriber.attach_to :noiseless
|
|
21
|
+
|
|
22
|
+
# Include controller runtime tracking in ActionController
|
|
23
|
+
ActiveSupport.on_load(:action_controller) do
|
|
24
|
+
include Noiseless::ControllerRuntime
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Reset runtime tracking at the beginning of each request
|
|
28
|
+
app.middleware.use Noiseless::RuntimeResetMiddleware
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
generators do
|
|
32
|
+
require_relative "generators/application_search_generator"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|