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,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
|
data/lib/maglev/trace.rb
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
31
|
-
|
|
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?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
data/lib/maglev/version.rb
CHANGED
data/lib/maglev-rb.rb
ADDED
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/
|
|
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/
|
|
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"
|