loams 0.1.0 → 0.1.2

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.
@@ -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