loams 0.1.0 → 0.1.1
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/README.md +80 -2
- data/gen/loams-descriptor.binpb +0 -0
- data/lib/loams/app.rb +726 -0
- data/lib/loams/batch.rb +93 -0
- data/lib/loams/builders.rb +622 -0
- data/lib/loams/client.rb +76 -2
- data/lib/loams/consistency.rb +201 -0
- data/lib/loams/descriptors.rb +6 -0
- data/lib/loams/errors.rb +60 -0
- data/lib/loams/invoker.rb +41 -2
- data/lib/loams/subscribe.rb +75 -0
- data/lib/loams/transport.rb +5 -3
- data/lib/loams/values.rb +87 -0
- data/lib/loams/version.rb +1 -1
- data/lib/loams.rb +11 -0
- metadata +7 -1
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "loams/errors"
|
|
5
|
+
require "loams/consistency"
|
|
6
|
+
|
|
7
|
+
module Loams
|
|
8
|
+
# The typed builders of the app-building API (SDKP §2.4 and §2.6): `Schema`,
|
|
9
|
+
# `Filter` and `Search`. Each is a pure function onto one request: `to_h`
|
|
10
|
+
# returns the request's **proto3 JSON** (string keys, `lowerCamelCase` field
|
|
11
|
+
# names, enum names as strings), which is what the builder corpus pins and
|
|
12
|
+
# what `decode_json` turns into the message the invoker sends.
|
|
13
|
+
#
|
|
14
|
+
# Nothing here talks to a server, and nothing validates what the server
|
|
15
|
+
# validates: a `weighted` fusion with the wrong number of weights is sent and
|
|
16
|
+
# refused by the server with its field violation.
|
|
17
|
+
module Builders
|
|
18
|
+
# Accepts a builder, a Hash or nil, and answers proto3 JSON or nil.
|
|
19
|
+
def self.json(value)
|
|
20
|
+
case value
|
|
21
|
+
when nil then nil
|
|
22
|
+
when Hash then value
|
|
23
|
+
else value.respond_to?(:to_h) ? value.to_h : value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Removes nil values, so an unset optional field is absent.
|
|
28
|
+
def self.compact(hash)
|
|
29
|
+
hash.reject { |_, v| v.nil? }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The `CreateCollectionRequest.schema` Struct, in the shape the server's own
|
|
34
|
+
# schema parser reads (`kb_schema()` in `connect_collections.rs`).
|
|
35
|
+
#
|
|
36
|
+
# Loams::Schema.new
|
|
37
|
+
# .text("body").keyword("tags").i64("n")
|
|
38
|
+
# .vector("embedding", 4)
|
|
39
|
+
class Schema
|
|
40
|
+
def initialize
|
|
41
|
+
@fields = []
|
|
42
|
+
@vectors = []
|
|
43
|
+
@sparse = []
|
|
44
|
+
@dynamic = "ignore"
|
|
45
|
+
@max_fields = 1000
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A full-text field. `positions` is what phrase queries need.
|
|
49
|
+
def text(name, analyzer: "standard", positions: true, indexed: true, fast: false, source_path: nil)
|
|
50
|
+
field(name, { "text" => { "analyzer" => analyzer, "positions" => positions } }, indexed, fast, source_path)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# An exact-match field: terms, prefixes, facets.
|
|
54
|
+
def keyword(name, indexed: true, fast: true, source_path: nil)
|
|
55
|
+
field(name, "keyword", indexed, fast, source_path)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def i64(name, indexed: true, fast: true, source_path: nil)
|
|
59
|
+
field(name, "i64", indexed, fast, source_path)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def f64(name, indexed: true, fast: true, source_path: nil)
|
|
63
|
+
field(name, "f64", indexed, fast, source_path)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def bool(name, indexed: true, fast: true, source_path: nil)
|
|
67
|
+
field(name, "bool", indexed, fast, source_path)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A date-time field (the schema's `date` kind).
|
|
71
|
+
def timestamp(name, indexed: true, fast: true, source_path: nil)
|
|
72
|
+
field(name, "date", indexed, fast, source_path)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def uuid(name, indexed: true, fast: true, source_path: nil)
|
|
76
|
+
field(name, "uuid", indexed, fast, source_path)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A dense vector of +dim+ floats.
|
|
80
|
+
def vector(name, dim, distance: "cosine")
|
|
81
|
+
@vectors << { "name" => name.to_s, "dim" => Integer(dim), "distance" => distance.to_s }
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def sparse_vector(name, modifier: nil)
|
|
86
|
+
@sparse << Builders.compact({ "name" => name.to_s, "modifier" => modifier&.to_s })
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# What a document field the schema does not name does: `"ignore"` (the
|
|
91
|
+
# builder's default), `"strict"` (the server's) or `"map"`.
|
|
92
|
+
def dynamic(mode)
|
|
93
|
+
mode = mode.to_s
|
|
94
|
+
raise ArgumentError, "dynamic must be ignore, strict or map" unless %w[ignore strict map].include?(mode)
|
|
95
|
+
|
|
96
|
+
@dynamic = mode
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def max_fields(count)
|
|
101
|
+
@max_fields = Integer(count)
|
|
102
|
+
self
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def to_h
|
|
106
|
+
{
|
|
107
|
+
"fields" => @fields.map(&:dup),
|
|
108
|
+
"vectors" => @vectors.map(&:dup),
|
|
109
|
+
"sparse_vectors" => @sparse.map(&:dup),
|
|
110
|
+
"dynamic" => @dynamic,
|
|
111
|
+
"max_fields" => @max_fields,
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def to_json(*args)
|
|
116
|
+
to_h.to_json(*args)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def field(name, kind, indexed, fast, source_path)
|
|
122
|
+
@fields << {
|
|
123
|
+
"name" => name.to_s,
|
|
124
|
+
"source_path" => (source_path || name).to_s,
|
|
125
|
+
"kind" => kind,
|
|
126
|
+
"indexed" => indexed,
|
|
127
|
+
"fast" => fast,
|
|
128
|
+
}
|
|
129
|
+
self
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# One `loams.collection.v1.Query`: the filter of a search, the query of a
|
|
134
|
+
# text retriever, and the typed `where` of the document reads.
|
|
135
|
+
#
|
|
136
|
+
# Loams::Filter.and(Loams::Filter.eq("tags", "policy"),
|
|
137
|
+
# Loams::Filter.range("n", gte: 2))
|
|
138
|
+
class Filter
|
|
139
|
+
attr_reader :to_h
|
|
140
|
+
|
|
141
|
+
def initialize(json)
|
|
142
|
+
@to_h = json.freeze
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def to_json(*args)
|
|
146
|
+
to_h.to_json(*args)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def ==(other)
|
|
150
|
+
other.is_a?(Filter) && other.to_h == to_h
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# `self & other`, `self | other` and `~self`: `and`, `or` and `not`.
|
|
154
|
+
def &(other)
|
|
155
|
+
Filter.and(self, other)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def |(other)
|
|
159
|
+
Filter.or(self, other)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def ~
|
|
163
|
+
Filter.not(self)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
class << self
|
|
167
|
+
def all
|
|
168
|
+
new({ "matchAll" => {} })
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def none
|
|
172
|
+
new({ "matchNone" => {} })
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# `field` equals +value+ (a term query).
|
|
176
|
+
def eq(field, value)
|
|
177
|
+
new({ "term" => { "field" => field.to_s, "value" => value } })
|
|
178
|
+
end
|
|
179
|
+
alias term eq
|
|
180
|
+
|
|
181
|
+
# `field` is one of +values+ (a terms query).
|
|
182
|
+
def in(field, values)
|
|
183
|
+
new({ "terms" => { "field" => field.to_s, "values" => Array(values) } })
|
|
184
|
+
end
|
|
185
|
+
alias terms in
|
|
186
|
+
|
|
187
|
+
def range(field, gt: nil, gte: nil, lt: nil, lte: nil)
|
|
188
|
+
new({ "range" => Builders.compact({ "field" => field.to_s, "gt" => gt, "gte" => gte, "lt" => lt, "lte" => lte }) })
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def exists(field)
|
|
192
|
+
new({ "exists" => { "field" => field.to_s } })
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def is_null(field)
|
|
196
|
+
new({ "isNull" => { "field" => field.to_s } })
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def is_empty(field)
|
|
200
|
+
new({ "isEmpty" => { "field" => field.to_s } })
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def prefix(field, value)
|
|
204
|
+
new({ "prefix" => { "field" => field.to_s, "value" => value.to_s } })
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def wildcard(field, pattern)
|
|
208
|
+
new({ "wildcard" => { "field" => field.to_s, "pattern" => pattern.to_s } })
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# +edits+ is a number of edits or `:auto`; nil leaves the server's default.
|
|
212
|
+
def fuzzy(field, value, edits: nil)
|
|
213
|
+
new({ "fuzzy" => Builders.compact({ "field" => field.to_s, "value" => value.to_s, "fuzziness" => fuzziness(edits) }) })
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Full-text match; +operator+ is `"or"` (default) or `"and"`.
|
|
217
|
+
def match(field, text, operator: nil, minimum_should_match: nil, fuzziness: nil, analyzer: nil)
|
|
218
|
+
new({ "match" => Builders.compact({
|
|
219
|
+
"field" => field.to_s, "text" => text.to_s,
|
|
220
|
+
"operator" => operator&.to_s, "minimumShouldMatch" => minimum_should_match&.to_s,
|
|
221
|
+
"fuzziness" => fuzziness(fuzziness), "analyzer" => analyzer&.to_s,
|
|
222
|
+
}) })
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def phrase(field, text, slop: nil)
|
|
226
|
+
new({ "matchPhrase" => Builders.compact({ "field" => field.to_s, "text" => text.to_s, "slop" => slop }) })
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
KINDS = %w[best_fields most_fields cross_fields phrase phrase_prefix].freeze
|
|
230
|
+
|
|
231
|
+
# One text over several fields: names, `"name^boost"`, or `{name => boost}`.
|
|
232
|
+
# +kind+ is one of `KINDS`; +operator+ `"or"` or `"and"`.
|
|
233
|
+
def multi_match(fields, text, kind: nil, operator: nil, tie_breaker: nil)
|
|
234
|
+
list = if fields.is_a?(Hash)
|
|
235
|
+
fields.map { |f, b| { "field" => f.to_s, "boost" => b } }
|
|
236
|
+
else
|
|
237
|
+
Array(fields).map { |f| boosted(f.to_s) }
|
|
238
|
+
end
|
|
239
|
+
new({ "multiMatch" => Builders.compact({
|
|
240
|
+
"fields" => list, "text" => text.to_s,
|
|
241
|
+
"kind" => kind && "MULTI_MATCH_KIND_#{kind.to_s.upcase}",
|
|
242
|
+
"operator" => operator && bool_operator(operator), "tieBreaker" => tie_breaker,
|
|
243
|
+
}) })
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def query_string(query, fields: nil, operator: nil)
|
|
247
|
+
new({ "queryString" => Builders.compact({
|
|
248
|
+
"query" => query.to_s,
|
|
249
|
+
"defaultFields" => fields.nil? || fields.empty? ? nil : fields.map(&:to_s),
|
|
250
|
+
"defaultOperator" => operator && bool_operator(operator),
|
|
251
|
+
}) })
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Documents by primary key: integers, strings or `{"uuid" => …}`.
|
|
255
|
+
def ids(*ids)
|
|
256
|
+
new({ "ids" => ids.flatten.map { |id| id_value(id) } })
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# A primary key as `Query.ids` spells it: a number is `{"uint" => "…"}`,
|
|
260
|
+
# a string stays a string, and `{uuid: …}` is `{"uuid" => …}`.
|
|
261
|
+
def id_value(id)
|
|
262
|
+
case id
|
|
263
|
+
when Integer
|
|
264
|
+
raise ArgumentError, "a numeric document id is non-negative" if id.negative?
|
|
265
|
+
|
|
266
|
+
{ "uint" => id.to_s }
|
|
267
|
+
when String, Symbol then id.to_s
|
|
268
|
+
when Hash then id.transform_keys(&:to_s).transform_values(&:to_s)
|
|
269
|
+
else raise ArgumentError, "a document id is an Integer, a String or {uuid: …}"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Every clause must match (`bool.must`).
|
|
274
|
+
def and(*clauses)
|
|
275
|
+
new({ "bool" => { "must" => clauses.flatten.map { |c| Builders.json(c) } } })
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Any clause matches (`bool.should`).
|
|
279
|
+
def or(*clauses)
|
|
280
|
+
new({ "bool" => { "should" => clauses.flatten.map { |c| Builders.json(c) } } })
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# At least +minimum_should_match+ of +clauses+ match.
|
|
284
|
+
def any_of(clauses, minimum_should_match)
|
|
285
|
+
new({ "bool" => {
|
|
286
|
+
"should" => Array(clauses).map { |c| Builders.json(c) },
|
|
287
|
+
"minimumShouldMatch" => minimum_should_match.to_s,
|
|
288
|
+
} })
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# No clause matches (`bool.must_not`).
|
|
292
|
+
def not(*clauses)
|
|
293
|
+
new({ "bool" => { "mustNot" => clauses.flatten.map { |c| Builders.json(c) } } })
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Every clause matches, without scoring (`bool.filter`).
|
|
297
|
+
def filter(*clauses)
|
|
298
|
+
new({ "bool" => { "filter" => clauses.flatten.map { |c| Builders.json(c) } } })
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def boost(clause, boost)
|
|
302
|
+
new({ "boost" => { "query" => Builders.json(clause), "boost" => boost } })
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def constant_score(clause, score)
|
|
306
|
+
new({ "constantScore" => { "query" => Builders.json(clause), "score" => score } })
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
private
|
|
310
|
+
|
|
311
|
+
def boosted(spec)
|
|
312
|
+
at = spec.rindex("^")
|
|
313
|
+
if at&.positive?
|
|
314
|
+
boost = Float(spec[(at + 1)..], exception: false)
|
|
315
|
+
return { "field" => spec[0...at], "boost" => boost } if boost&.finite?
|
|
316
|
+
end
|
|
317
|
+
{ "field" => spec }
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def bool_operator(operator)
|
|
321
|
+
operator.to_s == "and" ? "BOOL_OPERATOR_AND" : "BOOL_OPERATOR_OR"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def fuzziness(edits)
|
|
325
|
+
case edits
|
|
326
|
+
when nil then nil
|
|
327
|
+
when :auto, "auto" then { "auto" => {} }
|
|
328
|
+
else { "edits" => Integer(edits) }
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# How a hybrid search fuses its retrievers' rankings.
|
|
335
|
+
module Fusion
|
|
336
|
+
# Reciprocal rank fusion; nil leaves `k` at the server's default (60).
|
|
337
|
+
def self.rrf(k = nil)
|
|
338
|
+
{ "rrf" => k.nil? ? {} : { "k" => Integer(k) } }
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Distribution-based score fusion.
|
|
342
|
+
def self.dbsf
|
|
343
|
+
{ "dbsf" => {} }
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# A weighted sum, one weight per retriever.
|
|
347
|
+
def self.weighted(*weights)
|
|
348
|
+
{ "weightedSum" => { "weights" => weights.flatten } }
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# One sort key.
|
|
353
|
+
module Sort
|
|
354
|
+
ORDERS = { asc: "SORT_ORDER_ASC", desc: "SORT_ORDER_DESC" }.freeze
|
|
355
|
+
MISSING = { first: "MISSING_ORDER_FIRST", last: "MISSING_ORDER_LAST" }.freeze
|
|
356
|
+
|
|
357
|
+
def self.score(order = nil)
|
|
358
|
+
{ "score" => Builders.compact({ "order" => order(order) }) }
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# The primary key: the tiebreaker `query_all` requires.
|
|
362
|
+
def self.pk(order = nil)
|
|
363
|
+
{ "pk" => Builders.compact({ "order" => order(order) }) }
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def self.field(name, order = nil, missing: nil)
|
|
367
|
+
{ "field" => Builders.compact({ "field" => name.to_s, "order" => order(order), "missing" => missing && MISSING.fetch(missing.to_sym) }) }
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def self.order(order)
|
|
371
|
+
order && ORDERS.fetch(order.to_sym)
|
|
372
|
+
end
|
|
373
|
+
private_class_method :order
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# A `SearchRequest`, verb by verb:
|
|
377
|
+
#
|
|
378
|
+
# Loams::Search.on("notes")
|
|
379
|
+
# .text("refund", fields: ["body"])
|
|
380
|
+
# .vector("embedding", [1, 0, 0, 0])
|
|
381
|
+
# .fuse(Loams::Fusion.rrf(60))
|
|
382
|
+
# .filter(Loams::Filter.eq("tags", "policy"))
|
|
383
|
+
# .limit(2)
|
|
384
|
+
#
|
|
385
|
+
# `to_h` is the request exactly as built (what the builder corpus pins);
|
|
386
|
+
# `build` checks it too, and `search.query` sends `build`.
|
|
387
|
+
class Search
|
|
388
|
+
# The page size `query_all` uses when the request sets no `limit`.
|
|
389
|
+
DEFAULT_K = 10
|
|
390
|
+
|
|
391
|
+
def self.on(collection, namespace: nil)
|
|
392
|
+
new(collection, namespace: namespace)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def initialize(collection = nil, namespace: nil)
|
|
396
|
+
@req = {}
|
|
397
|
+
@req["namespace"] = namespace.to_s unless namespace.nil?
|
|
398
|
+
@req["collection"] = collection.to_s unless collection.nil?
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def initialize_copy(source)
|
|
402
|
+
super
|
|
403
|
+
@req = Marshal.load(Marshal.dump(@req))
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def collection(name)
|
|
407
|
+
@req["collection"] = name.to_s
|
|
408
|
+
self
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def namespace(name)
|
|
412
|
+
@req["namespace"] = name.to_s
|
|
413
|
+
self
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# The namespace this request names, or nil.
|
|
417
|
+
def namespace_name
|
|
418
|
+
@req["namespace"]
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def collection_name
|
|
422
|
+
@req["collection"]
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# A text retriever. +query+ is a `Filter` (any query), or a string: a
|
|
426
|
+
# match on one field, a multi-match on several, or a query string when
|
|
427
|
+
# no field is given. `field:` is the one-field spelling of `fields:`.
|
|
428
|
+
def text(query, fields: nil, field: nil, k: nil)
|
|
429
|
+
fields = [field] if fields.nil? && !field.nil?
|
|
430
|
+
q = case query
|
|
431
|
+
when Filter, Hash then Builders.json(query)
|
|
432
|
+
else
|
|
433
|
+
text = query.to_s
|
|
434
|
+
if fields.nil? || fields.empty? then Filter.query_string(text).to_h
|
|
435
|
+
elsif fields.size == 1 then Filter.match(fields.first, text).to_h
|
|
436
|
+
else Filter.multi_match(fields, text).to_h
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
retriever(:text, Builders.compact({ "query" => q, "k" => k }))
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# A dense-vector (kNN) retriever. The ANN params are the server's
|
|
443
|
+
# `AnnParams`; nil leaves each at its default.
|
|
444
|
+
def vector(field, values, k: nil, exact: nil, nprobes: nil, refine_factor: nil, ef: nil,
|
|
445
|
+
oversampling: nil, distance: nil, filter: nil)
|
|
446
|
+
params = Builders.compact({
|
|
447
|
+
"exact" => exact, "nprobes" => nprobes, "refineFactor" => refine_factor, "ef" => ef,
|
|
448
|
+
"oversampling" => oversampling, "distance" => distance&.to_s,
|
|
449
|
+
})
|
|
450
|
+
body = { "field" => field.to_s, "query" => values.map(&:to_f) }
|
|
451
|
+
body["k"] = k unless k.nil?
|
|
452
|
+
body["params"] = params unless params.empty?
|
|
453
|
+
body["filter"] = Builders.json(filter) unless filter.nil?
|
|
454
|
+
retriever(:vector, body)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# A sparse-vector retriever; +terms+ is `{index => weight}` or
|
|
458
|
+
# `{indices: […], values: […]}`.
|
|
459
|
+
def sparse(field, terms, k: nil, filter: nil)
|
|
460
|
+
terms = terms.transform_keys(&:to_s)
|
|
461
|
+
query = if terms.key?("indices")
|
|
462
|
+
{ "indices" => terms["indices"].map { |i| Integer(i) }, "values" => terms["values"].map(&:to_f) }
|
|
463
|
+
else
|
|
464
|
+
{ "indices" => terms.keys.map { |i| Integer(i) }, "values" => terms.values.map(&:to_f) }
|
|
465
|
+
end
|
|
466
|
+
retriever(:sparse, Builders.compact({ "field" => field.to_s, "query" => query, "k" => k, "filter" => filter && Builders.json(filter) }))
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def fuse(fusion)
|
|
470
|
+
@req["fusion"] = Builders.json(fusion)
|
|
471
|
+
self
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def filter(filter)
|
|
475
|
+
@req["filter"] = Builders.json(filter)
|
|
476
|
+
self
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Sort keys, in order: `Sort.score`, `Sort.pk`, `Sort.field`, or the
|
|
480
|
+
# symbols `:score` and `:pk`.
|
|
481
|
+
def sort(*keys)
|
|
482
|
+
@req["sort"] = keys.flatten.map do |key|
|
|
483
|
+
case key
|
|
484
|
+
when :score then Sort.score
|
|
485
|
+
when :pk then Sort.pk
|
|
486
|
+
else Builders.json(key)
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
self
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def limit(count)
|
|
493
|
+
@req["limit"] = Integer(count)
|
|
494
|
+
self
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def offset(count)
|
|
498
|
+
@req["offset"] = Integer(count)
|
|
499
|
+
self
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Continue after a hit: its `sort_values`.
|
|
503
|
+
def after(sort_values)
|
|
504
|
+
@req["searchAfter"] = Array(sort_values)
|
|
505
|
+
self
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def min_score(score)
|
|
509
|
+
@req["scoreThreshold"] = score
|
|
510
|
+
self
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# The projection: which `_source` paths (or `no_source: true`), vectors
|
|
514
|
+
# and typed fields to return.
|
|
515
|
+
def select(include: nil, exclude: nil, no_source: false, vectors: nil, fields: nil)
|
|
516
|
+
source = Builders.compact({ "include" => include&.map(&:to_s), "exclude" => exclude&.map(&:to_s) })
|
|
517
|
+
@req["select"] = Builders.compact({
|
|
518
|
+
"source" => no_source ? "none" : (source.empty? ? nil : source),
|
|
519
|
+
"vectors" => vectors&.map(&:to_s),
|
|
520
|
+
"fields" => fields&.map(&:to_s),
|
|
521
|
+
})
|
|
522
|
+
self
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def highlight(field, pre: nil, post: nil, fragment_size: nil, fragments: nil)
|
|
526
|
+
existing = @req.dig("highlight", "fields") || []
|
|
527
|
+
@req["highlight"] = { "fields" => existing + [Builders.compact({
|
|
528
|
+
"field" => field.to_s, "preTag" => pre, "postTag" => post,
|
|
529
|
+
"fragmentSize" => fragment_size, "numberOfFragments" => fragments,
|
|
530
|
+
})] }
|
|
531
|
+
self
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def group_by(field, size: nil, limit: nil)
|
|
535
|
+
@req["groupBy"] = Builders.compact({ "field" => field.to_s, "groupSize" => size, "limit" => limit })
|
|
536
|
+
self
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# Graph expansion of the hits (`expand_spec`); +direction+ is `:out`,
|
|
540
|
+
# `:in` or `:both`, +output+ `:nested` or `:flat`.
|
|
541
|
+
def expand(graph, from_field: nil, seed_label: nil, seeds: nil, hops: nil, direction: nil,
|
|
542
|
+
edge_types: nil, limit_per_seed: nil, limit: nil, output: nil)
|
|
543
|
+
@req["expandSpec"] = Builders.compact({
|
|
544
|
+
"graph" => graph.to_s, "fromField" => from_field&.to_s, "seedLabel" => seed_label&.to_s,
|
|
545
|
+
"seeds" => seeds, "hops" => hops, "limitPerSeed" => limit_per_seed, "limit" => limit,
|
|
546
|
+
"direction" => direction && "EXPAND_DIRECTION_#{direction.to_s.upcase}",
|
|
547
|
+
"edgeTypes" => edge_types&.map(&:to_s),
|
|
548
|
+
"output" => output && "EXPAND_OUTPUT_#{output.to_s.upcase}",
|
|
549
|
+
})
|
|
550
|
+
self
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# `:strong`, `:eventual`, or a token (a String, checked here, or a
|
|
554
|
+
# `ConsistencyToken`) as `at_least`.
|
|
555
|
+
def consistency(level)
|
|
556
|
+
@req["consistency"] = Search.consistency_json(level)
|
|
557
|
+
self
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def self.consistency_json(level)
|
|
561
|
+
case level
|
|
562
|
+
when nil then nil
|
|
563
|
+
when :strong, "strong" then { "freshness" => "FRESHNESS_STRONG" }
|
|
564
|
+
when :eventual, "eventual" then { "freshness" => "FRESHNESS_EVENTUAL" }
|
|
565
|
+
when Hash then level
|
|
566
|
+
when ConsistencyToken then { "atLeast" => level.to_s }
|
|
567
|
+
else { "atLeast" => ConsistencyToken.parse(level.to_s).to_s }
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# Whether the request's last sort key is the primary key, the tiebreaker
|
|
572
|
+
# `query_all` needs.
|
|
573
|
+
def tiebreak?
|
|
574
|
+
Search.tiebreak?(@req)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def self.tiebreak?(request)
|
|
578
|
+
last = Array(request["sort"]).last
|
|
579
|
+
last.is_a?(Hash) && last.key?("pk")
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def to_h
|
|
583
|
+
Marshal.load(Marshal.dump(@req))
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def to_json(*args)
|
|
587
|
+
to_h.to_json(*args)
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# `to_h`, checked: a weighted fusion needs one weight per retriever.
|
|
591
|
+
def build
|
|
592
|
+
Search.check(to_h)
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def self.check(request)
|
|
596
|
+
weights = request.dig("fusion", "weightedSum", "weights")
|
|
597
|
+
count = Array(request["retrievers"]).size
|
|
598
|
+
if weights && weights.size != count
|
|
599
|
+
raise InvalidArgumentError.new(
|
|
600
|
+
"a weighted fusion needs one weight per retriever: #{weights.size} weights for #{count} retrievers",
|
|
601
|
+
code: :invalid_argument,
|
|
602
|
+
metadata: { "field" => "fusion.weighted_sum.weights" },
|
|
603
|
+
)
|
|
604
|
+
end
|
|
605
|
+
request
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# What the app modules send: `build`. A retriever's `k` goes out only when
|
|
609
|
+
# the caller gave one; the engine fills `max(10, offset + limit)` itself
|
|
610
|
+
# (SDKP-15, SDKP-18).
|
|
611
|
+
def self.wire(request)
|
|
612
|
+
check(request.is_a?(Search) ? request.to_h : Marshal.load(Marshal.dump(request)))
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
private
|
|
616
|
+
|
|
617
|
+
def retriever(kind, body)
|
|
618
|
+
@req["retrievers"] = (@req["retrievers"] || []) + [{ kind.to_s => body }]
|
|
619
|
+
self
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
end
|