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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "index_state"
|
|
4
|
+
|
|
5
|
+
module Maglev
|
|
6
|
+
class IndexDiagnostics
|
|
7
|
+
State = Data.define(:owner_type, :owner_id, :status, :active_index_version, :chunk_count,
|
|
8
|
+
:last_success_at, :latest_failure, :rebuild_required)
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def record_started(owner_type:, owner_id:, index_version:)
|
|
12
|
+
return unless available?
|
|
13
|
+
|
|
14
|
+
previous = IndexState.find_by(owner_type: owner_type.to_s, owner_id: owner_id)
|
|
15
|
+
persist(owner_type, owner_id, status: previous&.last_success_at ? "rebuilding" : "indexing",
|
|
16
|
+
active_index_version: index_version, rebuild_required: true)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def record_success(owner_type:, owner_id:, index_version:, chunk_count:)
|
|
20
|
+
return unless available?
|
|
21
|
+
|
|
22
|
+
persist(owner_type, owner_id, status: "ready", active_index_version: index_version,
|
|
23
|
+
chunk_count: chunk_count, last_success_at: Time.now.utc, latest_failure_class: nil,
|
|
24
|
+
latest_failure_at: nil, rebuild_required: false)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def record_failure(owner_type:, owner_id:, index_version:, error:)
|
|
28
|
+
return unless available?
|
|
29
|
+
|
|
30
|
+
persist(owner_type, owner_id, status: "failed", active_index_version: index_version,
|
|
31
|
+
latest_failure_class: error.class.name, latest_failure_at: Time.now.utc, rebuild_required: true)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def record_unindexed(owner_type:, owner_id:)
|
|
35
|
+
return unless available?
|
|
36
|
+
|
|
37
|
+
persist(owner_type, owner_id, status: "not_indexed", active_index_version: nil,
|
|
38
|
+
chunk_count: 0, rebuild_required: false)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def status(owner_type:, owner_id:)
|
|
42
|
+
return empty_state(owner_type, owner_id) unless available?
|
|
43
|
+
|
|
44
|
+
row = IndexState.find_by(owner_type: owner_type.to_s, owner_id: owner_id)
|
|
45
|
+
return empty_state(owner_type, owner_id) unless row
|
|
46
|
+
|
|
47
|
+
State.new(owner_type: row.owner_type, owner_id: row.owner_id, status: row.status.to_sym,
|
|
48
|
+
active_index_version: row.active_index_version, chunk_count: row.chunk_count,
|
|
49
|
+
last_success_at: row.last_success_at, latest_failure: failure_for(row),
|
|
50
|
+
rebuild_required: row.rebuild_required)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def available?
|
|
56
|
+
IndexState.table_exists?
|
|
57
|
+
rescue ActiveRecord::ConnectionNotDefined, ActiveRecord::StatementInvalid
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def persist(owner_type, owner_id, **attributes)
|
|
62
|
+
row = IndexState.create_or_find_by!(owner_type: owner_type.to_s, owner_id: owner_id) do |state|
|
|
63
|
+
state.assign_attributes(attributes)
|
|
64
|
+
end
|
|
65
|
+
row.with_lock do
|
|
66
|
+
row.update!(attributes)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def empty_state(owner_type, owner_id)
|
|
71
|
+
State.new(owner_type: owner_type.to_s, owner_id: owner_id, status: :not_indexed,
|
|
72
|
+
active_index_version: nil, chunk_count: 0, last_success_at: nil,
|
|
73
|
+
latest_failure: nil, rebuild_required: false)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def failure_for(row)
|
|
77
|
+
return unless row.latest_failure_class
|
|
78
|
+
|
|
79
|
+
{error_class: row.latest_failure_class, occurred_at: row.latest_failure_at}.freeze
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "chunker"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
|
|
9
|
+
module Maglev
|
|
10
|
+
class IndexIdentity
|
|
11
|
+
PAYLOAD_NAMESPACE = "maglev-index"
|
|
12
|
+
FORMAT_VERSION = 1
|
|
13
|
+
|
|
14
|
+
def initialize(configuration:, adapter:, chunk_size:)
|
|
15
|
+
@configuration = configuration
|
|
16
|
+
@adapter = adapter
|
|
17
|
+
@chunk_size = chunk_size
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_s
|
|
21
|
+
Digest::SHA256.hexdigest(JSON.generate([
|
|
22
|
+
PAYLOAD_NAMESPACE,
|
|
23
|
+
FORMAT_VERSION,
|
|
24
|
+
required_string(@configuration.embedding_model, "embedding model"),
|
|
25
|
+
positive_integer(@configuration.embedding_dimensions, "embedding dimensions"),
|
|
26
|
+
adapter_id,
|
|
27
|
+
adapter_version,
|
|
28
|
+
required_string(Chunker::ALGORITHM_VERSION, "chunking algorithm version"),
|
|
29
|
+
positive_integer(@chunk_size, "chunk size"),
|
|
30
|
+
required_string(@configuration.application_index_version, "application index version")
|
|
31
|
+
]))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def adapter_id
|
|
37
|
+
value = @configuration.embedding_adapter_id
|
|
38
|
+
if value.nil?
|
|
39
|
+
unless @adapter.respond_to?(:maglev_adapter_id)
|
|
40
|
+
raise ConfigurationError, "embedding adapter ID must be configured or implemented by the adapter"
|
|
41
|
+
end
|
|
42
|
+
value = @adapter.maglev_adapter_id
|
|
43
|
+
end
|
|
44
|
+
required_string(value, "embedding adapter ID")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def adapter_version
|
|
48
|
+
value = @configuration.embedding_adapter_version
|
|
49
|
+
if value.nil?
|
|
50
|
+
unless @adapter.respond_to?(:maglev_adapter_version)
|
|
51
|
+
raise ConfigurationError, "embedding adapter version must be configured or implemented by the adapter"
|
|
52
|
+
end
|
|
53
|
+
value = @adapter.maglev_adapter_version
|
|
54
|
+
end
|
|
55
|
+
required_string(value, "embedding adapter version")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def required_string(value, name)
|
|
59
|
+
return value if value.is_a?(String) && !value.empty?
|
|
60
|
+
|
|
61
|
+
raise ConfigurationError, "#{name} must be a non-empty string"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def positive_integer(value, name)
|
|
65
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
66
|
+
|
|
67
|
+
raise ConfigurationError, "#{name} must be a positive integer"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/maglev/indexer.rb
CHANGED
|
@@ -2,50 +2,115 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
|
|
5
|
-
require_relative "adapters/
|
|
5
|
+
require_relative "adapters/faraday_embedding"
|
|
6
6
|
require_relative "chunk"
|
|
7
7
|
require_relative "chunker"
|
|
8
8
|
require_relative "errors"
|
|
9
|
+
require_relative "index_identity"
|
|
9
10
|
require_relative "provider_call"
|
|
10
11
|
require_relative "vector_stores/document"
|
|
12
|
+
require_relative "source_extractor"
|
|
13
|
+
require_relative "index_diagnostics"
|
|
14
|
+
require_relative "vector_stores/document_id"
|
|
11
15
|
|
|
12
16
|
module Maglev
|
|
13
17
|
class Indexer
|
|
14
18
|
SOURCE = "snapshot"
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
IdentityConfiguration = Struct.new(
|
|
20
|
+
:embedding_model,
|
|
21
|
+
:embedding_dimensions,
|
|
22
|
+
:embedding_adapter_id,
|
|
23
|
+
:embedding_adapter_version,
|
|
24
|
+
:application_index_version
|
|
25
|
+
)
|
|
26
|
+
private_constant :IdentityConfiguration
|
|
27
|
+
|
|
28
|
+
def initialize(record, chunk_model: Chunk, embedding_adapter: Maglev.configuration.embedding_adapter, embedding_dimensions: Maglev.configuration.embedding_dimensions, chunk_size: Maglev.configuration.chunk_size, vector_store: Maglev.configuration.vector_store, index_identity: nil, provider_call: ProviderCall.new)
|
|
17
29
|
@record = record
|
|
18
30
|
@chunk_model = chunk_model
|
|
19
|
-
@embedding_adapter = embedding_adapter || Adapters::
|
|
31
|
+
@embedding_adapter = embedding_adapter || Adapters::FaradayEmbedding.new
|
|
20
32
|
@embedding_dimensions = embedding_dimensions
|
|
21
33
|
@chunk_size = chunk_size
|
|
22
34
|
@vector_store = vector_store
|
|
35
|
+
@index_identity = index_identity
|
|
36
|
+
@provider_call = provider_call
|
|
23
37
|
end
|
|
24
38
|
|
|
25
39
|
def index
|
|
26
40
|
validate_owner_id!
|
|
27
41
|
validate_storage_dimensions!
|
|
42
|
+
prepare_index_identity
|
|
43
|
+
IndexDiagnostics.record_started(owner_type: @record.class.name, owner_id: @record.id, index_version: @index_version)
|
|
28
44
|
ActiveSupport::Notifications.instrument("maglev.index.start", owner_type: @record.class.name, owner_id: @record.id)
|
|
45
|
+
@embedding_count = 0
|
|
29
46
|
|
|
47
|
+
index_metadata = nil
|
|
30
48
|
chunk_count = loop do
|
|
31
|
-
|
|
32
|
-
|
|
49
|
+
snapshot_result = if @record.respond_to?(:maglev_snapshot_result) && @record.class.respond_to?(:maglev_config) && @record.class.maglev_config
|
|
50
|
+
@record.maglev_snapshot_result
|
|
51
|
+
end
|
|
52
|
+
snapshot = snapshot_result ? snapshot_result.to_s : @record.maglev_snapshot
|
|
53
|
+
source_chunks = chunks_for(snapshot, snapshot_result)
|
|
54
|
+
all_chunks = source_chunks
|
|
55
|
+
chunks = all_chunks.first(Maglev.configuration.snapshot_max_chunks)
|
|
56
|
+
budget_metadata = snapshot_result&.metadata || {truncated: false, sources: []}
|
|
57
|
+
if all_chunks.length > chunks.length && budget_metadata.fetch(:sources, []).none? { |source| source[:kind] == :chunks }
|
|
58
|
+
budget_metadata = budget_metadata.merge(
|
|
59
|
+
truncated: true,
|
|
60
|
+
sources: budget_metadata.fetch(:sources, []) + [{
|
|
61
|
+
kind: :chunks,
|
|
62
|
+
path: "snapshot.chunks",
|
|
63
|
+
original_chunks: all_chunks.length,
|
|
64
|
+
retained_chunks: chunks.length
|
|
65
|
+
}]
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
index_metadata = budget_metadata.merge(
|
|
69
|
+
original_chunk_count: all_chunks.length,
|
|
70
|
+
retained_chunk_count: chunks.length,
|
|
71
|
+
chunk_limit: Maglev.configuration.snapshot_max_chunks
|
|
72
|
+
)
|
|
73
|
+
index_metadata = deep_freeze(index_metadata)
|
|
33
74
|
prepared = @vector_store ? prepare_documents(chunks) : prepare_chunks(chunks)
|
|
34
75
|
break chunks.length if persist_if_current(snapshot, prepared)
|
|
35
76
|
end
|
|
36
77
|
|
|
37
|
-
ActiveSupport::Notifications.instrument("maglev.index.success", owner_type: @record.class.name, owner_id: @record.id, chunk_count: chunk_count)
|
|
78
|
+
ActiveSupport::Notifications.instrument("maglev.index.success", owner_type: @record.class.name, owner_id: @record.id, chunk_count: chunk_count, budget: index_metadata)
|
|
79
|
+
IndexDiagnostics.record_success(owner_type: @record.class.name, owner_id: @record.id,
|
|
80
|
+
index_version: @index_version, chunk_count: chunk_count)
|
|
38
81
|
rescue => error
|
|
39
|
-
ActiveSupport::Notifications.instrument("maglev.index.failure", owner_type: @record.class.name, owner_id: @record.id, error_class: error.class.name)
|
|
82
|
+
ActiveSupport::Notifications.instrument("maglev.index.failure", owner_type: @record.class.name, owner_id: @record.id, error_class: error.class.name, budget: index_metadata)
|
|
83
|
+
IndexDiagnostics.record_failure(owner_type: @record.class.name, owner_id: @record.id,
|
|
84
|
+
index_version: @index_version, error: error)
|
|
40
85
|
raise
|
|
41
86
|
end
|
|
42
87
|
|
|
43
88
|
def unindex
|
|
44
|
-
|
|
89
|
+
if @vector_store
|
|
90
|
+
@vector_store.delete_by_owner(owner_type: @record.class.name, owner_id: @record.id)
|
|
91
|
+
else
|
|
92
|
+
identity_scope.delete_all
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
IndexDiagnostics.record_unindexed(owner_type: @record.class.name, owner_id: @record.id)
|
|
45
96
|
end
|
|
46
97
|
|
|
47
98
|
private
|
|
48
99
|
|
|
100
|
+
def chunks_for(snapshot, snapshot_result)
|
|
101
|
+
unless snapshot_result
|
|
102
|
+
return Chunker.new(max_characters: @chunk_size, max_chunks: nil).call(snapshot).each_with_index.map do |content, index|
|
|
103
|
+
{content: content, source_identity: SOURCE, source_type: :snapshot, chunk_index: index}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
SourceExtractor.new.call(snapshot).flat_map do |source|
|
|
108
|
+
Chunker.new(max_characters: @chunk_size, max_chunks: nil).call(source.content).each_with_index.map do |content, index|
|
|
109
|
+
{content: content, source_identity: source.identity, source_type: source.type, chunk_index: index}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
49
114
|
def persist_if_current(snapshot, prepared)
|
|
50
115
|
with_owner_lock do
|
|
51
116
|
next false unless @record.maglev_snapshot == snapshot
|
|
@@ -55,11 +120,11 @@ module Maglev
|
|
|
55
120
|
end
|
|
56
121
|
|
|
57
122
|
def prepare_chunks(chunks)
|
|
58
|
-
chunks.
|
|
59
|
-
checksum = Digest::SHA256.hexdigest(content)
|
|
60
|
-
existing =
|
|
61
|
-
embedding = embed(content) unless existing
|
|
62
|
-
|
|
123
|
+
chunks.map do |chunk|
|
|
124
|
+
checksum = Digest::SHA256.hexdigest(chunk[:content])
|
|
125
|
+
existing = identity_scope_for(chunk).find_by(chunk_index: chunk[:chunk_index], content_checksum: checksum, index_version: @index_version)
|
|
126
|
+
embedding = embed(chunk[:content]) unless existing
|
|
127
|
+
chunk.merge(checksum: checksum, embedding: embedding)
|
|
63
128
|
end
|
|
64
129
|
end
|
|
65
130
|
|
|
@@ -68,48 +133,82 @@ module Maglev
|
|
|
68
133
|
|
|
69
134
|
@chunk_model.transaction do
|
|
70
135
|
chunks.each { |chunk| persist_chunk(chunk) }
|
|
71
|
-
delete_obsolete_chunks(chunks
|
|
136
|
+
delete_obsolete_chunks(chunks)
|
|
72
137
|
end
|
|
73
138
|
true
|
|
74
139
|
end
|
|
75
140
|
|
|
76
141
|
def matching_chunk?(chunk)
|
|
77
|
-
|
|
142
|
+
identity_scope_for(chunk).find_by(chunk_index: chunk[:chunk_index], content_checksum: chunk[:checksum], index_version: @index_version)
|
|
78
143
|
end
|
|
79
144
|
|
|
80
145
|
def persist_chunk(chunk)
|
|
81
146
|
return if matching_chunk?(chunk)
|
|
82
147
|
|
|
83
|
-
|
|
148
|
+
identity_scope_for(chunk).where(chunk_index: chunk[:chunk_index]).delete_all
|
|
84
149
|
|
|
85
|
-
|
|
150
|
+
attributes = {
|
|
86
151
|
**identity,
|
|
87
152
|
owner: @record,
|
|
88
|
-
source:
|
|
153
|
+
source: chunk[:source_identity],
|
|
89
154
|
chunk_index: chunk[:chunk_index],
|
|
90
155
|
content: chunk[:content],
|
|
91
156
|
content_checksum: chunk[:checksum],
|
|
92
|
-
embedding_model:
|
|
157
|
+
embedding_model: @identity_configuration.embedding_model,
|
|
158
|
+
index_version: @index_version,
|
|
93
159
|
embedding: chunk[:embedding]
|
|
94
|
-
|
|
160
|
+
}
|
|
161
|
+
if source_metadata_columns?
|
|
162
|
+
attributes[:source_identity] = chunk[:source_identity]
|
|
163
|
+
attributes[:source_type] = chunk[:source_type]
|
|
164
|
+
attributes[:tenant_id] = Maglev.configuration.tenant_id(record: @record)
|
|
165
|
+
end
|
|
166
|
+
@chunk_model.create!(attributes)
|
|
95
167
|
end
|
|
96
168
|
|
|
97
169
|
def prepare_documents(chunks)
|
|
98
|
-
chunks.
|
|
170
|
+
prepared = chunks.map do |chunk|
|
|
171
|
+
{
|
|
172
|
+
id: stable_document_id(chunk[:source_identity], chunk[:chunk_index]),
|
|
173
|
+
content: chunk[:content],
|
|
174
|
+
source_identity: chunk[:source_identity], source_type: chunk[:source_type],
|
|
175
|
+
chunk_index: chunk[:chunk_index],
|
|
176
|
+
checksum: Digest::SHA256.hexdigest(chunk[:content])
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
existing_by_id = @vector_store.fetch(ids: prepared.map { |chunk| chunk[:id] }).to_h { |document| [document.id, document] }
|
|
180
|
+
|
|
181
|
+
prepared.map do |chunk|
|
|
182
|
+
existing = existing_by_id[chunk[:id]]
|
|
183
|
+
reusable = existing && existing.content_checksum == chunk[:checksum] && existing.index_version == @index_version
|
|
184
|
+
document_for(chunk, embedding: reusable ? existing.embedding : embed(chunk[:content]))
|
|
185
|
+
end
|
|
99
186
|
end
|
|
100
187
|
|
|
101
188
|
def persist_documents(documents)
|
|
102
|
-
@vector_store.
|
|
103
|
-
@vector_store.upsert(documents: documents)
|
|
189
|
+
@vector_store.replace_owner(owner_type: @record.class.name, owner_id: @record.id, documents: documents)
|
|
104
190
|
true
|
|
105
191
|
end
|
|
106
192
|
|
|
107
|
-
def delete_obsolete_chunks(
|
|
108
|
-
|
|
193
|
+
def delete_obsolete_chunks(chunks)
|
|
194
|
+
retained = chunks.group_by { |chunk| chunk[:source_identity] }
|
|
195
|
+
if retained.keys == [SOURCE]
|
|
196
|
+
identity_scope.where(source: SOURCE).where.not(chunk_index: retained.fetch(SOURCE).map { |chunk| chunk[:chunk_index] }).delete_all
|
|
197
|
+
return
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
identity_scope.where.not(source: retained.keys).delete_all
|
|
201
|
+
retained.each do |source, source_chunks|
|
|
202
|
+
identity_scope.where(source: source).where.not(chunk_index: source_chunks.map { |chunk| chunk[:chunk_index] }).delete_all
|
|
203
|
+
end
|
|
109
204
|
end
|
|
110
205
|
|
|
111
206
|
def identity_scope
|
|
112
|
-
@chunk_model.where(identity
|
|
207
|
+
@chunk_model.where(identity)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def identity_scope_for(chunk)
|
|
211
|
+
@chunk_model.where(identity.merge(source: chunk[:source_identity]))
|
|
113
212
|
end
|
|
114
213
|
|
|
115
214
|
def identity
|
|
@@ -145,30 +244,81 @@ module Maglev
|
|
|
145
244
|
"#{@chunk_model.table_name}.embedding vector(#{database_dimensions})"
|
|
146
245
|
end
|
|
147
246
|
|
|
247
|
+
def source_metadata_columns?
|
|
248
|
+
!@chunk_model.respond_to?(:columns_hash) || @chunk_model.columns_hash.key?("source_identity")
|
|
249
|
+
end
|
|
250
|
+
|
|
148
251
|
def embed(content)
|
|
149
|
-
embedding =
|
|
252
|
+
embedding = @provider_call.call(operation: "embed") { perform_embedding(content) }
|
|
150
253
|
validate_embedding!(embedding)
|
|
151
254
|
embedding
|
|
152
255
|
end
|
|
153
256
|
|
|
257
|
+
def perform_embedding(content)
|
|
258
|
+
if @embedding_count >= Maglev.configuration.snapshot_max_chunks
|
|
259
|
+
raise RetryableProviderError, "snapshot changed while indexing and exhausted the per-execution embedding budget"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
@embedding_count += 1
|
|
263
|
+
@embedding_adapter.embed(content)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def deep_freeze(value)
|
|
267
|
+
case value
|
|
268
|
+
when Hash
|
|
269
|
+
value.to_h { |key, item| [key, deep_freeze(item)] }.freeze
|
|
270
|
+
when Array
|
|
271
|
+
value.map { |item| deep_freeze(item) }.freeze
|
|
272
|
+
else
|
|
273
|
+
value.freeze
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def prepare_index_identity
|
|
278
|
+
configuration = Maglev.configuration
|
|
279
|
+
@identity_configuration = IdentityConfiguration.new(
|
|
280
|
+
embedding_model: configuration.embedding_model,
|
|
281
|
+
embedding_dimensions: @embedding_dimensions,
|
|
282
|
+
embedding_adapter_id: configuration.embedding_adapter_id,
|
|
283
|
+
embedding_adapter_version: configuration.embedding_adapter_version,
|
|
284
|
+
application_index_version: configuration.application_index_version
|
|
285
|
+
)
|
|
286
|
+
identity = @index_identity || IndexIdentity.new(
|
|
287
|
+
configuration: @identity_configuration,
|
|
288
|
+
adapter: @embedding_adapter,
|
|
289
|
+
chunk_size: @chunk_size
|
|
290
|
+
)
|
|
291
|
+
@index_version = identity.to_s
|
|
292
|
+
end
|
|
293
|
+
|
|
154
294
|
def with_owner_lock(&block)
|
|
155
295
|
return yield unless @record.respond_to?(:with_lock)
|
|
156
296
|
|
|
157
297
|
@record.with_lock(&block)
|
|
158
298
|
end
|
|
159
299
|
|
|
160
|
-
def
|
|
300
|
+
def stable_document_id(source_identity, chunk_index)
|
|
301
|
+
VectorStores::DocumentId.build(owner_type: @record.class.name, owner_id: @record.id,
|
|
302
|
+
source_identity: source_identity, chunk_index: chunk_index)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def document_for(chunk, embedding:)
|
|
161
306
|
VectorStores::Document.new(
|
|
307
|
+
id: chunk[:id],
|
|
162
308
|
owner_type: @record.class.name,
|
|
163
309
|
owner_id: @record.id,
|
|
164
310
|
owner_model_name: @record.class.name,
|
|
165
311
|
owner: @record,
|
|
166
|
-
source:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
312
|
+
source: chunk[:source_identity],
|
|
313
|
+
source_identity: chunk[:source_identity],
|
|
314
|
+
source_type: chunk[:source_type],
|
|
315
|
+
tenant_id: Maglev.configuration.tenant_id(record: @record),
|
|
316
|
+
chunk_index: chunk[:chunk_index],
|
|
317
|
+
content: chunk[:content],
|
|
318
|
+
content_checksum: chunk[:checksum],
|
|
319
|
+
embedding_model: @identity_configuration.embedding_model,
|
|
320
|
+
index_version: @index_version,
|
|
321
|
+
embedding: embedding
|
|
172
322
|
)
|
|
173
323
|
end
|
|
174
324
|
end
|
|
@@ -4,9 +4,9 @@ require_relative "errors"
|
|
|
4
4
|
|
|
5
5
|
module Maglev
|
|
6
6
|
class KnowledgeConfig
|
|
7
|
-
Relation = Struct.new(:name, :depth, :limit, :inverse) do
|
|
8
|
-
def initialize(name:, depth:, limit:, inverse: nil)
|
|
9
|
-
super(name.to_s, depth, limit, inverse&.to_s)
|
|
7
|
+
Relation = Struct.new(:name, :depth, :limit, :inverse, :order) do
|
|
8
|
+
def initialize(name:, depth:, limit:, inverse: nil, order: nil)
|
|
9
|
+
super(name.to_s, depth, limit, inverse&.to_s, order&.dup&.freeze)
|
|
10
10
|
freeze
|
|
11
11
|
end
|
|
12
12
|
end
|
|
@@ -97,8 +97,9 @@ module Maglev
|
|
|
97
97
|
@tags.concat(tags)
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
-
def include_related(association, depth:, limit:, inverse: nil)
|
|
101
|
-
|
|
100
|
+
def include_related(association, depth:, limit:, inverse: nil, order: nil)
|
|
101
|
+
normalized_order = normalize_order(order)
|
|
102
|
+
@relations << Relation.new(name: association, depth: depth, limit: limit, inverse: inverse, order: normalized_order)
|
|
102
103
|
end
|
|
103
104
|
|
|
104
105
|
def expose_attached(*names)
|
|
@@ -131,6 +132,27 @@ module Maglev
|
|
|
131
132
|
def normalize(values)
|
|
132
133
|
values.map(&:to_s).uniq
|
|
133
134
|
end
|
|
135
|
+
|
|
136
|
+
VALID_DIRECTIONS = %i[asc desc].freeze
|
|
137
|
+
|
|
138
|
+
def normalize_order(order)
|
|
139
|
+
return nil if order.nil?
|
|
140
|
+
|
|
141
|
+
case order
|
|
142
|
+
when Symbol, String
|
|
143
|
+
{order.to_sym => :asc}
|
|
144
|
+
when Hash
|
|
145
|
+
raise ConfigurationError, "Maglev relation order Hash cannot be empty" if order.empty?
|
|
146
|
+
|
|
147
|
+
order.each do |column, direction|
|
|
148
|
+
raise ConfigurationError, "Maglev relation order direction must be :asc or :desc, got: #{direction.inspect}" unless VALID_DIRECTIONS.include?(direction)
|
|
149
|
+
raise ConfigurationError, "Maglev relation order column must be a Symbol or String, got: #{column.inspect}" unless column.is_a?(Symbol) || column.is_a?(String)
|
|
150
|
+
end
|
|
151
|
+
order.to_h { |col, dir| [col.to_sym, dir.to_sym] }
|
|
152
|
+
else
|
|
153
|
+
raise ConfigurationError, "Maglev relation order must be a Symbol, String, or Hash, got: #{order.class}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
134
156
|
end
|
|
135
157
|
end
|
|
136
158
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/inflections"
|
|
4
|
+
|
|
5
|
+
module Maglev
|
|
6
|
+
class KnowledgeRegistry
|
|
7
|
+
class << self
|
|
8
|
+
def register(model_name)
|
|
9
|
+
mutex.synchronize do
|
|
10
|
+
@model_names ||= []
|
|
11
|
+
@model_names << model_name.to_s unless @model_names.include?(model_name.to_s)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def model_names
|
|
16
|
+
mutex.synchronize { (@model_names || []).dup.freeze }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def rebuild!
|
|
20
|
+
model_names.each do |model_name|
|
|
21
|
+
model = model_name.safe_constantize
|
|
22
|
+
model.rebuild_maglev_registration if model&.respond_to?(:rebuild_maglev_registration)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def mutex
|
|
29
|
+
@mutex ||= Mutex.new
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|