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
data/README.zh-CN.md
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
# Maglev
|
|
2
|
+
|
|
3
|
+
[English](README.md) | [简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
[](https://github.com/benjis/maglev/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.ruby-lang.org/)
|
|
7
|
+
[](https://rubyonrails.org/)
|
|
8
|
+
[](LICENSE.txt)
|
|
9
|
+
|
|
10
|
+
**无需在 Rails 之外另建一套系统,也能让 Rails 模型拥有语义记忆。**
|
|
11
|
+
|
|
12
|
+
Maglev 是面向 ActiveRecord 对象图的 Rails 原生语义知识层。你只需声明领域模型中哪些信息适合被理解和检索,Maglev 就会把记录、关联关系、附件和富文本转换为可搜索的知识。它通过 Rails 原有的生命周期机制持续更新这些知识,并以熟悉的模型 API 提供语义搜索和有依据的问答能力。
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
Product.search("存在续航或易用性问题的笔记本电脑")
|
|
16
|
+
|
|
17
|
+
response = Product.ask("有哪些反复出现、值得调查的产品问题?", user: current_user)
|
|
18
|
+
response.text
|
|
19
|
+
response.sources # 支撑回答的 ActiveRecord 记录和内容分块
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Maglev 专注于检索增强生成(RAG)。ActiveRecord 仍然负责精确筛选、关联查询、报表和聚合;Maglev 则处理用自然语言提出的问题。
|
|
23
|
+
|
|
24
|
+
## 为什么选择 Maglev?
|
|
25
|
+
|
|
26
|
+
- **Rails 原生:** 它是一个 gem 和 Railtie,而不是独立服务、Engine 或额外 API。
|
|
27
|
+
- **模型驱动:** 在拥有数据的 ActiveRecord 模型旁直接声明知识边界。
|
|
28
|
+
- **理解对象图:** 支持直接关联、`has_many :through` 和多态关联,并可显式限制深度与记录数。
|
|
29
|
+
- **自动保持新鲜:** 已声明记录、直接关联、附件或 Action Text 内容发生变化后,自动重新索引知识所有者。
|
|
30
|
+
- **回答有依据:** 仅根据检索到的上下文生成答案,并返回来源。
|
|
31
|
+
- **面向生产环境:** 内置授权接口、内容限制、清洗、重试、可观测性和幂等重建索引。
|
|
32
|
+
- **可扩展:** 可使用默认的 PostgreSQL/pgvector,也可实现精简的向量存储协议。
|
|
33
|
+
|
|
34
|
+
## 快速开始
|
|
35
|
+
|
|
36
|
+
### 1. 安装前置依赖
|
|
37
|
+
|
|
38
|
+
Maglev 需要 Ruby 3.2+、Rails 7.1 或 8.0、PostgreSQL,以及
|
|
39
|
+
[`pgvector`](https://github.com/pgvector/pgvector) 扩展。
|
|
40
|
+
|
|
41
|
+
将 Maglev 加入应用:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
# Gemfile
|
|
45
|
+
gem "maglev-rb"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
bundle install
|
|
50
|
+
bin/rails generate maglev:install --embedding-dimensions=1536
|
|
51
|
+
bin/rails db:migrate
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
生成器会把 `--embedding-dimensions` 同时写入 `config/initializers/maglev.rb` 和 `maglev_chunks` 的向量列,并创建基于余弦距离的 HNSW 索引。
|
|
55
|
+
|
|
56
|
+
### 2. 配置模型提供商
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# config/initializers/maglev.rb
|
|
60
|
+
Maglev.configure do |config|
|
|
61
|
+
config.embedding_provider do |provider|
|
|
62
|
+
provider.url = "http://localhost:11434/v1"
|
|
63
|
+
provider.api_key = ENV["LOCAL_EMBEDDING_API_KEY"]
|
|
64
|
+
provider.model = "Qwen3-Embedding-0.6B-8bit"
|
|
65
|
+
provider.dimensions = 1024
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
config.generation_provider do |provider|
|
|
69
|
+
provider.url = "https://api.deepseek.com/v1"
|
|
70
|
+
provider.api_key = Rails.application.credentials.dig(:deepseek_api_key)
|
|
71
|
+
provider.model = "deepseek-chat"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
config.chunk_size = 1000
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Embedding 与 generation endpoint 相互独立,可以使用不同的 URL、API key 和 model。默认 provider bridge 使用 OpenAI-compatible HTTP endpoint;其他协议仍可通过 Maglev 自定义 adapter 接入。
|
|
79
|
+
|
|
80
|
+
对于已有安装,请同时修改配置维度和数据库向量列。Maglev 会在请求 embedding 前检查两者是否一致。
|
|
81
|
+
|
|
82
|
+
### 3. 声明模型知识
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
class Product < ApplicationRecord
|
|
86
|
+
has_many :reviews, inverse_of: :product
|
|
87
|
+
has_many :product_categories, inverse_of: :product
|
|
88
|
+
has_many :categories, through: :product_categories
|
|
89
|
+
has_many_attached :images
|
|
90
|
+
has_rich_text :description
|
|
91
|
+
|
|
92
|
+
has_knowledge do
|
|
93
|
+
expose :name, :sku, :price, :status
|
|
94
|
+
tags :product
|
|
95
|
+
|
|
96
|
+
include_related :reviews, depth: 1, limit: 10
|
|
97
|
+
include_related :categories, depth: 1, limit: 10, inverse: :products
|
|
98
|
+
|
|
99
|
+
expose_attached :images
|
|
100
|
+
expose_rich_text :description
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
class Review < ApplicationRecord
|
|
105
|
+
belongs_to :product, inverse_of: :reviews
|
|
106
|
+
|
|
107
|
+
has_knowledge do
|
|
108
|
+
expose :rating, :title, :body
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
只有显式公开的字段和内容源才会进入 Maglev 的知识快照。关联的 `limit` 限制每个关联包含的记录数;`depth` 限制关联跳数:`depth: 1` 会包含直接关联记录,但不会继续展开该记录的关联。`config.max_relation_depth` 是每个快照从根记录到叶记录的全局硬上限。每个关联模型独立声明自己的公开知识,因此连接模型中的敏感字段不会被意外展开。
|
|
114
|
+
|
|
115
|
+
### 4. 为已有记录建立索引
|
|
116
|
+
|
|
117
|
+
新建和更新记录时会自动将 `Maglev::ReindexJob` 加入队列。安装后可执行一次已有数据回填:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
bin/rails maglev:reindex[Product]
|
|
121
|
+
# 或重建所有声明了 has_knowledge 的模型
|
|
122
|
+
bin/rails maglev:reindex_all
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
请确保生产环境中的 Active Job 后端正在运行。
|
|
126
|
+
|
|
127
|
+
### 5. 搜索与提问
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
results = Product.search(
|
|
131
|
+
"存在续航或易用性问题的笔记本电脑",
|
|
132
|
+
limit: 10,
|
|
133
|
+
user: current_user
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
results.each do |result|
|
|
137
|
+
result.owner # => Product
|
|
138
|
+
result.content # 检索到的快照分块
|
|
139
|
+
result.source # => "snapshot"
|
|
140
|
+
result.distance # 余弦距离,越小越接近
|
|
141
|
+
result.similarity # 归一化后的便捷相似度分数
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
根据检索到的记录生成有依据的回答:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
response = Product.ask(
|
|
149
|
+
"商品团队应调查哪些反复出现的产品问题?",
|
|
150
|
+
limit: 5,
|
|
151
|
+
user: current_user
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
response.text
|
|
155
|
+
response.sources # 所有者、分块、距离和实际检索到的内容
|
|
156
|
+
response.metadata
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
例如,回答可能根据检索到的评价内容,总结出风扇噪音、触控板偶尔失灵以及实际续航低于宣传值。应把它理解为对“已检索上下文”的总结,而不是对全部产品做出的数据库聚合;每项结论都应通过 `response.sources` 展示依据。
|
|
160
|
+
|
|
161
|
+
实例级问题会限定在单个知识所有者内:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
product.ask("总结这款产品被反馈的优点和缺点。", user: current_user)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Rails 数据变化时,Maglev 还会沿已声明的关联更新知识。将评价移动到另一款产品后,事务提交会为原产品和新产品安排重新索引,使两边的可搜索知识保持最新:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
review.update!(product: replacement_product)
|
|
171
|
+
# 两个受影响的产品都会加入 Maglev::ReindexJob 队列。
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
如果没有检索到可用上下文,Maglev 会返回确定性的“上下文不足”响应,而不会让模型自行猜测。
|
|
175
|
+
|
|
176
|
+
## 工作原理
|
|
177
|
+
|
|
178
|
+
```mermaid
|
|
179
|
+
flowchart LR
|
|
180
|
+
A["ActiveRecord 对象图"] --> B["显式知识结构"]
|
|
181
|
+
B --> C["有边界且已清洗的快照"]
|
|
182
|
+
C --> D["分块 + 嵌入"]
|
|
183
|
+
D --> E["向量存储<br/>默认 pgvector"]
|
|
184
|
+
Q["search / ask"] --> F["语义检索"]
|
|
185
|
+
E --> F
|
|
186
|
+
F --> G["上下文组装"]
|
|
187
|
+
G --> H["基于上下文生成"]
|
|
188
|
+
H --> I["回答 + 来源"]
|
|
189
|
+
J["Rails 提交与内容变化"] --> K["Active Job 重建索引"]
|
|
190
|
+
K --> C
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
1. `has_knowledge` 为模型及其声明的关联编译一份显式知识结构。
|
|
194
|
+
2. Maglev 根据允许的属性、关联记录、附件、富文本和标签生成确定性的文本快照。
|
|
195
|
+
3. 快照被拆成大小受限的分块,并通过配置的适配器生成嵌入向量。
|
|
196
|
+
4. 分块被写入向量存储;默认存储使用 PostgreSQL 和 pgvector。
|
|
197
|
+
5. `search` 为查询生成嵌入,并执行基于余弦距离的近邻检索。
|
|
198
|
+
6. `ask` 在上下文预算内组装分块,构建有依据的提示词,并返回包含来源元数据的答案。
|
|
199
|
+
7. Rails 回调会沿对象图传播已声明记录的变化,并为受影响的所有者安排重新索引。
|
|
200
|
+
|
|
201
|
+
Maglev 不会复制你的关系数据模型。向量文档会指回对应的 ActiveRecord 所有者;事务、业务规则和结构化查询仍由应用负责。
|
|
202
|
+
|
|
203
|
+
## 知识来源
|
|
204
|
+
|
|
205
|
+
### ActiveRecord 对象图
|
|
206
|
+
|
|
207
|
+
`include_related` 支持对普通关联、`has_many :through` 和多态关联进行有限遍历。当 Maglev 无法自动推断关联模型的变化应如何找到知识所有者时,可使用 `inverse:` 显式指定反向关联。
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
has_knowledge do
|
|
211
|
+
include_related :tickets, depth: 2, limit: 25
|
|
212
|
+
include_related :events, depth: 1, limit: 20, inverse: :eventable
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
当关联记录从一个所有者转移到另一个所有者时,Maglev 会同时重新索引旧所有者和新所有者。
|
|
217
|
+
|
|
218
|
+
创建、删除或重新分配连接记录会改变 `has_many :through` 关系,但不会改变关联记录本身。此类连接模型变更后,请在应用中显式加入或执行所有者重新索引。
|
|
219
|
+
|
|
220
|
+
### Active Storage 与 Action Text
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
has_knowledge do
|
|
224
|
+
expose_attached :contracts, :brief
|
|
225
|
+
expose_rich_text :notes
|
|
226
|
+
end
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
HTML 和 Action Text 内容会在索引前进行清洗。附件会受到内容类型、字节大小和提取字符数限制。已声明附件和富文本的变化会触发所有者重新索引。
|
|
230
|
+
|
|
231
|
+
### 在索引前检查内容
|
|
232
|
+
|
|
233
|
+
以下开发者 API 可用于确认模型实际公开的内容,且不会调用嵌入或生成服务:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
Customer.maglev_schema
|
|
237
|
+
customer.maglev_snapshot
|
|
238
|
+
|
|
239
|
+
preview = customer.maglev_context_preview(
|
|
240
|
+
question: "为什么这位客户存在风险?"
|
|
241
|
+
)
|
|
242
|
+
preview.text
|
|
243
|
+
preview.metadata # 包含 provider_calls: 0
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## 授权
|
|
247
|
+
|
|
248
|
+
Maglev 不绑定特定的策略库。你可以配置一个简单适配器,在检索和回答时应用应用自身的授权规则:
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
class MaglevAuthorization
|
|
252
|
+
def scope(model:, user:)
|
|
253
|
+
model.accessible_by(user)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def authorize(record:, user:)
|
|
257
|
+
record.account_id == user.account_id
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
Maglev.configure do |config|
|
|
262
|
+
config.authorization_adapter = MaglevAuthorization.new
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
适配器必须实现:
|
|
267
|
+
|
|
268
|
+
- `scope(model:, user:)`:返回该用户可见的记录。
|
|
269
|
+
- `authorize(record:, user:)`:返回 `false` 时拒绝访问记录。
|
|
270
|
+
|
|
271
|
+
如果未配置适配器,默认允许访问所有记录。凡是需要限定检索范围的调用,都应一致地传入 `user:`。
|
|
272
|
+
|
|
273
|
+
`customer.explain` 是面向无需用户级授权场景的便捷 API;需要用户上下文时,请使用 `customer.ask(Maglev.configuration.explain_question, user: current_user)`。授权作用域适用于默认 pgvector 检索路径。将直接 `search` 调用与自定义存储结合前,请阅读[向量存储](#向量存储)。
|
|
274
|
+
|
|
275
|
+
## 配置
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
Maglev.configure do |config|
|
|
279
|
+
config.embedding_provider do |provider|
|
|
280
|
+
provider.url = ENV.fetch("MAGLEV_EMBEDDING_URL", "https://api.openai.com/v1")
|
|
281
|
+
provider.api_key = ENV["MAGLEV_EMBEDDING_API_KEY"]
|
|
282
|
+
provider.model = "text-embedding-3-small"
|
|
283
|
+
provider.dimensions = 1536
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
config.generation_provider do |provider|
|
|
287
|
+
provider.url = ENV.fetch("MAGLEV_GENERATION_URL", "https://api.openai.com/v1")
|
|
288
|
+
provider.api_key = ENV["MAGLEV_GENERATION_API_KEY"]
|
|
289
|
+
provider.model = "gpt-4.1-mini"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
config.chunk_size = 1000
|
|
293
|
+
|
|
294
|
+
config.context_max_characters = 4000
|
|
295
|
+
config.context_per_owner_characters = 1200
|
|
296
|
+
config.max_relation_depth = 3
|
|
297
|
+
|
|
298
|
+
config.attachment_allowed_content_types = [
|
|
299
|
+
"text/plain",
|
|
300
|
+
"text/markdown",
|
|
301
|
+
"text/html"
|
|
302
|
+
]
|
|
303
|
+
config.attachment_max_bytes = 5 * 1024 * 1024
|
|
304
|
+
config.attachment_max_characters = 20_000
|
|
305
|
+
|
|
306
|
+
config.provider_max_attempts = 2
|
|
307
|
+
config.provider_timeout = 30
|
|
308
|
+
config.logger = Rails.logger
|
|
309
|
+
end
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
`provider_timeout` 对每次 provider 尝试分别生效。超时会被视为可重试错误,并计入 `provider_max_attempts`。
|
|
313
|
+
|
|
314
|
+
当应用需要不同的模型提供商或策略行为时,可注入自定义的 `embedding_adapter`、`generation_adapter`、`attachment_extractor`、`authorization_adapter` 或 `source_redactor`。测试环境可以使用确定性适配器,全程不发起网络请求。
|
|
315
|
+
|
|
316
|
+
## 向量存储
|
|
317
|
+
|
|
318
|
+
PostgreSQL 与 pgvector 是默认的生产环境方案。对于需要其他存储后端的应用,Maglev 也提供了精简的协议:
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
class MyVectorStore < Maglev::VectorStores::Base
|
|
322
|
+
def upsert(documents:)
|
|
323
|
+
# 使用 document.id 持久化或替换文档
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def search(vector:, filters:, limit:)
|
|
327
|
+
# 返回最近的 Maglev::VectorStores::Document 对象
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def delete(ids:)
|
|
331
|
+
# 删除具有这些稳定 ID 的文档
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def delete_by_owner(owner_type:, owner_id:)
|
|
335
|
+
# 删除属于该所有者的所有文档
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def healthcheck = :ok
|
|
339
|
+
def capabilities = {metadata_filtering: true}
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
Maglev.configure do |config|
|
|
343
|
+
config.vector_store = MyVectorStore.new
|
|
344
|
+
end
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
`Maglev::VectorStores::Memory` 适合测试和本地实验。自定义存储应保留文档元数据筛选能力和稳定的文档标识语义。
|
|
348
|
+
|
|
349
|
+
自定义向量存储目前会收到模型和所有者元数据筛选条件,但不会收到已配置的授权作用域。`ask` 仍会逐条授权检索到的所有者;使用自定义存储时,不能将直接 `search(..., user:)` 视为已按授权过滤。需要时请在自定义存储内部应用租户或策略筛选;如果搜索必须遵守授权作用域,请使用默认 pgvector 路径。
|
|
350
|
+
|
|
351
|
+
## 运维与可观测性
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
bin/rails maglev:status
|
|
355
|
+
bin/rails maglev:reindex[Customer]
|
|
356
|
+
bin/rails maglev:reindex_all
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
重建索引可安全重复执行:未变化的分块会被复用,过期分块会被删除。Maglev 会为索引开始/成功/失败、检索、生成和提供商重试发出 ActiveSupport 通知:
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
ActiveSupport::Notifications.subscribe(/\Amaglev\./) do |name, start, finish, id, payload|
|
|
363
|
+
Rails.logger.info(
|
|
364
|
+
event: name,
|
|
365
|
+
duration_ms: ((finish - start) * 1000).round(1),
|
|
366
|
+
**payload
|
|
367
|
+
)
|
|
368
|
+
end
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## 安全与能力边界
|
|
372
|
+
|
|
373
|
+
- 应将提取内容视为不可信上下文。Maglev 会清洗支持的 HTML 来源,但授权和模型公开范围仍由应用负责。
|
|
374
|
+
- 只公开适合发送给所配置模型提供商的字段和内容源。
|
|
375
|
+
- 默认附件限制为 5 MiB 和 20,000 个提取字符;默认允许纯文本、Markdown、HTML 和 XHTML。
|
|
376
|
+
- v1 生成迁移使用 `bigint` 所有者 ID。使用 UUID 的模型需要自定义迁移。
|
|
377
|
+
- Maglev 只负责 RAG。它不会生成 SQL、通过数据库计算回答聚合问题、公开 REST 端点、提供管理界面、运行 Agent 或管理对话记忆。
|
|
378
|
+
|
|
379
|
+
## 兼容性
|
|
380
|
+
|
|
381
|
+
CI 测试矩阵如下:
|
|
382
|
+
|
|
383
|
+
| | 支持版本 |
|
|
384
|
+
|---|---|
|
|
385
|
+
| Ruby | 3.2、3.3 |
|
|
386
|
+
| Rails | 7.1、8.0 |
|
|
387
|
+
| 数据库 | PostgreSQL + pgvector |
|
|
388
|
+
|
|
389
|
+
矩阵之外的版本可能可以运行,但目前不作保证。
|
|
390
|
+
|
|
391
|
+
## 开发
|
|
392
|
+
|
|
393
|
+
```bash
|
|
394
|
+
bundle install
|
|
395
|
+
|
|
396
|
+
# PostgreSQL + pgvector 集成测试
|
|
397
|
+
MAGLEV_REQUIRE_POSTGRESQL=true MAGLEV_DATABASE=maglev_test bundle exec rspec
|
|
398
|
+
|
|
399
|
+
bundle exec standardrb
|
|
400
|
+
bundle exec rubocop
|
|
401
|
+
bundle exec rake build
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
默认测试套件使用确定性的虚拟适配器,不会调用真实的 LLM 或嵌入服务。
|
|
405
|
+
|
|
406
|
+
## 许可证
|
|
407
|
+
|
|
408
|
+
Maglev 以 [MIT License](LICENSE.txt) 开源发布。
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Maglev
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
class_option :embedding_dimensions, type: :numeric, default: 1536
|
|
10
|
+
|
|
11
|
+
def create_initializer
|
|
12
|
+
create_file "config/initializers/maglev.rb", <<~RUBY
|
|
13
|
+
# frozen_string_literal: true
|
|
14
|
+
|
|
15
|
+
Maglev.configure do |config|
|
|
16
|
+
config.embedding_provider do |provider|
|
|
17
|
+
provider.url = ENV.fetch("MAGLEV_EMBEDDING_URL", "https://api.openai.com/v1")
|
|
18
|
+
provider.api_key = ENV["MAGLEV_EMBEDDING_API_KEY"]
|
|
19
|
+
provider.model = "text-embedding-3-small"
|
|
20
|
+
provider.dimensions = #{options[:embedding_dimensions]}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
config.generation_provider do |provider|
|
|
24
|
+
provider.url = ENV.fetch("MAGLEV_GENERATION_URL", "https://api.openai.com/v1")
|
|
25
|
+
provider.api_key = ENV["MAGLEV_GENERATION_API_KEY"]
|
|
26
|
+
provider.model = "gpt-4.1-mini"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
config.chunk_size = 1000
|
|
30
|
+
end
|
|
31
|
+
RUBY
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def create_migration
|
|
35
|
+
create_file "db/migrate/#{migration_timestamp}_create_maglev_chunks.rb", <<~RUBY
|
|
36
|
+
# frozen_string_literal: true
|
|
37
|
+
|
|
38
|
+
class CreateMaglevChunks < ActiveRecord::Migration[7.1]
|
|
39
|
+
def change
|
|
40
|
+
enable_extension "vector"
|
|
41
|
+
|
|
42
|
+
create_table :maglev_chunks do |t|
|
|
43
|
+
t.string :owner_type, null: false
|
|
44
|
+
t.bigint :owner_id, null: false
|
|
45
|
+
t.string :owner_model_name, null: false
|
|
46
|
+
t.string :source, null: false
|
|
47
|
+
t.integer :chunk_index, null: false
|
|
48
|
+
t.text :content, null: false
|
|
49
|
+
t.string :content_checksum, null: false
|
|
50
|
+
t.string :embedding_model, null: false
|
|
51
|
+
t.vector :embedding, limit: #{options[:embedding_dimensions]}, null: false
|
|
52
|
+
t.timestamps
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
add_index :maglev_chunks, [:owner_type, :owner_id, :source, :chunk_index], unique: true, name: "index_maglev_chunks_on_owner_source_chunk"
|
|
56
|
+
add_index :maglev_chunks, :owner_model_name
|
|
57
|
+
add_index :maglev_chunks, :embedding, using: :hnsw, opclass: :vector_cosine_ops
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
RUBY
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def migration_timestamp
|
|
66
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
require_relative "answerer"
|
|
6
|
+
require_relative "content_source_graph"
|
|
7
|
+
require_relative "context_preview"
|
|
8
|
+
require_relative "dependency_graph"
|
|
9
|
+
require_relative "knowledge_config"
|
|
10
|
+
require_relative "indexer"
|
|
11
|
+
require_relative "reindex_job"
|
|
12
|
+
require_relative "retriever"
|
|
13
|
+
require_relative "schema_compiler"
|
|
14
|
+
require_relative "snapshot_builder"
|
|
15
|
+
|
|
16
|
+
module Maglev
|
|
17
|
+
module ActiveRecordExtension
|
|
18
|
+
extend ActiveSupport::Concern
|
|
19
|
+
|
|
20
|
+
class_methods do
|
|
21
|
+
def has_knowledge(&block)
|
|
22
|
+
@maglev_config = KnowledgeConfig.build(self, &block)
|
|
23
|
+
@maglev_schema = SchemaCompiler.new(@maglev_config).compile if @maglev_config.relations.any?
|
|
24
|
+
DependencyGraph.register(@maglev_schema) if @maglev_schema
|
|
25
|
+
ContentSourceGraph.register(@maglev_config)
|
|
26
|
+
register_maglev_callbacks
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def maglev_config
|
|
30
|
+
if instance_variable_defined?(:@maglev_config)
|
|
31
|
+
@maglev_config
|
|
32
|
+
elsif superclass.respond_to?(:maglev_config)
|
|
33
|
+
superclass.maglev_config
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def search(query, limit: 10, user: nil)
|
|
38
|
+
Retriever.new(self).search(query, limit: limit, user: user)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def maglev_schema
|
|
42
|
+
config = maglev_config
|
|
43
|
+
{
|
|
44
|
+
model: name,
|
|
45
|
+
exposed_attributes: config&.exposed_attributes || [],
|
|
46
|
+
relations: config&.relations&.map(&:name) || [],
|
|
47
|
+
attached_sources: config&.attached_sources&.map(&:name) || [],
|
|
48
|
+
rich_text_sources: config&.rich_text_sources&.map(&:name) || []
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def ask(question, limit: 10, user: nil)
|
|
53
|
+
user ? Answerer.new(self).ask(question, limit: limit, user: user) : Answerer.new(self).ask(question, limit: limit)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def register_maglev_callbacks
|
|
59
|
+
return if instance_variable_defined?(:@maglev_callbacks_registered) && @maglev_callbacks_registered
|
|
60
|
+
|
|
61
|
+
after_commit :maglev_reindex, on: %i[create update]
|
|
62
|
+
after_commit :maglev_unindex, on: :destroy
|
|
63
|
+
@maglev_callbacks_registered = true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def maglev_snapshot
|
|
68
|
+
SnapshotBuilder.new(self, self.class.maglev_config).build.to_s
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ask(question, limit: 10, user: nil)
|
|
72
|
+
if user
|
|
73
|
+
Answerer.new(self.class).ask(question, limit: limit, owner: self, user: user)
|
|
74
|
+
else
|
|
75
|
+
Answerer.new(self.class).ask(question, limit: limit, owner: self)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def explain(limit: 10)
|
|
80
|
+
ask(Maglev.configuration.explain_question, limit: limit)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def maglev_context_preview(question: nil)
|
|
84
|
+
ContextPreview.new(
|
|
85
|
+
text: maglev_snapshot,
|
|
86
|
+
metadata: {question: question, provider_calls: 0}
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def maglev_reindex
|
|
93
|
+
ReindexJob.perform_later(self.class.name, id)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def maglev_unindex
|
|
97
|
+
Indexer.new(self).unindex
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../attachment_extractor"
|
|
4
|
+
|
|
5
|
+
module Maglev
|
|
6
|
+
module Adapters
|
|
7
|
+
class RubyLLMAttachmentExtractor < AttachmentExtractor
|
|
8
|
+
def extract(blob, source_name:)
|
|
9
|
+
require "ruby_llm"
|
|
10
|
+
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../embedding_adapter"
|
|
4
|
+
require_relative "../provider_configuration"
|
|
5
|
+
require_relative "ruby_llm_provider"
|
|
6
|
+
|
|
7
|
+
module Maglev
|
|
8
|
+
module Adapters
|
|
9
|
+
class RubyLLMEmbedding < EmbeddingAdapter
|
|
10
|
+
def initialize(provider: Maglev.configuration.embedding_provider, model: nil, dimensions: nil)
|
|
11
|
+
configuration = provider.to_h
|
|
12
|
+
configuration[:model] = model if model
|
|
13
|
+
configuration[:dimensions] = dimensions if dimensions
|
|
14
|
+
@client = RubyLLMProvider.new(ProviderConfiguration.new(**configuration))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def embed(text)
|
|
18
|
+
@client.embed(text)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../configuration"
|
|
4
|
+
require_relative "../generation_adapter"
|
|
5
|
+
require_relative "../provider_configuration"
|
|
6
|
+
require_relative "ruby_llm_provider"
|
|
7
|
+
|
|
8
|
+
module Maglev
|
|
9
|
+
module Adapters
|
|
10
|
+
class RubyLLMGeneration < GenerationAdapter
|
|
11
|
+
def initialize(provider: Maglev.configuration.generation_provider, model: nil)
|
|
12
|
+
configuration = provider.to_h
|
|
13
|
+
configuration[:model] = model if model
|
|
14
|
+
@client = RubyLLMProvider.new(ProviderConfiguration.new(**configuration))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate(prompt)
|
|
18
|
+
@client.generate(prompt)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|