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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.ja.md +618 -0
- data/README.md +533 -248
- data/README.zh-CN.md +457 -250
- data/lib/generators/maglev/install/install_generator.rb +20 -0
- data/lib/generators/maglev/upgrade_index_version/upgrade_index_version_generator.rb +27 -0
- data/lib/generators/maglev/upgrade_source_identity/upgrade_source_identity_generator.rb +52 -0
- data/lib/maglev/active_record_extension.rb +141 -24
- data/lib/maglev/adapters/faraday_client.rb +94 -0
- data/lib/maglev/adapters/faraday_embedding.rb +51 -0
- data/lib/maglev/adapters/faraday_generation.rb +49 -0
- data/lib/maglev/adapters/faraday_planner.rb +88 -0
- data/lib/maglev/answerer.rb +30 -12
- data/lib/maglev/chunker.rb +39 -4
- data/lib/maglev/configuration.rb +66 -1
- data/lib/maglev/content_source_graph.rb +17 -11
- data/lib/maglev/dependency_graph.rb +72 -13
- data/lib/maglev/embedding_adapter.rb +10 -0
- data/lib/maglev/hybrid_candidate_set.rb +25 -0
- data/lib/maglev/hybrid_coordinator.rb +112 -0
- data/lib/maglev/hybrid_result.rb +25 -0
- data/lib/maglev/index_diagnostics.rb +83 -0
- data/lib/maglev/index_identity.rb +70 -0
- data/lib/maglev/index_state.rb +9 -0
- data/lib/maglev/indexer.rb +185 -35
- data/lib/maglev/knowledge_config.rb +27 -5
- data/lib/maglev/knowledge_registry.rb +33 -0
- data/lib/maglev/planner.rb +172 -0
- data/lib/maglev/planner_adapter.rb +25 -0
- data/lib/maglev/planner_evaluation.rb +49 -0
- data/lib/maglev/query_compiler.rb +197 -0
- data/lib/maglev/query_ir.rb +143 -0
- data/lib/maglev/query_validator.rb +311 -0
- data/lib/maglev/railtie.rb +9 -0
- data/lib/maglev/registry.rb +72 -0
- data/lib/maglev/reindex_job.rb +34 -2
- data/lib/maglev/relation_order.rb +16 -0
- data/lib/maglev/request.rb +22 -0
- data/lib/maglev/request_executor.rb +101 -0
- data/lib/maglev/resource_config.rb +222 -0
- data/lib/maglev/response.rb +2 -2
- data/lib/maglev/result.rb +30 -0
- data/lib/maglev/retrieval_outcome.rb +52 -0
- data/lib/maglev/retrieval_result.rb +25 -0
- data/lib/maglev/retriever.rb +282 -27
- data/lib/maglev/router.rb +77 -0
- data/lib/maglev/routing_adapter.rb +25 -0
- data/lib/maglev/schema_compiler.rb +17 -4
- data/lib/maglev/schema_snapshot.rb +159 -0
- data/lib/maglev/search_result.rb +7 -3
- data/lib/maglev/snapshot.rb +21 -1
- data/lib/maglev/snapshot_budget.rb +57 -0
- data/lib/maglev/snapshot_builder.rb +89 -11
- data/lib/maglev/source_extractor.rb +43 -0
- data/lib/maglev/source_fragment.rb +9 -0
- data/lib/maglev/structured_answer_composer.rb +67 -0
- data/lib/maglev/structured_evidence_builder.rb +56 -0
- data/lib/maglev/structured_executor.rb +157 -0
- data/lib/maglev/structured_result.rb +97 -0
- data/lib/maglev/trace.rb +56 -0
- data/lib/maglev/vector_stores/base.rb +12 -0
- data/lib/maglev/vector_stores/document.rb +14 -5
- data/lib/maglev/vector_stores/document_id.rb +27 -0
- data/lib/maglev/vector_stores/memory.rb +68 -6
- data/lib/maglev/vector_stores/metadata_filter.rb +55 -0
- data/lib/maglev/vector_stores/pgvector.rb +94 -7
- data/lib/maglev/version.rb +1 -1
- data/lib/maglev-rb.rb +3 -0
- data/lib/maglev.rb +36 -3
- data/lib/tasks/maglev.rake +43 -0
- metadata +71 -11
- data/lib/maglev/adapters/ruby_llm_attachment_extractor.rb +0 -15
- data/lib/maglev/adapters/ruby_llm_embedding.rb +0 -22
- data/lib/maglev/adapters/ruby_llm_generation.rb +0 -22
- data/lib/maglev/adapters/ruby_llm_provider.rb +0 -64
data/lib/maglev/answerer.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "configuration"
|
|
4
|
-
require_relative "adapters/
|
|
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::
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
data/lib/maglev/chunker.rb
CHANGED
|
@@ -2,12 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module Maglev
|
|
4
4
|
class Chunker
|
|
5
|
-
|
|
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
|
data/lib/maglev/configuration.rb
CHANGED
|
@@ -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 "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
|
|
34
|
+
def reindex_dependents_for(record)
|
|
35
|
+
enqueue(transitive_dependents_for(record))
|
|
36
|
+
clear_previous_dependent_owners(record)
|
|
37
|
+
end
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|