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
@@ -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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module Maglev
6
+ class IndexState < ActiveRecord::Base
7
+ self.table_name = "maglev_index_states"
8
+ end
9
+ end
@@ -2,50 +2,115 @@
2
2
 
3
3
  require "digest"
4
4
 
5
- require_relative "adapters/ruby_llm_embedding"
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
- 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)
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::RubyLLMEmbedding.new
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
- snapshot = @record.maglev_snapshot
32
- chunks = Chunker.new(max_characters: @chunk_size).call(snapshot)
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
- identity_scope.delete_all
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.each_with_index.map do |content, chunk_index|
59
- checksum = Digest::SHA256.hexdigest(content)
60
- existing = identity_scope.find_by(chunk_index: chunk_index, content_checksum: checksum)
61
- embedding = embed(content) unless existing
62
- {content: content, chunk_index: chunk_index, checksum: checksum, embedding: embedding}
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.length)
136
+ delete_obsolete_chunks(chunks)
72
137
  end
73
138
  true
74
139
  end
75
140
 
76
141
  def matching_chunk?(chunk)
77
- identity_scope.find_by(chunk_index: chunk[:chunk_index], content_checksum: chunk[:checksum])
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
- identity_scope.where(chunk_index: chunk[:chunk_index]).delete_all
148
+ identity_scope_for(chunk).where(chunk_index: chunk[:chunk_index]).delete_all
84
149
 
85
- @chunk_model.create!(
150
+ attributes = {
86
151
  **identity,
87
152
  owner: @record,
88
- source: 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: Maglev.configuration.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.each_with_index.map { |content, chunk_index| document_for(content, chunk_index) }
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.delete_by_owner(owner_type: @record.class.name, owner_id: @record.id)
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(chunk_count)
108
- identity_scope.where.not(chunk_index: (0...chunk_count).to_a).delete_all
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.merge(source: SOURCE))
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 = ProviderCall.new.call(operation: "embed") { @embedding_adapter.embed(content) }
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 document_for(content, chunk_index)
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: SOURCE,
167
- chunk_index: chunk_index,
168
- content: content,
169
- content_checksum: Digest::SHA256.hexdigest(content),
170
- embedding_model: Maglev.configuration.embedding_model,
171
- embedding: embed(content)
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
- @relations << Relation.new(name: association, depth: depth, limit: limit, inverse: inverse)
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