maglev-rb 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +408 -0
- data/README.zh-CN.md +408 -0
- data/lib/generators/maglev/install/install_generator.rb +70 -0
- data/lib/maglev/active_record_extension.rb +100 -0
- data/lib/maglev/adapters/ruby_llm_attachment_extractor.rb +15 -0
- data/lib/maglev/adapters/ruby_llm_embedding.rb +22 -0
- data/lib/maglev/adapters/ruby_llm_generation.rb +22 -0
- data/lib/maglev/adapters/ruby_llm_provider.rb +64 -0
- data/lib/maglev/answerer.rb +58 -0
- data/lib/maglev/attachment_extractor.rb +106 -0
- data/lib/maglev/authorization.rb +43 -0
- data/lib/maglev/chunk.rb +14 -0
- data/lib/maglev/chunker.rb +58 -0
- data/lib/maglev/configuration.rb +76 -0
- data/lib/maglev/content_source_graph.rb +66 -0
- data/lib/maglev/context_assembler.rb +94 -0
- data/lib/maglev/context_preview.rb +13 -0
- data/lib/maglev/dependency_graph.rb +129 -0
- data/lib/maglev/embedding_adapter.rb +9 -0
- data/lib/maglev/errors.rb +21 -0
- data/lib/maglev/extracted_document.rb +36 -0
- data/lib/maglev/generation_adapter.rb +9 -0
- data/lib/maglev/indexer.rb +175 -0
- data/lib/maglev/knowledge_config.rb +136 -0
- data/lib/maglev/prompt_builder.rb +27 -0
- data/lib/maglev/provider_call.rb +49 -0
- data/lib/maglev/provider_configuration.rb +18 -0
- data/lib/maglev/railtie.rb +19 -0
- data/lib/maglev/reindex_job.rb +16 -0
- data/lib/maglev/response.rb +26 -0
- data/lib/maglev/retriever.rb +89 -0
- data/lib/maglev/schema_compiler.rb +51 -0
- data/lib/maglev/search_result.rb +22 -0
- data/lib/maglev/snapshot.rb +14 -0
- data/lib/maglev/snapshot_builder.rb +204 -0
- data/lib/maglev/vector_stores/base.rb +31 -0
- data/lib/maglev/vector_stores/document.rb +39 -0
- data/lib/maglev/vector_stores/memory.rb +76 -0
- data/lib/maglev/vector_stores/pgvector.rb +94 -0
- data/lib/maglev/version.rb +5 -0
- data/lib/maglev.rb +37 -0
- data/lib/tasks/maglev.rake +31 -0
- metadata +202 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "adapters/ruby_llm_embedding"
|
|
4
|
+
require_relative "authorization"
|
|
5
|
+
require_relative "chunk"
|
|
6
|
+
require_relative "provider_call"
|
|
7
|
+
require_relative "search_result"
|
|
8
|
+
require_relative "vector_stores/pgvector"
|
|
9
|
+
|
|
10
|
+
module Maglev
|
|
11
|
+
class Retriever
|
|
12
|
+
def initialize(model_class, chunk_model: Chunk, embedding_adapter: Maglev.configuration.embedding_adapter, authorization: Authorization.new, vector_store: Maglev.configuration.vector_store)
|
|
13
|
+
@model_class = model_class
|
|
14
|
+
@chunk_model = chunk_model
|
|
15
|
+
@embedding_adapter = embedding_adapter || Adapters::RubyLLMEmbedding.new
|
|
16
|
+
@authorization = authorization
|
|
17
|
+
@vector_store = vector_store
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def search(query, limit:, owner: nil, user: nil)
|
|
21
|
+
embedding = ProviderCall.new.call(operation: "embed") { @embedding_adapter.embed(query) }
|
|
22
|
+
return search_vector_store(embedding, limit: limit, owner: owner, user: user) if @vector_store
|
|
23
|
+
|
|
24
|
+
scope = @chunk_model.where(owner_model_name: @model_class.name)
|
|
25
|
+
scope = scope.where(owner: owner) if owner
|
|
26
|
+
scope = apply_authorization_scope(scope, user) unless owner
|
|
27
|
+
rows = scope
|
|
28
|
+
.nearest_neighbors(:embedding, embedding, distance: "cosine")
|
|
29
|
+
|
|
30
|
+
unique_owner_results(rows).first(limit)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def search_vector_store(embedding, limit:, owner:, user:)
|
|
36
|
+
documents = @vector_store.search(vector: embedding, filters: filters_for(owner), limit: limit)
|
|
37
|
+
unique_owner_results(documents).first(limit)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def filters_for(owner)
|
|
41
|
+
filters = {owner_model_name: @model_class.name}
|
|
42
|
+
if owner
|
|
43
|
+
filters[:owner_type] = owner.class.name
|
|
44
|
+
filters[:owner_id] = owner.id
|
|
45
|
+
end
|
|
46
|
+
filters
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def apply_authorization_scope(scope, user)
|
|
50
|
+
return scope unless @authorization.configured? && user
|
|
51
|
+
|
|
52
|
+
authorized_scope = @authorization.scope(model: @model_class, user: user)
|
|
53
|
+
if authorized_scope.respond_to?(:select)
|
|
54
|
+
scope.where(owner_id: authorized_scope.select(:id))
|
|
55
|
+
else
|
|
56
|
+
scope
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def unique_owner_results(rows)
|
|
61
|
+
seen_owners = {}
|
|
62
|
+
results = []
|
|
63
|
+
|
|
64
|
+
rows.each do |row|
|
|
65
|
+
owner = owner_for(row)
|
|
66
|
+
next if seen_owners[owner]
|
|
67
|
+
|
|
68
|
+
seen_owners[owner] = true
|
|
69
|
+
results << SearchResult.new(
|
|
70
|
+
owner: owner,
|
|
71
|
+
content: row.content,
|
|
72
|
+
source: row.source,
|
|
73
|
+
distance: row.respond_to?(:neighbor_distance) ? row.neighbor_distance : row.distance,
|
|
74
|
+
chunk_index: row.respond_to?(:chunk_index) ? row.chunk_index : nil
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
results
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def owner_for(row)
|
|
82
|
+
if row.owner
|
|
83
|
+
row.owner
|
|
84
|
+
elsif row.respond_to?(:owner_type) && row.respond_to?(:owner_id)
|
|
85
|
+
row.owner_type.constantize.find(row.owner_id)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "configuration"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module Maglev
|
|
7
|
+
class SchemaCompiler
|
|
8
|
+
SUPPORTED_MACROS = %i[belongs_to has_one has_many].freeze
|
|
9
|
+
|
|
10
|
+
CompiledSchema = Struct.new(:model_class, :relations)
|
|
11
|
+
CompiledRelation = Struct.new(:name, :depth, :limit, :inverse, :macro, :related_class)
|
|
12
|
+
|
|
13
|
+
def initialize(config, max_depth: Maglev.configuration.max_relation_depth)
|
|
14
|
+
@config = config
|
|
15
|
+
@max_depth = max_depth
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def compile
|
|
19
|
+
CompiledSchema.new(@config.model_class, @config.relations.map { |relation| compile_relation(relation) }.freeze)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def compile_relation(relation)
|
|
25
|
+
reflection = @config.model_class.reflect_on_association(relation.name.to_sym)
|
|
26
|
+
unless reflection
|
|
27
|
+
raise ConfigurationError, "Unknown Maglev association #{@config.model_class.name}.#{relation.name}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
unless SUPPORTED_MACROS.include?(reflection.macro)
|
|
31
|
+
raise ConfigurationError, "Unsupported Maglev association #{@config.model_class.name}.#{relation.name}: #{reflection.macro}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if relation.depth > @max_depth
|
|
35
|
+
raise ConfigurationError, "Maglev association #{@config.model_class.name}.#{relation.name} exceeds maximum depth #{@max_depth}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
related_class = reflection.klass
|
|
39
|
+
unless related_class.respond_to?(:maglev_config) && related_class.maglev_config
|
|
40
|
+
raise ConfigurationError, "Related Maglev model #{related_class.name} must declare has_knowledge"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
inverse = relation.inverse || reflection.inverse_of&.name&.to_s
|
|
44
|
+
unless inverse && related_class.reflect_on_association(inverse.to_sym)
|
|
45
|
+
raise ConfigurationError, "Maglev association #{@config.model_class.name}.#{relation.name} requires an inverse for invalidation"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
CompiledRelation.new(relation.name, relation.depth, relation.limit, inverse, reflection.macro, related_class)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maglev
|
|
4
|
+
class SearchResult
|
|
5
|
+
attr_reader :owner, :content, :source, :distance, :chunk_index
|
|
6
|
+
|
|
7
|
+
def initialize(owner:, content:, source:, distance:, chunk_index: nil)
|
|
8
|
+
@owner = owner
|
|
9
|
+
@content = content
|
|
10
|
+
@source = source
|
|
11
|
+
@distance = distance
|
|
12
|
+
@chunk_index = chunk_index
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def similarity
|
|
17
|
+
return nil if distance.nil?
|
|
18
|
+
|
|
19
|
+
1.0 - distance.to_f
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
require_relative "attachment_extractor"
|
|
6
|
+
require_relative "snapshot"
|
|
7
|
+
|
|
8
|
+
module Maglev
|
|
9
|
+
class SnapshotBuilder
|
|
10
|
+
def initialize(record, config, path: nil, visited: nil, attachment_extractor: nil, remaining_depth: Maglev.configuration.max_relation_depth)
|
|
11
|
+
@record = record
|
|
12
|
+
@config = config
|
|
13
|
+
@path = path
|
|
14
|
+
@visited = visited || {}
|
|
15
|
+
@attachment_extractor = attachment_extractor || Maglev.configuration.attachment_extractor || AttachmentExtractor.new
|
|
16
|
+
@remaining_depth = remaining_depth
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def build
|
|
20
|
+
return Snapshot.new([]) if visited?
|
|
21
|
+
|
|
22
|
+
mark_visited
|
|
23
|
+
Snapshot.new(lines)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def lines
|
|
29
|
+
[
|
|
30
|
+
header,
|
|
31
|
+
*attribute_lines,
|
|
32
|
+
*tag_lines,
|
|
33
|
+
*attachment_lines,
|
|
34
|
+
*rich_text_lines,
|
|
35
|
+
*relation_lines
|
|
36
|
+
]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def header
|
|
40
|
+
label = "#{@record.class.name}##{record_id}"
|
|
41
|
+
@path ? "#{@path} #{label}" : label
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def record_id
|
|
45
|
+
id = @record.respond_to?(:id) ? @record.id : nil
|
|
46
|
+
id.nil? ? "new_record" : id
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def attribute_lines
|
|
50
|
+
@config.exposed_attributes.filter_map do |attribute|
|
|
51
|
+
value = @record.public_send(attribute)
|
|
52
|
+
"#{attribute_path(attribute)}: #{value}" unless value.nil?
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def tag_lines
|
|
57
|
+
return [] if @config.tags.empty?
|
|
58
|
+
|
|
59
|
+
["tags: #{@config.tags.join(", ")}"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def relation_lines
|
|
63
|
+
return [] unless @remaining_depth.positive?
|
|
64
|
+
|
|
65
|
+
@config.relations.flat_map do |relation|
|
|
66
|
+
remaining_depth = [@remaining_depth, relation.depth].min - 1
|
|
67
|
+
relation_records, collection = relation_records(relation)
|
|
68
|
+
relation_records.each_with_index.flat_map do |related_record, index|
|
|
69
|
+
relation_path = path_for(relation, index, collection: collection)
|
|
70
|
+
SnapshotBuilder.new(
|
|
71
|
+
related_record,
|
|
72
|
+
related_record.class.maglev_config,
|
|
73
|
+
path: relation_path,
|
|
74
|
+
visited: @visited,
|
|
75
|
+
attachment_extractor: @attachment_extractor,
|
|
76
|
+
remaining_depth: remaining_depth
|
|
77
|
+
).build.to_s.split("\n")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def attachment_lines
|
|
83
|
+
@config.attached_sources.flat_map do |source|
|
|
84
|
+
attachment_blobs(source.name).flat_map do |blob|
|
|
85
|
+
document = extract_attachment(source.name, blob)
|
|
86
|
+
if document.extracted?
|
|
87
|
+
["#{document.source_identifier}.text: #{document.text}"]
|
|
88
|
+
else
|
|
89
|
+
["#{document.source_identifier}.skipped: #{document.metadata[:reason]}"]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def attachment_blobs(source_name)
|
|
96
|
+
value = @record.public_send(source_name)
|
|
97
|
+
attachments = if value.respond_to?(:attachments)
|
|
98
|
+
value.attachments.to_a
|
|
99
|
+
elsif value.respond_to?(:attachment)
|
|
100
|
+
[value.attachment].compact
|
|
101
|
+
elsif value.respond_to?(:blob)
|
|
102
|
+
[value]
|
|
103
|
+
elsif value.respond_to?(:to_ary)
|
|
104
|
+
value.to_ary
|
|
105
|
+
else
|
|
106
|
+
[value].compact
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
attachments.map { |attachment| attachment.respond_to?(:blob) ? attachment.blob : attachment }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def extract_attachment(source_name, blob)
|
|
113
|
+
@attachment_extractor.extract(blob, source_name: source_name)
|
|
114
|
+
rescue
|
|
115
|
+
ExtractedDocument.skipped(
|
|
116
|
+
source_identifier: "#{source_name}[blob:#{blob_identifier(blob)}]",
|
|
117
|
+
reason: "extraction_failed",
|
|
118
|
+
metadata: {
|
|
119
|
+
filename: blob.respond_to?(:filename) ? blob.filename.to_s : "unknown",
|
|
120
|
+
content_type: blob.respond_to?(:content_type) ? blob.content_type.to_s : nil
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def rich_text_lines
|
|
126
|
+
@config.rich_text_sources.filter_map do |source|
|
|
127
|
+
text = rich_text_value(source.name)
|
|
128
|
+
"#{rich_text_identifier(source.name)}.text: #{text}" unless text.nil? || text.empty?
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def rich_text_value(source_name)
|
|
133
|
+
value = @record.public_send(source_name)
|
|
134
|
+
text = if value.respond_to?(:body) && value.body.respond_to?(:to_plain_text)
|
|
135
|
+
value.body.to_plain_text
|
|
136
|
+
elsif value.respond_to?(:to_plain_text)
|
|
137
|
+
value.to_plain_text
|
|
138
|
+
elsif value.respond_to?(:body) && value.body.respond_to?(:to_html)
|
|
139
|
+
sanitize_html(value.body.to_html.to_s)
|
|
140
|
+
else
|
|
141
|
+
sanitize_html(value.to_s)
|
|
142
|
+
end
|
|
143
|
+
text.squeeze(" ").strip
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def rich_text_identifier(source_name)
|
|
147
|
+
"rich_text.#{source_name}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def blob_identifier(blob)
|
|
151
|
+
if blob.respond_to?(:id) && blob.id
|
|
152
|
+
blob.id
|
|
153
|
+
elsif blob.respond_to?(:key) && blob.key
|
|
154
|
+
blob.key
|
|
155
|
+
elsif blob.respond_to?(:filename)
|
|
156
|
+
blob.filename.to_s
|
|
157
|
+
else
|
|
158
|
+
"unknown"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def sanitize_html(html)
|
|
163
|
+
without_scripts = html.gsub(%r{<script\b[^>]*>.*?</script>}im, " ")
|
|
164
|
+
.gsub(%r{<style\b[^>]*>.*?</style>}im, " ")
|
|
165
|
+
CGI.unescapeHTML(without_scripts.gsub(/<[^>]+>/, " ").squeeze(" ").strip)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def relation_records(relation)
|
|
169
|
+
value = @record.public_send(relation.name)
|
|
170
|
+
records, collection = if value.nil?
|
|
171
|
+
[[], false]
|
|
172
|
+
elsif value.respond_to?(:to_ary)
|
|
173
|
+
[value.to_ary, true]
|
|
174
|
+
elsif value.respond_to?(:limit)
|
|
175
|
+
[value.limit(relation.limit).to_a, true]
|
|
176
|
+
else
|
|
177
|
+
[[value], false]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
[records.first(relation.limit), collection]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def path_for(relation, index, collection:)
|
|
184
|
+
segment = collection ? "#{relation.name}[#{index}]" : relation.name
|
|
185
|
+
@path ? "#{@path}.#{segment}" : segment
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def attribute_path(attribute)
|
|
189
|
+
@path ? "#{@path}.#{attribute}" : attribute
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def visited?
|
|
193
|
+
@visited.key?(record_key)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def mark_visited
|
|
197
|
+
@visited[record_key] = true
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def record_key
|
|
201
|
+
[@record.class.name, record_id]
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maglev
|
|
4
|
+
module VectorStores
|
|
5
|
+
class Base
|
|
6
|
+
def upsert(documents:)
|
|
7
|
+
raise NotImplementedError, "#{self.class.name} must implement #upsert"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def search(vector:, filters:, limit:)
|
|
11
|
+
raise NotImplementedError, "#{self.class.name} must implement #search"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def delete(ids:)
|
|
15
|
+
raise NotImplementedError, "#{self.class.name} must implement #delete"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def delete_by_owner(owner_type:, owner_id:)
|
|
19
|
+
raise NotImplementedError, "#{self.class.name} must implement #delete_by_owner"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def healthcheck
|
|
23
|
+
raise NotImplementedError, "#{self.class.name} must implement #healthcheck"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def capabilities
|
|
27
|
+
{}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maglev
|
|
4
|
+
module VectorStores
|
|
5
|
+
class Document
|
|
6
|
+
attr_reader :id, :owner_type, :owner_id, :owner_model_name, :source,
|
|
7
|
+
:chunk_index, :content, :content_checksum, :embedding_model,
|
|
8
|
+
:embedding, :owner, :distance
|
|
9
|
+
|
|
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)
|
|
13
|
+
@owner_type = owner_type
|
|
14
|
+
@owner_id = owner_id
|
|
15
|
+
@owner_model_name = owner_model_name
|
|
16
|
+
@source = source
|
|
17
|
+
@chunk_index = chunk_index
|
|
18
|
+
@content = content
|
|
19
|
+
@content_checksum = content_checksum
|
|
20
|
+
@embedding_model = embedding_model
|
|
21
|
+
@embedding = embedding
|
|
22
|
+
@id = id || "#{owner_type}:#{owner_id}:#{source}:#{chunk_index}"
|
|
23
|
+
@owner = owner
|
|
24
|
+
@distance = distance
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def metadata
|
|
29
|
+
{
|
|
30
|
+
owner_type: owner_type,
|
|
31
|
+
owner_id: owner_id,
|
|
32
|
+
owner_model_name: owner_model_name,
|
|
33
|
+
source: source,
|
|
34
|
+
chunk_index: chunk_index
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "document"
|
|
5
|
+
|
|
6
|
+
module Maglev
|
|
7
|
+
module VectorStores
|
|
8
|
+
class Memory < Base
|
|
9
|
+
def initialize
|
|
10
|
+
@documents = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def upsert(documents:)
|
|
14
|
+
documents.each { |document| @documents[document.id] = document }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def search(vector:, filters:, limit:)
|
|
18
|
+
@documents.values
|
|
19
|
+
.select { |document| matches_filters?(document, filters) }
|
|
20
|
+
.map { |document| with_distance(document, cosine_distance(vector, document.embedding)) }
|
|
21
|
+
.sort_by(&:distance)
|
|
22
|
+
.first(limit)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def delete(ids:)
|
|
26
|
+
ids.each { |id| @documents.delete(id) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
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
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def healthcheck
|
|
36
|
+
:ok
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def capabilities
|
|
40
|
+
{metadata_filtering: true, in_memory: true}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def matches_filters?(document, filters)
|
|
46
|
+
filters.all? { |key, value| document.metadata.fetch(key) == value }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def cosine_distance(left, right)
|
|
50
|
+
dot = left.zip(right).sum { |a, b| a.to_f * b.to_f }
|
|
51
|
+
left_norm = Math.sqrt(left.sum { |value| value.to_f**2 })
|
|
52
|
+
right_norm = Math.sqrt(right.sum { |value| value.to_f**2 })
|
|
53
|
+
return 1.0 if left_norm.zero? || right_norm.zero?
|
|
54
|
+
|
|
55
|
+
1.0 - (dot / (left_norm * right_norm))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def with_distance(document, distance)
|
|
59
|
+
Document.new(
|
|
60
|
+
id: document.id,
|
|
61
|
+
owner_type: document.owner_type,
|
|
62
|
+
owner_id: document.owner_id,
|
|
63
|
+
owner_model_name: document.owner_model_name,
|
|
64
|
+
source: document.source,
|
|
65
|
+
chunk_index: document.chunk_index,
|
|
66
|
+
content: document.content,
|
|
67
|
+
content_checksum: document.content_checksum,
|
|
68
|
+
embedding_model: document.embedding_model,
|
|
69
|
+
embedding: document.embedding,
|
|
70
|
+
owner: document.owner,
|
|
71
|
+
distance: distance
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../chunk"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
require_relative "document"
|
|
6
|
+
|
|
7
|
+
module Maglev
|
|
8
|
+
module VectorStores
|
|
9
|
+
class Pgvector < Base
|
|
10
|
+
def initialize(chunk_model: Chunk)
|
|
11
|
+
@chunk_model = chunk_model
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def upsert(documents:)
|
|
15
|
+
@chunk_model.transaction do
|
|
16
|
+
documents.each do |document|
|
|
17
|
+
scope = identity_scope(document)
|
|
18
|
+
existing = scope.find_by(chunk_index: document.chunk_index, content_checksum: document.content_checksum)
|
|
19
|
+
next if existing
|
|
20
|
+
|
|
21
|
+
scope.where(chunk_index: document.chunk_index).delete_all
|
|
22
|
+
@chunk_model.create!(
|
|
23
|
+
owner_type: document.owner_type,
|
|
24
|
+
owner_id: document.owner_id,
|
|
25
|
+
owner_model_name: document.owner_model_name,
|
|
26
|
+
owner: document.owner,
|
|
27
|
+
source: document.source,
|
|
28
|
+
chunk_index: document.chunk_index,
|
|
29
|
+
content: document.content,
|
|
30
|
+
content_checksum: document.content_checksum,
|
|
31
|
+
embedding_model: document.embedding_model,
|
|
32
|
+
embedding: document.embedding
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def search(vector:, filters:, limit:)
|
|
39
|
+
scope = filters.reduce(@chunk_model.all) do |current_scope, (key, value)|
|
|
40
|
+
current_scope.where(key => value)
|
|
41
|
+
end
|
|
42
|
+
scope.nearest_neighbors(:embedding, vector, distance: "cosine")
|
|
43
|
+
.first(limit)
|
|
44
|
+
.map { |row| document_for(row) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def delete(ids:)
|
|
48
|
+
ids.each do |id|
|
|
49
|
+
owner_type, owner_id, source, chunk_index = id.split(":", 4)
|
|
50
|
+
@chunk_model.where(owner_type: owner_type, owner_id: owner_id, source: source, chunk_index: chunk_index).delete_all
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def delete_by_owner(owner_type:, owner_id:)
|
|
55
|
+
@chunk_model.where(owner_type: owner_type, owner_id: owner_id).delete_all
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def healthcheck
|
|
59
|
+
@chunk_model.connection.active? ? :ok : :unavailable
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def capabilities
|
|
63
|
+
{metadata_filtering: true, pgvector: true}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def identity_scope(document)
|
|
69
|
+
@chunk_model.where(
|
|
70
|
+
owner_type: document.owner_type,
|
|
71
|
+
owner_id: document.owner_id,
|
|
72
|
+
owner_model_name: document.owner_model_name,
|
|
73
|
+
source: document.source
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def document_for(row)
|
|
78
|
+
Document.new(
|
|
79
|
+
owner_type: row.owner_type,
|
|
80
|
+
owner_id: row.owner_id,
|
|
81
|
+
owner_model_name: row.owner_model_name,
|
|
82
|
+
source: row.source,
|
|
83
|
+
chunk_index: row.chunk_index,
|
|
84
|
+
content: row.content,
|
|
85
|
+
content_checksum: row.content_checksum,
|
|
86
|
+
embedding_model: row.embedding_model,
|
|
87
|
+
embedding: row.embedding,
|
|
88
|
+
owner: row.owner,
|
|
89
|
+
distance: row.respond_to?(:neighbor_distance) ? row.neighbor_distance : row.distance
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/maglev.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "maglev/version"
|
|
4
|
+
require_relative "maglev/configuration"
|
|
5
|
+
require_relative "maglev/provider_configuration"
|
|
6
|
+
require_relative "maglev/errors"
|
|
7
|
+
require_relative "maglev/authorization"
|
|
8
|
+
require_relative "maglev/provider_call"
|
|
9
|
+
require_relative "maglev/knowledge_config"
|
|
10
|
+
require_relative "maglev/snapshot"
|
|
11
|
+
require_relative "maglev/snapshot_builder"
|
|
12
|
+
require_relative "maglev/chunker"
|
|
13
|
+
require_relative "maglev/embedding_adapter"
|
|
14
|
+
require_relative "maglev/adapters/ruby_llm_embedding"
|
|
15
|
+
require_relative "maglev/generation_adapter"
|
|
16
|
+
require_relative "maglev/adapters/ruby_llm_generation"
|
|
17
|
+
require_relative "maglev/extracted_document"
|
|
18
|
+
require_relative "maglev/attachment_extractor"
|
|
19
|
+
require_relative "maglev/adapters/ruby_llm_attachment_extractor"
|
|
20
|
+
require_relative "maglev/search_result"
|
|
21
|
+
require_relative "maglev/vector_stores/base"
|
|
22
|
+
require_relative "maglev/vector_stores/document"
|
|
23
|
+
require_relative "maglev/vector_stores/memory"
|
|
24
|
+
require_relative "maglev/vector_stores/pgvector"
|
|
25
|
+
require_relative "maglev/response"
|
|
26
|
+
require_relative "maglev/prompt_builder"
|
|
27
|
+
require_relative "maglev/context_assembler"
|
|
28
|
+
require_relative "maglev/context_preview"
|
|
29
|
+
require_relative "maglev/indexer"
|
|
30
|
+
require_relative "maglev/reindex_job"
|
|
31
|
+
require_relative "maglev/retriever"
|
|
32
|
+
require_relative "maglev/schema_compiler"
|
|
33
|
+
require_relative "maglev/dependency_graph"
|
|
34
|
+
require_relative "maglev/content_source_graph"
|
|
35
|
+
require_relative "maglev/answerer"
|
|
36
|
+
|
|
37
|
+
require_relative "maglev/railtie" if defined?(Rails::Railtie)
|