maglev-rb 0.1.1 → 0.2.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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.ja.md +618 -0
  4. data/README.md +533 -248
  5. data/README.zh-CN.md +457 -250
  6. data/lib/generators/maglev/install/install_generator.rb +20 -0
  7. data/lib/generators/maglev/upgrade_index_version/upgrade_index_version_generator.rb +27 -0
  8. data/lib/generators/maglev/upgrade_source_identity/upgrade_source_identity_generator.rb +52 -0
  9. data/lib/maglev/active_record_extension.rb +141 -24
  10. data/lib/maglev/adapters/faraday_client.rb +94 -0
  11. data/lib/maglev/adapters/faraday_embedding.rb +51 -0
  12. data/lib/maglev/adapters/faraday_generation.rb +49 -0
  13. data/lib/maglev/adapters/faraday_planner.rb +88 -0
  14. data/lib/maglev/answerer.rb +30 -12
  15. data/lib/maglev/chunker.rb +39 -4
  16. data/lib/maglev/configuration.rb +66 -1
  17. data/lib/maglev/content_source_graph.rb +17 -11
  18. data/lib/maglev/dependency_graph.rb +72 -13
  19. data/lib/maglev/embedding_adapter.rb +10 -0
  20. data/lib/maglev/hybrid_candidate_set.rb +25 -0
  21. data/lib/maglev/hybrid_coordinator.rb +112 -0
  22. data/lib/maglev/hybrid_result.rb +25 -0
  23. data/lib/maglev/index_diagnostics.rb +83 -0
  24. data/lib/maglev/index_identity.rb +70 -0
  25. data/lib/maglev/index_state.rb +9 -0
  26. data/lib/maglev/indexer.rb +185 -35
  27. data/lib/maglev/knowledge_config.rb +27 -5
  28. data/lib/maglev/knowledge_registry.rb +33 -0
  29. data/lib/maglev/planner.rb +172 -0
  30. data/lib/maglev/planner_adapter.rb +25 -0
  31. data/lib/maglev/planner_evaluation.rb +49 -0
  32. data/lib/maglev/query_compiler.rb +197 -0
  33. data/lib/maglev/query_ir.rb +143 -0
  34. data/lib/maglev/query_validator.rb +311 -0
  35. data/lib/maglev/railtie.rb +9 -0
  36. data/lib/maglev/registry.rb +72 -0
  37. data/lib/maglev/reindex_job.rb +34 -2
  38. data/lib/maglev/relation_order.rb +16 -0
  39. data/lib/maglev/request.rb +22 -0
  40. data/lib/maglev/request_executor.rb +101 -0
  41. data/lib/maglev/resource_config.rb +222 -0
  42. data/lib/maglev/response.rb +2 -2
  43. data/lib/maglev/result.rb +30 -0
  44. data/lib/maglev/retrieval_outcome.rb +52 -0
  45. data/lib/maglev/retrieval_result.rb +25 -0
  46. data/lib/maglev/retriever.rb +282 -27
  47. data/lib/maglev/router.rb +77 -0
  48. data/lib/maglev/routing_adapter.rb +25 -0
  49. data/lib/maglev/schema_compiler.rb +17 -4
  50. data/lib/maglev/schema_snapshot.rb +159 -0
  51. data/lib/maglev/search_result.rb +7 -3
  52. data/lib/maglev/snapshot.rb +21 -1
  53. data/lib/maglev/snapshot_budget.rb +57 -0
  54. data/lib/maglev/snapshot_builder.rb +89 -11
  55. data/lib/maglev/source_extractor.rb +43 -0
  56. data/lib/maglev/source_fragment.rb +9 -0
  57. data/lib/maglev/structured_answer_composer.rb +67 -0
  58. data/lib/maglev/structured_evidence_builder.rb +56 -0
  59. data/lib/maglev/structured_executor.rb +157 -0
  60. data/lib/maglev/structured_result.rb +97 -0
  61. data/lib/maglev/trace.rb +56 -0
  62. data/lib/maglev/vector_stores/base.rb +12 -0
  63. data/lib/maglev/vector_stores/document.rb +14 -5
  64. data/lib/maglev/vector_stores/document_id.rb +27 -0
  65. data/lib/maglev/vector_stores/memory.rb +68 -6
  66. data/lib/maglev/vector_stores/metadata_filter.rb +55 -0
  67. data/lib/maglev/vector_stores/pgvector.rb +94 -7
  68. data/lib/maglev/version.rb +1 -1
  69. data/lib/maglev-rb.rb +3 -0
  70. data/lib/maglev.rb +36 -3
  71. data/lib/tasks/maglev.rake +43 -0
  72. metadata +71 -11
  73. data/lib/maglev/adapters/ruby_llm_attachment_extractor.rb +0 -15
  74. data/lib/maglev/adapters/ruby_llm_embedding.rb +0 -22
  75. data/lib/maglev/adapters/ruby_llm_generation.rb +0 -22
  76. data/lib/maglev/adapters/ruby_llm_provider.rb +0 -64
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "configuration"
4
- require_relative "adapters/ruby_llm_generation"
4
+ require_relative "adapters/faraday_generation"
5
5
  require_relative "authorization"
6
6
  require_relative "context_assembler"
7
7
  require_relative "prompt_builder"
@@ -16,26 +16,31 @@ module Maglev
16
16
  authorization: Authorization.new)
17
17
  @model_class = model_class
18
18
  @retriever = retriever
19
- @generation_adapter = generation_adapter || Adapters::RubyLLMGeneration.new
19
+ @generation_adapter = generation_adapter || Adapters::FaradayGeneration.new
20
20
  @authorization = authorization
21
21
  end
22
22
 
23
- def ask(question, limit:, owner: nil, user: nil)
23
+ def ask(question, limit:, owner: nil, user: nil, minimum_similarity: nil, chunks_per_owner: nil)
24
+ chunks_per_owner = validate_chunks_per_owner(chunks_per_owner)
24
25
  @authorization.authorize(record: owner, user: user) if owner
25
26
 
26
- results = if user
27
- @retriever.search(question, limit: limit, owner: owner, user: user)
28
- else
29
- @retriever.search(question, limit: limit, owner: owner)
30
- end
31
- results = results.select { |result| @authorization.authorized?(record: result.owner, user: user) }
32
- return Response.insufficient_context(question: question) if results.empty?
27
+ outcome = @retriever.retrieval_outcome(
28
+ question,
29
+ limit: limit,
30
+ owner: owner,
31
+ user: user,
32
+ minimum_similarity: minimum_similarity,
33
+ chunks_per_owner: chunks_per_owner
34
+ )
35
+
36
+ results = outcome.results.select { |result| @authorization.authorized?(record: result.owner, user: user) }
37
+ return Response.insufficient_context(question: question, retrieval_metadata: rejection_metadata(outcome)) if results.empty?
33
38
 
34
39
  context = nil
35
40
  ActiveSupport::Notifications.instrument("maglev.query.retrieval", model: @model_class.name, result_count: results.size) do
36
41
  context = ContextAssembler.new.assemble(results)
37
42
  end
38
- return Response.insufficient_context(question: question) if context.sources.empty?
43
+ return Response.insufficient_context(question: question, retrieval_metadata: rejection_metadata(outcome)) if context.sources.empty?
39
44
 
40
45
  prompt = PromptBuilder.new.build(question: question, context: context.text)
41
46
  text = nil
@@ -45,12 +50,25 @@ module Maglev
45
50
  Response.new(
46
51
  text: text,
47
52
  sources: context.sources,
48
- metadata: context.metadata.merge(question: question, owner_scope: owner && owner_scope(owner))
53
+ metadata: context.metadata.merge(outcome.metadata).merge(question: question, owner_scope: owner && owner_scope(owner))
49
54
  )
50
55
  end
51
56
 
52
57
  private
53
58
 
59
+ def validate_chunks_per_owner(value)
60
+ return 1 if value.nil?
61
+
62
+ unless value.is_a?(Integer) && value.positive?
63
+ raise ArgumentError, "chunks_per_owner must be a positive Integer, got: #{value.inspect}"
64
+ end
65
+ value
66
+ end
67
+
68
+ def rejection_metadata(outcome)
69
+ outcome.metadata
70
+ end
71
+
54
72
  def owner_scope(owner)
55
73
  {owner_type: owner.class.name, owner_id: owner.respond_to?(:id) ? owner.id : nil}
56
74
  end
@@ -2,12 +2,26 @@
2
2
 
3
3
  module Maglev
4
4
  class Chunker
5
- def initialize(max_characters: Maglev.configuration.chunk_size)
5
+ ALGORITHM_VERSION = "1"
6
+
7
+ def initialize(max_characters: Maglev.configuration.chunk_size, max_chunks: Maglev.configuration.snapshot_max_chunks)
8
+ unless max_characters.is_a?(Integer) && max_characters.positive?
9
+ raise ArgumentError, "max_characters must be positive"
10
+ end
11
+
6
12
  @max_characters = max_characters
13
+ if max_chunks && !max_chunks.is_a?(Integer)
14
+ raise ArgumentError, "max_chunks must be a positive Integer or nil"
15
+ end
16
+ if max_chunks && !max_chunks.positive?
17
+ raise ArgumentError, "max_chunks must be a positive Integer or nil"
18
+ end
19
+ @max_chunks = max_chunks
7
20
  end
8
21
 
9
22
  def call(text)
10
- text.to_s.split(/\n{2,}/).flat_map { |part| split_part(part.strip) }.reject(&:empty?)
23
+ chunks = text.to_s.split(/\n{2,}/).flat_map { |part| split_part(part.strip) }.reject(&:empty?)
24
+ @max_chunks ? chunks.first(@max_chunks) : chunks
11
25
  end
12
26
 
13
27
  private
@@ -17,7 +31,7 @@ module Maglev
17
31
  return [part] if part.length <= @max_characters
18
32
 
19
33
  line_chunks = split_lines(part)
20
- return line_chunks if line_chunks.length > 1
34
+ return line_chunks.flat_map { |chunk| hard_split(chunk) } if line_chunks.length > 1
21
35
 
22
36
  chunks = []
23
37
  current = +""
@@ -33,7 +47,7 @@ module Maglev
33
47
  end
34
48
 
35
49
  chunks << current unless current.empty?
36
- chunks
50
+ chunks.flat_map { |chunk| hard_split(chunk) }
37
51
  end
38
52
 
39
53
  def split_lines(part)
@@ -54,5 +68,26 @@ module Maglev
54
68
  chunks << current unless current.empty?
55
69
  chunks
56
70
  end
71
+
72
+ def hard_split(text)
73
+ chunks = []
74
+ current = +""
75
+
76
+ text.scan(/\X/).each do |grapheme|
77
+ if grapheme.length > @max_characters
78
+ chunks << current unless current.empty?
79
+ chunks.concat(grapheme.chars.each_slice(@max_characters).map(&:join))
80
+ current = +""
81
+ elsif current.length + grapheme.length > @max_characters
82
+ chunks << current
83
+ current = +grapheme
84
+ else
85
+ current << grapheme
86
+ end
87
+ end
88
+
89
+ chunks << current unless current.empty?
90
+ chunks
91
+ end
57
92
  end
58
93
  end
@@ -20,7 +20,12 @@ module Maglev
20
20
  :attachment_extractor, :attachment_allowed_content_types,
21
21
  :attachment_max_bytes, :attachment_max_characters, :authorization_adapter,
22
22
  :provider_max_attempts, :provider_timeout, :source_redactor, :logger,
23
- :vector_store
23
+ :vector_store, :embedding_adapter_id, :embedding_adapter_version,
24
+ :application_index_version, :minimum_similarity,
25
+ :snapshot_attribute_max_characters, :snapshot_related_record_max_characters,
26
+ :snapshot_max_characters, :snapshot_max_chunks,
27
+ :structured_query_role, :structured_query_executor_wrapper, :planner_adapter, :routing_adapter, :audit_sink,
28
+ :tenant_id_resolver
24
29
 
25
30
  def initialize
26
31
  @embedding_provider = ProviderConfiguration.new(
@@ -43,6 +48,18 @@ module Maglev
43
48
  @provider_max_attempts = 2
44
49
  @provider_timeout = 30
45
50
  @source_redactor = nil
51
+ @application_index_version = "1"
52
+ @minimum_similarity = nil
53
+ @snapshot_attribute_max_characters = 20_000
54
+ @snapshot_related_record_max_characters = 50_000
55
+ @snapshot_max_characters = 100_000
56
+ @snapshot_max_chunks = 100
57
+ @structured_query_timeout = 5
58
+ @structured_query_role = nil
59
+ @structured_query_executor_wrapper = nil
60
+ @structured_evidence_max_rows = 100
61
+ @structured_evidence_max_bytes = 32_768
62
+ @retrieval_max_candidates = 1_000
46
63
  end
47
64
 
48
65
  def embedding_provider
@@ -72,5 +89,53 @@ module Maglev
72
89
  def generation_model=(model)
73
90
  @generation_provider.model = model
74
91
  end
92
+
93
+ def tenant_id(record: nil, user: nil)
94
+ return unless @tenant_id_resolver
95
+
96
+ value = @tenant_id_resolver.call(record: record, user: user)
97
+ return if value.nil?
98
+ raise ArgumentError, "tenant id must be a non-empty String" unless value.is_a?(String) && !value.empty?
99
+
100
+ value
101
+ end
102
+
103
+ attr_reader :structured_query_timeout, :structured_evidence_max_rows, :structured_evidence_max_bytes,
104
+ :retrieval_max_candidates
105
+
106
+ def structured_query_timeout=(value)
107
+ raise ArgumentError, "structured_query_timeout must be positive" unless value.is_a?(Numeric) && value.positive?
108
+
109
+ @structured_query_timeout = value
110
+ end
111
+
112
+ %i[structured_evidence_max_rows structured_evidence_max_bytes].each do |name|
113
+ define_method(:"#{name}=") do |value|
114
+ raise ArgumentError, "#{name} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
115
+
116
+ instance_variable_set(:"@#{name}", value)
117
+ end
118
+ end
119
+
120
+ def retrieval_max_candidates=(value)
121
+ raise ArgumentError, "retrieval_max_candidates must be a positive Integer" unless value.is_a?(Integer) && value.positive?
122
+
123
+ @retrieval_max_candidates = value
124
+ end
125
+
126
+ %i[
127
+ snapshot_attribute_max_characters
128
+ snapshot_related_record_max_characters
129
+ snapshot_max_characters
130
+ snapshot_max_chunks
131
+ ].each do |name|
132
+ define_method(:"#{name}=") do |value|
133
+ unless value.is_a?(Integer) && value.positive?
134
+ raise ArgumentError, "#{name} must be a positive Integer"
135
+ end
136
+
137
+ instance_variable_set(:"@#{name}", value)
138
+ end
139
+ end
75
140
  end
76
141
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "reindex_job"
3
+ require_relative "dependency_graph"
4
4
 
5
5
  module Maglev
6
6
  class ContentSourceGraph
@@ -14,41 +14,47 @@ module Maglev
14
14
  record = attachment.record
15
15
  return unless record && declared_attached?(record.class, attachment.name)
16
16
 
17
- ReindexJob.perform_later(record.class.name, record.id) if record.id
17
+ DependencyGraph.reindex_record_and_dependents_for(record) if record.id
18
18
  end
19
19
 
20
20
  def reindex_rich_text_owner(rich_text)
21
21
  record = rich_text.record
22
22
  return unless record && declared_rich_text?(record.class, rich_text.name)
23
23
 
24
- ReindexJob.perform_later(record.class.name, record.id) if record.id
24
+ DependencyGraph.reindex_record_and_dependents_for(record) if record.id
25
25
  end
26
26
 
27
27
  private
28
28
 
29
29
  def register_active_storage
30
30
  return unless defined?(ActiveStorage::Attachment)
31
- return if ActiveStorage::Attachment.method_defined?(:maglev_reindex_attachment_owner)
32
31
 
33
32
  ActiveStorage::Attachment.class_eval do
34
- def maglev_reindex_attachment_owner
35
- Maglev::ContentSourceGraph.reindex_attachment_owner(self)
33
+ unless method_defined?(:maglev_reindex_attachment_owner)
34
+ def maglev_reindex_attachment_owner
35
+ Maglev::ContentSourceGraph.reindex_attachment_owner(self)
36
+ end
36
37
  end
37
38
 
38
- after_commit :maglev_reindex_attachment_owner, on: %i[create destroy]
39
+ unless _commit_callbacks.any? { |callback| callback.filter == :maglev_reindex_attachment_owner }
40
+ after_commit :maglev_reindex_attachment_owner, on: %i[create destroy]
41
+ end
39
42
  end
40
43
  end
41
44
 
42
45
  def register_action_text
43
46
  return unless defined?(ActionText::RichText)
44
- return if ActionText::RichText.method_defined?(:maglev_reindex_rich_text_owner)
45
47
 
46
48
  ActionText::RichText.class_eval do
47
- def maglev_reindex_rich_text_owner
48
- Maglev::ContentSourceGraph.reindex_rich_text_owner(self)
49
+ unless method_defined?(:maglev_reindex_rich_text_owner)
50
+ def maglev_reindex_rich_text_owner
51
+ Maglev::ContentSourceGraph.reindex_rich_text_owner(self)
52
+ end
49
53
  end
50
54
 
51
- after_commit :maglev_reindex_rich_text_owner, on: %i[create update destroy]
55
+ unless _commit_callbacks.any? { |callback| callback.filter == :maglev_reindex_rich_text_owner }
56
+ after_commit :maglev_reindex_rich_text_owner, on: %i[create update destroy]
57
+ end
52
58
  end
53
59
  end
54
60
 
@@ -4,35 +4,49 @@ require_relative "reindex_job"
4
4
 
5
5
  module Maglev
6
6
  class DependencyGraph
7
- Edge = Struct.new(:owner_class, :related_class, :relation_name, :inverse)
7
+ Edge = Struct.new(:owner_class, :related_class, :relation_name, :inverse, :depth)
8
8
 
9
9
  class << self
10
+ def reset!
11
+ @edges = Hash.new { |hash, klass| hash[klass] = [] }
12
+ end
13
+
10
14
  def register(schema)
11
15
  schema.relations.each do |relation|
12
- edge = Edge.new(schema.model_class, relation.related_class, relation.name, relation.inverse)
13
- next if edges_for(relation.related_class).any? { |existing| same_edge?(existing, edge) }
16
+ edge = Edge.new(schema.model_class, relation.related_class, relation.name, relation.inverse, relation.depth)
17
+ existing = edges_for(relation.related_class).find { |candidate| same_edge?(candidate, edge) }
18
+ if existing
19
+ existing.depth = edge.depth
20
+ next
21
+ end
14
22
 
15
23
  edges_for(relation.related_class) << edge
16
24
  install_callbacks(relation.related_class)
17
25
  end
18
26
  end
19
27
 
20
- def reindex_dependents_for(record)
21
- dependent_owners = previous_dependent_owners(record)
22
- edges_for(record.class).each do |edge|
23
- dependent_owners.concat(owners_for(record, edge))
28
+ def unregister(owner_class)
29
+ edges.each_value do |registered_edges|
30
+ registered_edges.reject! { |edge| edge.owner_class == owner_class }
24
31
  end
32
+ end
25
33
 
26
- dependent_owners.uniq { |owner| [owner.class.name, owner.id] }.each do |owner|
27
- next unless owner.respond_to?(:id) && owner.id
34
+ def reindex_dependents_for(record)
35
+ enqueue(transitive_dependents_for(record))
36
+ clear_previous_dependent_owners(record)
37
+ end
28
38
 
29
- ReindexJob.perform_later(owner.class.name, owner.id)
30
- end
31
- record.remove_instance_variable(:@maglev_previous_dependent_owners) if record.instance_variable_defined?(:@maglev_previous_dependent_owners)
39
+ def reindex_record_and_dependents_for(record)
40
+ enqueue([record, *transitive_dependents_for(record)])
41
+ clear_previous_dependent_owners(record)
32
42
  end
33
43
 
34
44
  def capture_previous_dependents_for(record)
35
- owners = edges_for(record.class).flat_map { |edge| previous_owners_for(record, edge) }
45
+ owners = edges_for(record.class).flat_map do |edge|
46
+ next [] if edge.depth < 1
47
+
48
+ previous_owners_for(record, edge)
49
+ end
36
50
  record.instance_variable_set(:@maglev_previous_dependent_owners, owners)
37
51
  end
38
52
 
@@ -98,6 +112,51 @@ module Maglev
98
112
  end
99
113
  end
100
114
 
115
+ def transitive_dependents_for(record)
116
+ visited = {owner_key(record) => true}
117
+ queue = previous_dependent_owners(record).map { |owner| [owner, 1] }
118
+ queue.concat(direct_dependents_for(record, 0))
119
+ dependents = []
120
+
121
+ until queue.empty?
122
+ owner, distance = queue.shift
123
+ key = owner_key(owner)
124
+ next if visited[key]
125
+
126
+ visited[key] = true
127
+ dependents << owner
128
+ queue.concat(direct_dependents_for(owner, distance))
129
+ end
130
+
131
+ dependents
132
+ end
133
+
134
+ def direct_dependents_for(record, distance)
135
+ next_distance = distance + 1
136
+
137
+ edges_for(record.class).flat_map do |edge|
138
+ next [] if edge.depth < next_distance
139
+
140
+ owners_for(record, edge).map { |owner| [owner, next_distance] }
141
+ end
142
+ end
143
+
144
+ def enqueue(owners)
145
+ owners.uniq { |owner| owner_key(owner) }.each do |owner|
146
+ next unless owner.respond_to?(:id) && owner.id
147
+
148
+ ReindexJob.perform_later(owner.class.name, owner.id)
149
+ end
150
+ end
151
+
152
+ def owner_key(owner)
153
+ [owner.class.name, owner.respond_to?(:id) ? owner.id : owner.object_id]
154
+ end
155
+
156
+ def clear_previous_dependent_owners(record)
157
+ record.remove_instance_variable(:@maglev_previous_dependent_owners) if record.instance_variable_defined?(:@maglev_previous_dependent_owners)
158
+ end
159
+
101
160
  def previous_owners_for(record, edge)
102
161
  inverse_reflection = record.class.reflect_on_association(edge.inverse.to_sym)
103
162
  return owners_for(record, edge) unless inverse_reflection&.belongs_to?
@@ -1,7 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "errors"
4
+
3
5
  module Maglev
4
6
  class EmbeddingAdapter
7
+ def maglev_adapter_id
8
+ raise ConfigurationError, "embedding adapter ID must be implemented by concrete adapters"
9
+ end
10
+
11
+ def maglev_adapter_version
12
+ raise ConfigurationError, "embedding adapter version must be implemented by concrete adapters"
13
+ end
14
+
5
15
  def embed(_text)
6
16
  raise NotImplementedError, "#{self.class.name} must implement #embed"
7
17
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maglev
4
+ class HybridCandidateSet
5
+ DEFAULT_LIMIT = 100
6
+
7
+ attr_reader :model_class, :ids, :tenant_id, :trace_id
8
+
9
+ def initialize(model_class:, ids:, tenant_id:, trace_id:, limit: DEFAULT_LIMIT)
10
+ raise ArgumentError, "candidate limit must be a positive Integer" unless limit.is_a?(Integer) && limit.positive?
11
+ raise ConfigurationError, "hybrid candidate set exceeds #{limit} ids" if ids.size > limit
12
+
13
+ primary_key = model_class.primary_key
14
+ type = model_class.type_for_attribute(primary_key)
15
+ cast_ids = ids.map { |id| type.cast(id) }
16
+ raise ConfigurationError, "hybrid candidate set contains an invalid primary key" if cast_ids.any?(&:nil?)
17
+
18
+ @model_class = model_class
19
+ @ids = cast_ids.uniq.freeze
20
+ @tenant_id = tenant_id&.to_s&.freeze
21
+ @trace_id = trace_id.to_s.freeze
22
+ freeze
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Maglev
6
+ class HybridCoordinator
7
+ SHAPES = %i[structured_first rag_first].freeze
8
+
9
+ def initialize(retriever_factory:)
10
+ @retriever_factory = retriever_factory
11
+ end
12
+
13
+ def call(request, decision)
14
+ shape = request.options[:hybrid_plan]&.to_sym
15
+ raise ConfigurationError, "a fixed hybrid plan (structured_first or rag_first) is required" unless SHAPES.include?(shape)
16
+
17
+ entry = hybrid_entry(request)
18
+ relation = authorized_relation(request, entry)
19
+ trace_id = SecureRandom.uuid
20
+ send(shape, request, decision, entry, relation, trace_id)
21
+ end
22
+
23
+ private
24
+
25
+ def hybrid_entry(request)
26
+ entries = request.resources.filter_map { |identifier| Registry.fetch(identifier) }
27
+ .select { |entry| entry.queryable && entry.knowledge }
28
+ raise ConfigurationError, "hybrid routing requires exactly one registered resource with structured and RAG capabilities" unless entries.one?
29
+
30
+ entries.first
31
+ end
32
+
33
+ def authorized_relation(request, entry)
34
+ relation = request.base_relation
35
+ relation ||= entry.model_class.all if entry.queryable.allow_unscoped_model_queries
36
+ unless relation && relation.klass == entry.model_class
37
+ raise ConfigurationError, "hybrid requests require a matching authorized base relation"
38
+ end
39
+ relation
40
+ end
41
+
42
+ def structured_first(request, decision, entry, relation, trace_id)
43
+ structured = structured_result(request, entry, relation)
44
+ return wrap_non_success(structured, decision, :structured_first, trace_id) unless structured.status == :succeeded
45
+ raise ConfigurationError, "hybrid structured stages require record plans" unless structured.kind == :relation
46
+
47
+ ids = structured.value.pluck(entry.model_class.primary_key)
48
+ candidates = candidate_set(entry, ids, request, trace_id)
49
+ retrieval = retrieve(request, entry, candidates: candidates, trace_id: trace_id)
50
+ evidence = [HybridEvidence.new(provenance: :structured, value: structured.evidence),
51
+ HybridEvidence.new(provenance: :rag, value: retrieval)].freeze
52
+ result(decision, trace_id, :structured_first, candidates.ids, evidence,
53
+ ["structured filter", "RAG within typed candidates"])
54
+ end
55
+
56
+ def rag_first(request, decision, entry, relation, trace_id)
57
+ retrieval = retrieve(request, entry, trace_id: trace_id)
58
+ owners = retrieval.selected.map(&:owner)
59
+ if owners.any? { |owner| !owner.is_a?(entry.model_class) }
60
+ raise ConfigurationError, "hybrid retrieval returned mixed model candidates"
61
+ end
62
+ candidates = candidate_set(entry, owners.map(&:id), request, trace_id)
63
+ candidate_relation = relation.where(entry.model_class.primary_key => candidates.ids)
64
+ structured = structured_result(request, entry, candidate_relation)
65
+ return wrap_non_success(structured, decision, :rag_first, trace_id) unless structured.status == :succeeded
66
+ raise ConfigurationError, "hybrid structured stages require record plans" unless structured.kind == :relation
67
+
68
+ records = structured.value.to_a
69
+ missing = candidates.ids.size - records.size
70
+ warnings = missing.positive? ? ["#{missing} stale, deleted, or unauthorized candidates were excluded."] : []
71
+ evidence = [HybridEvidence.new(provenance: :rag, value: retrieval),
72
+ HybridEvidence.new(provenance: :structured, value: structured.evidence)].freeze
73
+ result(decision, trace_id, :rag_first, records, evidence,
74
+ ["RAG candidate retrieval", "structured verification"], warnings: warnings)
75
+ end
76
+
77
+ def structured_result(request, entry, relation)
78
+ plan = Maglev.plan(request.question, resource: entry.identifier, resources: request.resources,
79
+ base_relation: relation, user: request.user, authorizer: request.options[:authorizer],
80
+ constraints: request.options.fetch(:constraints, {}),
81
+ adapter: request.options[:planner_adapter] || Maglev.configuration.planner_adapter)
82
+ Maglev.execute(plan)
83
+ end
84
+
85
+ def retrieve(request, entry, trace_id:, candidates: nil)
86
+ options = {limit: request.options.fetch(:limit, 10), user: request.user,
87
+ minimum_similarity: request.options[:minimum_similarity],
88
+ chunks_per_owner: request.options.fetch(:chunks_per_owner, 1), trace_id: trace_id}
89
+ options[:candidates] = candidates if candidates
90
+ @retriever_factory.call(entry.model_class).retrieve(request.question, **options)
91
+ end
92
+
93
+ def candidate_set(entry, ids, request, trace_id)
94
+ HybridCandidateSet.new(model_class: entry.model_class, ids: ids,
95
+ tenant_id: request.user && Maglev.configuration.tenant_id(user: request.user), trace_id: trace_id,
96
+ limit: request.options.fetch(:candidate_limit, HybridCandidateSet::DEFAULT_LIMIT))
97
+ end
98
+
99
+ def result(decision, trace_id, shape, records, evidence, operations, warnings: [])
100
+ Result.new(status: :succeeded, route: :hybrid, kind: :hybrid_answer,
101
+ value: HybridAnswer.new(records: records), evidence: evidence, warnings: warnings,
102
+ trace_id: trace_id, confidence: decision.confidence, reasons: decision.reasons,
103
+ metadata: {plan_shape: shape, operations: operations.freeze}.freeze)
104
+ end
105
+
106
+ def wrap_non_success(structured, decision, shape, trace_id)
107
+ Result.new(status: structured.status, route: :hybrid, kind: :none,
108
+ warnings: structured.warnings, trace_id: trace_id, confidence: decision.confidence,
109
+ reasons: decision.reasons, metadata: {plan_shape: shape}.freeze)
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maglev
4
+ class HybridEvidence
5
+ attr_reader :provenance, :value
6
+
7
+ def initialize(provenance:, value:)
8
+ raise ArgumentError, "invalid hybrid evidence provenance" unless %i[structured rag].include?(provenance)
9
+
10
+ @provenance = provenance
11
+ @value = value
12
+ freeze
13
+ end
14
+ end
15
+
16
+ class HybridAnswer
17
+ attr_reader :records, :claims
18
+
19
+ def initialize(records:, claims: [])
20
+ @records = records.freeze
21
+ @claims = claims.freeze
22
+ freeze
23
+ end
24
+ end
25
+ end