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,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maglev
4
+ class StructuredEvidence
5
+ State = Struct.new(:loaded, :records, :count, :truncated)
6
+
7
+ attr_reader :scalar, :filters, :date_ranges
8
+
9
+ def initialize(records: [], scalar: nil, filters: [], date_ranges: [], count: 0, truncated: false, loader: nil)
10
+ materialized = deep_freeze(records)
11
+ @state = State.new(loader.nil?, materialized, count, truncated)
12
+ @loader = loader
13
+ @materialization_lock = Mutex.new
14
+ @scalar = deep_freeze(scalar)
15
+ @filters = filters.map { |filter| deep_freeze(filter) }.freeze
16
+ @date_ranges = date_ranges.map { |range| deep_freeze(range) }.freeze
17
+ freeze
18
+ end
19
+
20
+ def records
21
+ materialize.records
22
+ end
23
+
24
+ def count
25
+ materialize.count
26
+ end
27
+
28
+ def truncated
29
+ materialize.truncated
30
+ end
31
+
32
+ def truncated? = truncated
33
+
34
+ private
35
+
36
+ def materialize
37
+ return @state if @state.loaded
38
+
39
+ @materialization_lock.synchronize do
40
+ return @state if @state.loaded
41
+
42
+ records, count, truncated = @loader.call
43
+ @state.records = deep_freeze(records)
44
+ @state.count = count
45
+ @state.truncated = truncated
46
+ @state.loaded = true
47
+ end
48
+ @state
49
+ end
50
+
51
+ def deep_freeze(value)
52
+ case value
53
+ when Hash then value.to_h { |key, item| [deep_freeze(key), deep_freeze(item)] }.freeze
54
+ when Array then value.map { |item| deep_freeze(item) }.freeze
55
+ else value.frozen? ? value : value.freeze
56
+ end
57
+ end
58
+ end
59
+
60
+ class StructuredResult
61
+ STATUSES = %i[succeeded clarification_required unsupported failed].freeze
62
+ KINDS = %i[relation scalar aggregate none].freeze
63
+
64
+ attr_reader :status, :route, :kind, :value, :evidence, :interpretation, :warnings, :plan, :trace_id
65
+
66
+ def initialize(status:, kind:, plan:, trace_id:, value: nil, evidence: StructuredEvidence.new,
67
+ interpretation: nil, warnings: [])
68
+ raise ArgumentError, "invalid structured result status" unless STATUSES.include?(status)
69
+ raise ArgumentError, "invalid structured result kind" unless KINDS.include?(kind)
70
+ raise ArgumentError, "only successful results may carry a value" if status != :succeeded && !value.nil?
71
+
72
+ @status = status
73
+ @route = :structured
74
+ @kind = kind
75
+ @value = value
76
+ @evidence = evidence
77
+ @interpretation = interpretation&.to_s&.freeze
78
+ @warnings = Array(warnings).map { |warning| warning.to_s.freeze }.freeze
79
+ @plan = plan
80
+ @trace_id = trace_id.to_s.freeze
81
+ freeze
82
+ end
83
+
84
+ def render
85
+ return warnings.first || "The request is unsupported." if status == :unsupported
86
+ return plan.clarification.fetch(:message) if status == :clarification_required
87
+ return "The structured request failed." if status == :failed
88
+ return "Count: #{value}" if kind == :aggregate && plan.ir.aggregate.function == :count
89
+ return "#{plan.ir.aggregate.function.to_s.capitalize}: #{value}" if kind == :aggregate
90
+
91
+ return "No matching records." if evidence.records.empty?
92
+
93
+ headings = evidence.records.first.keys
94
+ ([headings.join(" | ")] + evidence.records.map { |record| headings.map { |heading| record[heading] }.join(" | ") }).join("\n")
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Maglev
6
+ module Trace
7
+ module_function
8
+
9
+ def instrument(step, trace_id:, resource: nil, operation: nil)
10
+ payload = compact_payload(trace_id: trace_id, route: :structured, resource: resource,
11
+ operation: operation, status: :succeeded, timestamp: Time.now.utc.iso8601)
12
+ error = nil
13
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
+ value = ActiveSupport::Notifications.instrument("maglev.structured.#{step}", payload) do
15
+ yield(payload)
16
+ rescue => caught
17
+ error = caught
18
+ payload[:status] = :failed
19
+ payload[:error_code] = safe_error_code(caught)
20
+ nil
21
+ end
22
+ payload[:duration_bucket] = duration_bucket(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
23
+ emit_audit(step, payload.freeze)
24
+ raise error if error
25
+
26
+ value
27
+ end
28
+
29
+ def emit_audit(step, payload)
30
+ sink = Maglev.configuration.audit_sink
31
+ sink&.call(payload.merge(event: "maglev.structured.#{step}").freeze)
32
+ rescue => error
33
+ Maglev.configuration.logger&.warn("Maglev audit sink failed: #{error.class.name}")
34
+ end
35
+
36
+ def compact_payload(**values)
37
+ values.compact
38
+ end
39
+
40
+ def safe_error_code(error)
41
+ case error.class.name
42
+ when "Maglev::QueryCompilationError" then :compilation_failed
43
+ when "Maglev::StructuredExecutionError" then :execution_failed
44
+ else :internal_error
45
+ end
46
+ end
47
+
48
+ def duration_bucket(seconds)
49
+ return :under_10ms if seconds < 0.01
50
+ return :under_100ms if seconds < 0.1
51
+ return :under_1s if seconds < 1
52
+
53
+ :one_second_or_more
54
+ end
55
+ end
56
+ end
@@ -3,10 +3,22 @@
3
3
  module Maglev
4
4
  module VectorStores
5
5
  class Base
6
+ CONTRACT_VERSION = 2
7
+
8
+ def contract_version = CONTRACT_VERSION
9
+
10
+ def fetch(ids:)
11
+ raise NotImplementedError, "#{self.class.name} must implement #fetch"
12
+ end
13
+
6
14
  def upsert(documents:)
7
15
  raise NotImplementedError, "#{self.class.name} must implement #upsert"
8
16
  end
9
17
 
18
+ def replace_owner(owner_type:, owner_id:, documents:)
19
+ raise NotImplementedError, "#{self.class.name} must implement #replace_owner"
20
+ end
21
+
10
22
  def search(vector:, filters:, limit:)
11
23
  raise NotImplementedError, "#{self.class.name} must implement #search"
12
24
  end
@@ -3,25 +3,30 @@
3
3
  module Maglev
4
4
  module VectorStores
5
5
  class Document
6
- attr_reader :id, :owner_type, :owner_id, :owner_model_name, :source,
6
+ attr_reader :id, :owner_type, :owner_id, :owner_model_name, :source, :source_identity, :source_type, :tenant_id,
7
7
  :chunk_index, :content, :content_checksum, :embedding_model,
8
- :embedding, :owner, :distance
8
+ :index_version, :embedding, :owner, :distance, :score
9
9
 
10
10
  def initialize(owner_type:, owner_id:, owner_model_name:, source:, chunk_index:,
11
- content:, content_checksum:, embedding_model:, embedding:, id: nil,
12
- owner: nil, distance: nil)
11
+ content:, content_checksum:, embedding_model:, index_version:, embedding:, id: nil,
12
+ owner: nil, distance: nil, score: nil, source_identity: nil, source_type: nil, tenant_id: nil)
13
13
  @owner_type = owner_type
14
14
  @owner_id = owner_id
15
15
  @owner_model_name = owner_model_name
16
16
  @source = source
17
+ @source_identity = source_identity || source
18
+ @source_type = (source_type || :snapshot).to_sym
19
+ @tenant_id = tenant_id
17
20
  @chunk_index = chunk_index
18
21
  @content = content
19
22
  @content_checksum = content_checksum
20
23
  @embedding_model = embedding_model
24
+ @index_version = index_version
21
25
  @embedding = embedding
22
26
  @id = id || "#{owner_type}:#{owner_id}:#{source}:#{chunk_index}"
23
27
  @owner = owner
24
28
  @distance = distance
29
+ @score = (score.nil? && !distance.nil?) ? (1.0 - distance.to_f).clamp(0.0, 1.0) : score
25
30
  freeze
26
31
  end
27
32
 
@@ -31,7 +36,11 @@ module Maglev
31
36
  owner_id: owner_id,
32
37
  owner_model_name: owner_model_name,
33
38
  source: source,
34
- chunk_index: chunk_index
39
+ source_identity: source_identity,
40
+ source_type: source_type,
41
+ tenant_id: tenant_id,
42
+ chunk_index: chunk_index,
43
+ index_version: index_version
35
44
  }
36
45
  end
37
46
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Maglev
6
+ module VectorStores
7
+ module DocumentId
8
+ module_function
9
+
10
+ def build(owner_type:, owner_id:, source_identity:, chunk_index:)
11
+ source = (source_identity == "snapshot") ? "snapshot" : "b64-#{Base64.urlsafe_encode64(source_identity, padding: false)}"
12
+ "#{owner_type}:#{owner_id}:#{source}:#{chunk_index}"
13
+ end
14
+
15
+ def parse(id)
16
+ parts = id.split(":")
17
+ chunk_index = parts.pop
18
+ source = parts.pop
19
+ owner_id = parts.pop
20
+ source_identity = source.start_with?("b64-") ? Base64.urlsafe_decode64(source.delete_prefix("b64-")) : source
21
+ [parts.join(":"), owner_id, source_identity, chunk_index]
22
+ rescue ArgumentError
23
+ raise ArgumentError, "invalid Maglev document id"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -2,20 +2,58 @@
2
2
 
3
3
  require_relative "base"
4
4
  require_relative "document"
5
+ require_relative "metadata_filter"
5
6
 
6
7
  module Maglev
7
8
  module VectorStores
8
9
  class Memory < Base
10
+ Entry = Struct.new(:owner_type, :owner_id, :document)
11
+
9
12
  def initialize
10
13
  @documents = {}
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def fetch(ids:)
18
+ requested_ids = ids.to_a
19
+ documents = @mutex.synchronize { @documents }
20
+ requested_ids.filter_map { |id| documents[id]&.document }
11
21
  end
12
22
 
13
23
  def upsert(documents:)
14
- documents.each { |document| @documents[document.id] = document }
24
+ staged = stage(documents)
25
+ @mutex.synchronize do
26
+ replacement = @documents.dup
27
+ staged.each { |id, entry| replacement[id] = entry }
28
+ @documents = replacement
29
+ end
30
+ end
31
+
32
+ def replace_owner(owner_type:, owner_id:, documents:)
33
+ staged = stage(documents)
34
+ unless staged.all? { |_id, entry| entry.owner_type == owner_type && entry.owner_id == owner_id }
35
+ raise ArgumentError, "replacement documents must match the requested owner"
36
+ end
37
+
38
+ @mutex.synchronize do
39
+ conflict = staged.any? do |id, _entry|
40
+ existing = @documents[id]
41
+ existing && (existing.owner_type != owner_type || existing.owner_id != owner_id)
42
+ end
43
+ raise ArgumentError, "replacement document id belongs to another owner" if conflict
44
+
45
+ replacement = @documents.reject do |_id, entry|
46
+ entry.owner_type == owner_type && entry.owner_id == owner_id
47
+ end
48
+ staged.each { |id, entry| replacement[id] = entry }
49
+ @documents = replacement
50
+ end
15
51
  end
16
52
 
17
53
  def search(vector:, filters:, limit:)
18
- @documents.values
54
+ filters = MetadataFilter.coerce(filters)
55
+ documents = @mutex.synchronize { @documents.values.map(&:document) }
56
+ documents
19
57
  .select { |document| matches_filters?(document, filters) }
20
58
  .map { |document| with_distance(document, cosine_distance(vector, document.embedding)) }
21
59
  .sort_by(&:distance)
@@ -23,12 +61,19 @@ module Maglev
23
61
  end
24
62
 
25
63
  def delete(ids:)
26
- ids.each { |id| @documents.delete(id) }
64
+ requested_ids = ids.to_a
65
+ @mutex.synchronize do
66
+ replacement = @documents.dup
67
+ requested_ids.each { |id| replacement.delete(id) }
68
+ @documents = replacement
69
+ end
27
70
  end
28
71
 
29
72
  def delete_by_owner(owner_type:, owner_id:)
30
- @documents.delete_if do |_id, document|
31
- document.owner_type == owner_type && document.owner_id == owner_id
73
+ @mutex.synchronize do
74
+ @documents = @documents.reject do |_id, document|
75
+ document.owner_type == owner_type && document.owner_id == owner_id
76
+ end
32
77
  end
33
78
  end
34
79
 
@@ -42,8 +87,21 @@ module Maglev
42
87
 
43
88
  private
44
89
 
90
+ def stage(documents)
91
+ documents.map do |document|
92
+ entry = Entry.new(document.owner_type, document.owner_id, document).freeze
93
+ [document.id, entry]
94
+ end
95
+ end
96
+
45
97
  def matches_filters?(document, filters)
46
- filters.all? { |key, value| document.metadata.fetch(key) == value }
98
+ filters.all? do |key, value|
99
+ case key
100
+ when :owner_ids then value.include?(document.owner_id)
101
+ when :source_types then value.map(&:to_sym).include?(document.source_type)
102
+ else document.metadata.fetch(key) == value
103
+ end
104
+ end
47
105
  end
48
106
 
49
107
  def cosine_distance(left, right)
@@ -62,10 +120,14 @@ module Maglev
62
120
  owner_id: document.owner_id,
63
121
  owner_model_name: document.owner_model_name,
64
122
  source: document.source,
123
+ source_identity: document.source_identity,
124
+ source_type: document.source_type,
125
+ tenant_id: document.tenant_id,
65
126
  chunk_index: document.chunk_index,
66
127
  content: document.content,
67
128
  content_checksum: document.content_checksum,
68
129
  embedding_model: document.embedding_model,
130
+ index_version: document.index_version,
69
131
  embedding: document.embedding,
70
132
  owner: document.owner,
71
133
  distance: distance
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maglev
4
+ module VectorStores
5
+ class MetadataFilter
6
+ include Enumerable
7
+
8
+ FIELDS = %i[owner_type owner_id owner_ids owner_model_name tenant_id source_type source_types index_version].freeze
9
+ SOURCE_TYPES = %i[snapshot attribute related_record rich_text attachment tag].freeze
10
+ MAX_VALUES = 1_000
11
+
12
+ def self.coerce(value)
13
+ value.is_a?(self) ? value : new(**value.to_h)
14
+ end
15
+
16
+ def initialize(**values)
17
+ unknown = values.keys.map(&:to_sym) - FIELDS
18
+ raise ArgumentError, "Unsupported metadata filter: #{unknown.join(", ")}" if unknown.any?
19
+
20
+ normalized = values.transform_keys(&:to_sym)
21
+ validate!(normalized)
22
+ @values = normalized.transform_values { |value| value.is_a?(Array) ? value.dup.freeze : value }.freeze
23
+ freeze
24
+ end
25
+
26
+ def each(&block) = @values.each(&block)
27
+ def to_h = @values
28
+ def fetch(...) = @values.fetch(...)
29
+ def key?(key) = @values.key?(key)
30
+
31
+ private
32
+
33
+ def validate!(values)
34
+ %i[owner_ids source_types].each do |field|
35
+ next unless values.key?(field)
36
+ value = values[field]
37
+ raise ArgumentError, "#{field} must be a non-empty Array" unless value.is_a?(Array) && value.any?
38
+ raise ArgumentError, "#{field} exceeds #{MAX_VALUES} values" if value.size > MAX_VALUES
39
+ end
40
+ Array(values[:source_type]).each { |value| validate_source_type!(value) } if values.key?(:source_type)
41
+ Array(values[:source_types]).each { |value| validate_source_type!(value) } if values.key?(:source_types)
42
+ %i[owner_type owner_model_name tenant_id index_version].each do |field|
43
+ next unless values.key?(field)
44
+ raise ArgumentError, "#{field} must be a non-empty String" unless values[field].is_a?(String) && !values[field].empty?
45
+ end
46
+ end
47
+
48
+ def validate_source_type!(value)
49
+ return if SOURCE_TYPES.include?(value.to_sym)
50
+
51
+ raise ArgumentError, "Unsupported source type: #{value.inspect}"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  require_relative "../chunk"
4
6
  require_relative "base"
5
7
  require_relative "document"
8
+ require_relative "metadata_filter"
9
+ require_relative "document_id"
6
10
 
7
11
  module Maglev
8
12
  module VectorStores
@@ -19,7 +23,7 @@ module Maglev
19
23
  next if existing
20
24
 
21
25
  scope.where(chunk_index: document.chunk_index).delete_all
22
- @chunk_model.create!(
26
+ attributes = {
23
27
  owner_type: document.owner_type,
24
28
  owner_id: document.owner_id,
25
29
  owner_model_name: document.owner_model_name,
@@ -29,15 +33,44 @@ module Maglev
29
33
  content: document.content,
30
34
  content_checksum: document.content_checksum,
31
35
  embedding_model: document.embedding_model,
36
+ index_version: document.index_version,
32
37
  embedding: document.embedding
33
- )
38
+ }
39
+ if source_metadata_columns?
40
+ attributes[:source_identity] = document.source_identity
41
+ attributes[:source_type] = document.source_type
42
+ attributes[:tenant_id] = document.tenant_id
43
+ end
44
+ @chunk_model.create!(attributes)
34
45
  end
35
46
  end
36
47
  end
37
48
 
49
+ def fetch(ids:)
50
+ ids.to_a.filter_map do |id|
51
+ owner_type, owner_id, source, chunk_index = parse_id(id)
52
+ row = @chunk_model.find_by(owner_type: owner_type, owner_id: owner_id, source: source, chunk_index: chunk_index)
53
+ document_for(row) if row
54
+ end
55
+ end
56
+
57
+ def replace_owner(owner_type:, owner_id:, documents:)
58
+ staged = stage(documents)
59
+ unless staged.all? { |attributes| attributes[:owner_type] == owner_type && attributes[:owner_id] == owner_id }
60
+ raise ArgumentError, "replacement documents must match the requested owner"
61
+ end
62
+
63
+ @chunk_model.transaction do
64
+ lock_owner(owner_type, owner_id)
65
+ @chunk_model.where(owner_type: owner_type, owner_id: owner_id).delete_all
66
+ staged.each { |attributes| @chunk_model.create!(attributes) }
67
+ end
68
+ end
69
+
38
70
  def search(vector:, filters:, limit:)
39
- scope = filters.reduce(@chunk_model.all) do |current_scope, (key, value)|
40
- current_scope.where(key => value)
71
+ scope = MetadataFilter.coerce(filters).reduce(@chunk_model.all) do |current_scope, (key, value)|
72
+ column = {owner_ids: :owner_id, source_types: :source_type}.fetch(key, key)
73
+ current_scope.where(column => value)
41
74
  end
42
75
  scope.nearest_neighbors(:embedding, vector, distance: "cosine")
43
76
  .first(limit)
@@ -46,13 +79,16 @@ module Maglev
46
79
 
47
80
  def delete(ids:)
48
81
  ids.each do |id|
49
- owner_type, owner_id, source, chunk_index = id.split(":", 4)
82
+ owner_type, owner_id, source, chunk_index = parse_id(id)
50
83
  @chunk_model.where(owner_type: owner_type, owner_id: owner_id, source: source, chunk_index: chunk_index).delete_all
51
84
  end
52
85
  end
53
86
 
54
87
  def delete_by_owner(owner_type:, owner_id:)
55
- @chunk_model.where(owner_type: owner_type, owner_id: owner_id).delete_all
88
+ @chunk_model.transaction do
89
+ lock_owner(owner_type, owner_id)
90
+ @chunk_model.where(owner_type: owner_type, owner_id: owner_id).delete_all
91
+ end
56
92
  end
57
93
 
58
94
  def healthcheck
@@ -65,6 +101,44 @@ module Maglev
65
101
 
66
102
  private
67
103
 
104
+ def parse_id(id)
105
+ DocumentId.parse(id)
106
+ end
107
+
108
+ def stage(documents)
109
+ documents.map do |document|
110
+ attributes = {
111
+ owner_type: document.owner_type,
112
+ owner_id: document.owner_id,
113
+ owner_model_name: document.owner_model_name,
114
+ owner: document.owner,
115
+ source: document.source,
116
+ chunk_index: document.chunk_index,
117
+ content: document.content,
118
+ content_checksum: document.content_checksum,
119
+ embedding_model: document.embedding_model,
120
+ index_version: document.index_version,
121
+ embedding: document.embedding
122
+ }
123
+ if source_metadata_columns?
124
+ attributes[:source_identity] = document.source_identity
125
+ attributes[:source_type] = document.source_type
126
+ attributes[:tenant_id] = document.tenant_id
127
+ end
128
+ attributes
129
+ end
130
+ end
131
+
132
+ def source_metadata_columns?
133
+ !@chunk_model.respond_to?(:columns_hash) || @chunk_model.columns_hash.key?("source_identity")
134
+ end
135
+
136
+ def lock_owner(owner_type, owner_id)
137
+ key = Digest::SHA256.digest("#{owner_type}\0#{owner_id}").unpack1("q>")
138
+ quoted_key = @chunk_model.connection.quote(key)
139
+ @chunk_model.connection.execute("SELECT pg_advisory_xact_lock(#{quoted_key})")
140
+ end
141
+
68
142
  def identity_scope(document)
69
143
  @chunk_model.where(
70
144
  owner_type: document.owner_type,
@@ -76,19 +150,32 @@ module Maglev
76
150
 
77
151
  def document_for(row)
78
152
  Document.new(
153
+ id: DocumentId.build(owner_type: row.owner_type, owner_id: row.owner_id,
154
+ source_identity: ((row.respond_to?(:source_identity) && row.source_identity) ? row.source_identity : row.source),
155
+ chunk_index: row.chunk_index),
79
156
  owner_type: row.owner_type,
80
157
  owner_id: row.owner_id,
81
158
  owner_model_name: row.owner_model_name,
82
159
  source: row.source,
160
+ source_identity: row.respond_to?(:source_identity) ? row.source_identity : row.source,
161
+ source_type: row.respond_to?(:source_type) ? row.source_type : :snapshot,
162
+ tenant_id: row.respond_to?(:tenant_id) ? row.tenant_id : nil,
83
163
  chunk_index: row.chunk_index,
84
164
  content: row.content,
85
165
  content_checksum: row.content_checksum,
86
166
  embedding_model: row.embedding_model,
167
+ index_version: row.index_version,
87
168
  embedding: row.embedding,
88
169
  owner: row.owner,
89
- distance: row.respond_to?(:neighbor_distance) ? row.neighbor_distance : row.distance
170
+ distance: distance_for(row)
90
171
  )
91
172
  end
173
+
174
+ def distance_for(row)
175
+ return row.neighbor_distance if row.respond_to?(:neighbor_distance)
176
+
177
+ row.distance if row.respond_to?(:distance)
178
+ end
92
179
  end
93
180
  end
94
181
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maglev
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/maglev-rb.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "maglev"
data/lib/maglev.rb CHANGED
@@ -7,19 +7,50 @@ require_relative "maglev/errors"
7
7
  require_relative "maglev/authorization"
8
8
  require_relative "maglev/provider_call"
9
9
  require_relative "maglev/knowledge_config"
10
+ require_relative "maglev/knowledge_registry"
11
+ require_relative "maglev/resource_config"
12
+ require_relative "maglev/schema_snapshot"
13
+ require_relative "maglev/registry"
14
+ require_relative "maglev/query_ir"
15
+ require_relative "maglev/query_validator"
16
+ require_relative "maglev/planner_adapter"
17
+ require_relative "maglev/planner"
18
+ require_relative "maglev/planner_evaluation"
19
+ require_relative "maglev/adapters/faraday_planner"
20
+ require_relative "maglev/request"
21
+ require_relative "maglev/routing_adapter"
22
+ require_relative "maglev/router"
23
+ require_relative "maglev/result"
24
+ require_relative "maglev/hybrid_candidate_set"
25
+ require_relative "maglev/hybrid_result"
26
+ require_relative "maglev/hybrid_coordinator"
27
+ require_relative "maglev/request_executor"
28
+ require_relative "maglev/query_compiler"
29
+ require_relative "maglev/structured_executor"
30
+ require_relative "maglev/structured_result"
31
+ require_relative "maglev/trace"
32
+ require_relative "maglev/structured_evidence_builder"
33
+ require_relative "maglev/structured_answer_composer"
10
34
  require_relative "maglev/snapshot"
35
+ require_relative "maglev/snapshot_budget"
11
36
  require_relative "maglev/snapshot_builder"
37
+ require_relative "maglev/source_fragment"
38
+ require_relative "maglev/source_extractor"
12
39
  require_relative "maglev/chunker"
13
40
  require_relative "maglev/embedding_adapter"
14
- require_relative "maglev/adapters/ruby_llm_embedding"
41
+ require_relative "maglev/index_identity"
42
+ require_relative "maglev/adapters/faraday_client"
43
+ require_relative "maglev/adapters/faraday_embedding"
15
44
  require_relative "maglev/generation_adapter"
16
- require_relative "maglev/adapters/ruby_llm_generation"
45
+ require_relative "maglev/adapters/faraday_generation"
17
46
  require_relative "maglev/extracted_document"
18
47
  require_relative "maglev/attachment_extractor"
19
- require_relative "maglev/adapters/ruby_llm_attachment_extractor"
20
48
  require_relative "maglev/search_result"
49
+ require_relative "maglev/retrieval_result"
21
50
  require_relative "maglev/vector_stores/base"
22
51
  require_relative "maglev/vector_stores/document"
52
+ require_relative "maglev/vector_stores/metadata_filter"
53
+ require_relative "maglev/vector_stores/document_id"
23
54
  require_relative "maglev/vector_stores/memory"
24
55
  require_relative "maglev/vector_stores/pgvector"
25
56
  require_relative "maglev/response"
@@ -27,6 +58,8 @@ require_relative "maglev/prompt_builder"
27
58
  require_relative "maglev/context_assembler"
28
59
  require_relative "maglev/context_preview"
29
60
  require_relative "maglev/indexer"
61
+ require_relative "maglev/index_diagnostics"
62
+ require_relative "maglev/index_state"
30
63
  require_relative "maglev/reindex_job"
31
64
  require_relative "maglev/retriever"
32
65
  require_relative "maglev/schema_compiler"