IronClaw 深度剖析(八):混合搜索——pgvector + RRF 的记忆系统
在前面的文章中,我们探讨了 IronClaw 的执行引擎、安全沙箱和工具系统。但一个 AI Agent 的真正智慧,很大程度上取决于它如何存储和检索记忆。本文将深入 IronClaw 的存储与记忆系统,揭开其三阶段混合搜索架构的神秘面纱——从 PostgreSQL 全文搜索到 pgvector 向量索引,再到 RRF 融合算法的精妙配合。
一、为什么需要混合搜索?
在构建 AI 助手的记忆系统时,开发者通常面临一个两难选择:
| 搜索方式 | 优势 | 劣势 |
|---|---|---|
| 关键词搜索 (FTS) | 精确匹配,对专有名词、ID、代码片段效果好 | 无法理解语义,"Python" 和 "蟒蛇" 无法关联 |
| 向量搜索 | 理解语义相似性,支持模糊匹配 | 对精确标识符匹配不稳定,容易丢失关键细节 |
真实案例:用户询问"那个处理 JWT 认证的中间件在哪里?"——关键词搜索能精确命中"JWT"和"中间件",但无法理解"处理"和"认证"的语义关系;向量搜索则相反。IronClaw 的答案是:两者都要,然后用 RRF 智能融合。
二、三阶段混合搜索架构
IronClaw 的搜索系统采用经典的三阶段流水线设计:
flowchart TD
A[用户查询 Query] --> B[阶段一: 全文搜索 FTS]
A --> C[阶段二: 向量搜索 Vector Search]
B -->|按 ts_rank_cd 排序
获取前 pre_fusion_limit 个| D[RRF 融合引擎]
C -->|按 cosine distance 排序
获取前 pre_fusion_limit 个| D
D -->|score = Σ 1/(60 + rank)| E[归一化 & 过滤]
E -->|移除低于 min_score 的结果| F[排序截断]
F -->|取前 limit 个| G[最终结果]
style D fill:#ff6b6b,color:#fff
style B fill:#4ecdc4,color:#000
style C fill:#45b7d1,color:#fff2.1 搜索请求配置
IronClaw 将搜索参数化为一个精心设计的 MemorySearchRequest 结构体:
pub struct MemorySearchRequest {
query: String, // 搜索查询文本
limit: usize, // 最终结果数量 (默认 20, 最大 1000)
pre_fusion_limit: usize, // 每方法预取数量 (默认 50, 最大 5000)
full_text: bool, // 是否启用 FTS
vector: bool, // 是否启用向量搜索
query_embedding: Option<Vec<f32>>, // 查询向量 (向量搜索时必需)
fusion_strategy: FusionStrategy, // Rrf 或 WeightedScore
rrf_k: u32, // RRF 常数 (默认 60)
min_score: f32, // 最小分数阈值
full_text_weight: f32, // FTS 权重
vector_weight: f32, // 向量权重
}这种设计的精妙之处在于解耦:全文搜索和向量搜索可以独立开关,融合策略也可以根据场景切换。
三、全文搜索(FTS):关键词精确匹配
IronClaw 利用 PostgreSQL 的原生全文搜索能力,无需额外的搜索引擎依赖。
3.1 tsvector + GIN 索引
-- memory_chunks 表中的全文搜索列
content_tsv TSVECTOR GENERATED ALWAYS AS (
to_tsvector('english', content)
) STORED;
-- GIN 索引加速匹配
CREATE INDEX idx_memory_chunks_tsv
ON memory_chunks USING GIN(content_tsv);这里使用了 PostgreSQL 的 GENERATED ALWAYS 列,每次 content 更新时 content_tsv 会自动重新生成,无需应用层维护。
3.2 搜索查询
SELECT c.id, d.path, c.content,
ts_rank_cd(c.content_tsv, plainto_tsquery('english', $3)) AS rank
FROM memory_chunks c
JOIN memory_documents d ON d.id = c.document_id
WHERE d.user_id = $1
AND d.agent_id IS NOT DISTINCT FROM $2
AND c.content_tsv @@ plainto_tsquery('english', $3) -- @@ TS 匹配算子
ORDER BY rank DESC
LIMIT $4;关键点解析:
plainto_tsquery将用户输入的原始文本转换为tsquery,自动处理逻辑运算符ts_rank_cd(Cover Density Ranking)比ts_rank更适合短文档的排名IS NOT DISTINCT FROM优雅地处理agent_id为 NULL 的情况(共享文档)
四、向量搜索:语义相似性
4.1 pgvector + HNSW 索引
向量搜索由 pgvector 扩展提供支持,使用 HNSW(Hierarchical Navigable Small World)近似最近邻索引:
-- HNSW 索引:近似最近邻搜索
CREATE INDEX idx_memory_chunks_embedding ON memory_chunks
USING hnsw(embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);| 参数 | 值 | 说明 |
|---|---|---|
m | 16 | 每个节点的最大连接数,控制图密度 |
ef_construction | 64 | 构建时的搜索宽度,影响索引质量 |
vector_cosine_ops | - | 使用余弦相似度算子(适合语义向量) |
4.2 灵活维度支持(V9 迁移)
早期的 IronClaw 将向量维度固定为 1536(OpenAI text-embedding-ada-002 的维度)。V9 迁移将其改为任意维度,支持多种嵌入模型:
-- V9 迁移:从固定维度改为灵活维度
ALTER TABLE memory_chunks
ALTER COLUMN embedding TYPE vector -- 移除 (1536) 维度限制
USING embedding::vector;
-- 支持的嵌入模型对比
-- | 模型 | 维度 |
-- |------|------|
-- | Ollama nomic-embed-text | 768 |
-- | Ollama mxbai-embed-large | 1024 |
-- | OpenAI text-embedding-3-small | 1536 |
-- | OpenAI text-embedding-3-large | 3072 |值得注意的是,由于 HNSW 索引需要固定维度,V9 迁移时删除了原有索引。对于个人助手场景,数据集通常较小,精确搜索的延迟影响可忽略不计。
4.3 向量搜索 SQL
-- 余弦距离搜索 (值越小越相似)
SELECT c.id, d.path, c.content
FROM memory_chunks c
JOIN memory_documents d ON d.id = c.document_id
WHERE d.user_id = $1 AND d.agent_id IS NOT DISTINCT FROM $2
AND c.embedding IS NOT NULL
ORDER BY c.embedding <=> $3 -- <=> 是 pgvector 的余弦距离算子
LIMIT $4;Rust 代码中通过 pgvector::Vector 类型进行绑定:
let query_vector = pgvector::Vector::from(query_embedding.to_vec());
let rows = client.query(
"SELECT ... ORDER BY embedding <=> $3 LIMIT $4",
&[&owner_key, &agent_id, &query_vector, &limit],
).await?;五、RRF 融合算法:取两者之长
5.1 Reciprocal Rank Fusion 原理
RRF 是 Cormack、Clarke 和 Buettcher 在 SIGIR 2009 提出的经典融合算法。其核心洞察是:排名位置比原始相似度分数更可靠——不同搜索方法产生的分数尺度差异巨大,无法直接比较,但排名是无量纲的。
RRF 公式:
score(d) = Σ_m 1 / (k + rank_m(d))其中 k = 60 是平滑常数(可配置),rank_m(d) 是文档 d 在第 m 种搜索方法中的排名。
5.2 RRF 融合代码实现
fn fuse_memory_search_results(
full_text_results: Vec<RankedMemorySearchResult>,
vector_results: Vec<RankedMemorySearchResult>,
request: &MemorySearchRequest,
) -> Vec<MemorySearchResult> {
let mut results = HashMap::<String, ResultAccumulator>::new();
// 处理 FTS 结果
for result in full_text_results {
let score = match request.fusion_strategy() {
FusionStrategy::Rrf => {
1.0 / (request.rrf_k() as f32 + result.rank as f32)
}
FusionStrategy::WeightedScore => {
request.full_text_weight() / result.rank as f32
}
};
results.entry(result.chunk_key)
.and_modify(|existing| {
existing.score += score; // 累加分数
existing.full_text_rank = Some(result.rank);
})
.or_insert(ResultAccumulator {
path: result.path,
snippet: result.snippet,
score,
full_text_rank: Some(result.rank),
vector_rank: None,
});
}
// 处理向量结果 (相同模式)
for result in vector_results { /* ... */ }
// 归一化到 [0, 1]
if let Some(max_score) = fused.iter().map(|r| r.score).reduce(f32::max) {
for result in &mut fused { result.score /= max_score; }
}
// 排序截断
fused.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(Equal));
fused.truncate(request.limit());
fused
}5.3 Weighted Score 备选策略
当需要显式调整两种搜索方法的权重时,系统也支持 Weighted Score 策略:
score(d) = fts_weight * (1/fts_rank) + vector_weight * (1/vector_rank)默认权重各为 0.5,可根据应用场景调整。例如,代码检索场景可提高 FTS 权重到 0.7。
六、数据库 Schema 设计:27 次迁移的演进智慧
IronClaw 的 Schema 经过 27 次迁移迭代,体现了生产系统从简单到复杂的自然演进。
erDiagram
memory_documents ||--o{ memory_chunks : "1:N"
memory_documents ||--o{ memory_document_versions : "1:N"
users ||--o{ api_tokens : "1:N"
users ||--o{ user_identities : "1:N"
memory_documents {
uuid id PK
text user_id FK
uuid agent_id "NULL=共享"
text path
text content
timestamptz created_at
timestamptz updated_at
jsonb metadata
}
memory_chunks {
uuid id PK
uuid document_id FK
int chunk_index
text content
tsvector content_tsv
vector embedding "灵活维度"
timestamptz created_at
}
memory_document_versions {
uuid id PK
uuid document_id FK
int version
text content
text content_hash "SHA-256"
timestamptz created_at
text changed_by
}
users {
text id PK
text email UK
text display_name
text status "active|suspended"
text role "admin|member"
timestamptz created_at
timestamptz last_login_at
jsonb metadata
}
api_tokens {
uuid id PK
text user_id FK
bytea token_hash "SHA-256"
text token_prefix "前8字符"
text name
timestamptz expires_at
timestamptz revoked_at
}
user_identities {
uuid id PK
text user_id FK
text provider "google|github|near"
text provider_user_id
text email
boolean email_verified
jsonb raw_profile
}6.1 核心设计决策
多租户隔离:每个 (user_id, agent_id, path) 三元组唯一。agent_id 为 NULL 时表示跨所有 Agent 共享,这是实现"全局记忆"的关键机制。
版本历史:memory_document_versions 表记录每次修改,支持内容的时光回溯。content_hash 字段用于乐观锁,防止并发更新冲突。
API Token 安全:api_tokens 表从不存储明文 token,只保存 SHA-256 哈希和前 8 个 hex 字符(用于用户识别)。
6.2 迁移演进亮点
| 迁移版本 | 核心变更 |
|---|---|
| V1 | 初始 Schema,引入 pgvector |
| V8 | Settings 键值存储 |
| V9 | 灵活嵌入维度(从固定 1536 改为任意维度) |
| V12 | Token 预算控制 |
| V14 | 用户管理系统 |
| V16 | 文档版本历史 |
| V17 | OAuth 身份关联 |
七、工作区文件系统 API:虚拟路径的力量
IronClaw 使用虚拟路径统一所有存储访问,路径格式如下:
/memory/tenants/{tenant}/users/{user}/agents/{agent}/projects/{project}/{path}7.1 ScopedFilesystem 权限控制
pub struct ScopedFilesystem<F> {
root: Arc<F>, // 根文件系统
mounts: MountView, // 挂载视图 (定义可见范围)
}
// 操作权限检查
fn operation_allowed(
permissions: &MountPermissions,
operation: FilesystemOperation
) -> bool {
match operation {
ReadFile | ListDir | Stat => permissions.read,
WriteFile | AppendFile | Delete | CreateDirAll => permissions.write,
MountLocal => false, // 挂载是特权操作,不允许
}
}7.2 目录列表 PostgreSQL 函数
CREATE OR REPLACE FUNCTION list_workspace_files(
p_user_id TEXT,
p_agent_id UUID,
p_directory TEXT DEFAULT ''
) RETURNS TABLE (
path TEXT,
is_directory BOOLEAN,
updated_at TIMESTAMPTZ,
content_preview TEXT -- 前 200 字符
)这个数据库函数实现了虚拟目录浏览——根据路径前缀匹配文件,自动检测子目录,并返回内容预览。
7.3 内存文档作用域
pub struct MemoryDocumentScope {
tenant_id: String, // 租户
user_id: String, // 用户
agent_id: Option<String>, // Agent (None = 所有 agent 共享)
project_id: Option<String>, // 项目 (None = 未分配)
}八、Secrets 加密存储:AES-256-GCM 的安全实践
IronClaw 的密钥管理遵循零信任原则:原始密钥材料从不暴露在元数据、错误信息或日志中。
8.1 加密架构
flowchart LR
subgraph "加密流程"
A[明文 Secret] --> B[HKDF-SHA256
密钥派生]
S[随机 Salt
32 bytes] --> B
MK[Master Key
环境变量] --> B
B --> DK[派生密钥
32 bytes]
DK --> C[AES-256-GCM
加密]
N[随机 Nonce
12 bytes] --> C
C --> CT[密文 + Tag
16 bytes]
N --> OUT
CT --> OUT
end
OUT["最终存储: nonce || ciphertext
salt 单独存储"] --> DB[(PostgreSQL)]
style B fill:#4ecdc4,color:#000
style C fill:#ff6b6b,color:#fff8.2 加密/解密代码实现
pub struct SecretsCrypto {
master_key: SecretString, // secrecy::SecretString,防意外泄露
}
impl SecretsCrypto {
// 加密:返回 (encrypted_data, salt)
pub fn encrypt(&self, plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), SecretError> {
let salt = Self::generate_salt(); // 32 bytes 随机 salt
let derived_key = self.derive_key(&salt)?; // HKDF-SHA256
let cipher = Aes256Gcm::new_from_slice(&derived_key)?;
let nonce = Aes256Gcm::generate_nonce(&mut OsRng); // 12 bytes
let ciphertext = cipher.encrypt(&nonce, plaintext)?;
// 输出格式: nonce || ciphertext (包含 16 bytes GCM tag)
let mut encrypted = Vec::with_capacity(12 + ciphertext.len());
encrypted.extend_from_slice(&nonce);
encrypted.extend_from_slice(&ciphertext);
Ok((encrypted, salt))
}
// 解密
pub fn decrypt(&self, encrypted: &[u8], salt: &[u8]) -> Result<DecryptedSecret, SecretError> {
let derived_key = self.derive_key(salt)?;
let cipher = Aes256Gcm::new_from_slice(&derived_key)?;
// 分割: nonce (12 bytes) || ciphertext
let (nonce_bytes, ciphertext) = encrypted.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher.decrypt(nonce, ciphertext)?;
DecryptedSecret::from_bytes(plaintext)
}
// HKDF-SHA256 密钥派生
fn derive_key(&self, salt: &[u8]) -> Result<[u8; 32], SecretError> {
let hk = Hkdf::<Sha256>::new(
Some(salt),
self.master_key.expose_secret().as_bytes()
);
let mut derived = [0u8; 32];
hk.expand(b"near-agent-secrets-v1", &mut derived)?;
Ok(derived)
}
}8.3 安全常量
| 常量 | 值 | 用途 |
|---|---|---|
KEY_SIZE | 32 bytes | AES-256 密钥长度 |
NONCE_SIZE | 12 bytes | GCM nonce 长度(96 位,NIST 推荐) |
SALT_SIZE | 32 bytes | HKDF salt 长度 |
TAG_SIZE | 16 bytes | GCM 认证标签 |
| HKDF info | "near-agent-secrets-v1" | 派生上下文字符串,防止跨版本混淆 |
8.4 一次性租赁模式
Secrets 的访问通过租赁模式 (Lease Pattern) 控制:
#[async_trait]
pub trait SecretStore: Send + Sync {
// 存储密钥(返回脱敏元数据,不含密钥值)
async fn put(&self, scope: ResourceScope, handle: SecretHandle,
material: SecretMaterial) -> Result<SecretMetadata, SecretStoreError>;
// 创建一次性租赁
async fn lease_once(&self, scope: &ResourceScope, handle: &SecretHandle)
-> Result<SecretLease, SecretStoreError>;
// 消费租赁(返回密钥材料,租赁标记为 Consumed)
async fn consume(&self, scope: &ResourceScope, lease_id: SecretLeaseId)
-> Result<SecretMaterial, SecretStoreError>;
}租赁只能消费一次,消费后状态变为 Consumed,再次消费会返回 LeaseConsumed 错误。这种设计防止了重放攻击,也确保密钥使用可追溯。
九、身份文件与持久化人格
IronClaw 将 Agent 的"人格"存储为虚拟文件系统中的特殊文档,这些文件在运行时被注入到系统提示中。
9.1 受保护的提示文件
const DEFAULT_PROMPT_PROTECTED_PATHS: &[&str] = &[
"SOUL.md", // 代理核心身份
"AGENTS.md", // 代理配置
"USER.md", // 用户信息
"IDENTITY.md", // 身份定义
"SYSTEM.md", // 系统提示
"MEMORY.md", // 记忆策略
"TOOLS.md", // 工具定义
"HEARTBEAT.md", // 心跳配置
"BOOTSTRAP.md", // 启动配置
"context/assistant-directives.md",
"context/profile.json",
];9.2 提示注入防护
写入受保护文件时,系统通过 Sanitizer 检测内容中的提示注入攻击:
async fn check_write(&self, request: PromptWriteSafetyRequest<'_>)
-> Result<PromptWriteSafetyDecision, PromptWriteSafetyError> {
// 使用 Sanitizer 检测提示注入
let warnings = self.sanitizer.detect(request.content);
let max_severity = warnings.iter().map(|w| w.severity).max();
match max_severity {
Some(Critical) => Ok(PromptWriteSafetyDecision::Reject { ... }),
Some(High) => Ok(PromptWriteSafetyDecision::Reject { ... }),
Some(_) => Ok(PromptWriteSafetyDecision::Warn { ... }),
None => Ok(PromptWriteSafetyDecision::Allow),
}
}按严重程度分级响应:Critical 和 High 级别直接拒绝写入,Medium/Low 级别允许但记录警告。这种设计确保 Agent 的"人格"不会被恶意内容篡改。
9.3 .config 元数据继承
.config 文件支持目录级元数据继承——子目录自动继承父目录的 .config 设置。这使得可以为整个项目空间统一配置清理策略、JSON Schema 验证规则等。
十、资源配额系统:精细化的成本控制
10.1 六级账户层级
tree
root(("资源配额层级"))
Tenant["Tenant
租户级"]
User["User
用户级"]
Project["Project
项目级"]
Agent["Agent
代理级"]
Mission["Mission
任务级"]
Thread["Thread
线程级"]
Tenant --> User
User --> Project
Project --> Agent
Agent --> Mission
Mission --> Thread限制从顶层到底层全部适用,深层限制不覆盖浅层限制。这意味着一个 Agent 的开销同时受到它所属 Project、User 和 Tenant 的限制。
10.2 五维资源限制
pub enum ResourceDimension {
Cost, // 货币成本(如 USD)
Tokens, // LLM Token 数量
Time, // 执行时间(秒)
Sandboxes, // WASM 沙箱实例数
Fuel, // WASM 燃料(指令计数)
}10.3 预留-执行-对账协议
IronClaw 采用类似分布式事务的三阶段协议管理资源:
1. RESERVE: 预估资源需求 → 预留 → 获得 ReservationId
(如果任何层级配额不足,立即失败)
2. EXECUTE: 执行实际工作(LLM 调用、沙箱运行等)
(资源已预留,不会中途被拒绝)
3. RECONCILE: 对账实际用量 vs 预估预留
- 实际 < 预估:释放多余预留
- 实际 > 预估:追加扣除
RELEASE: 工作取消,释放全部预留pub trait ResourceGovernor: Send + Sync {
// 预留估计资源
fn reserve(&self, scope: &ResourceScope, estimate: &ResourceEstimate)
-> Result<ResourceReservation, ResourceError>;
// 对账实际用量
fn reconcile(&self, reservation_id: ResourceReservationId, usage: ResourceUsage)
-> Result<ResourceReceipt, ResourceError>;
// 取消预留
fn release(&self, reservation_id: ResourceReservationId) -> Result<(), ResourceError>;
}这种设计的核心优势在于可预测性:工作执行前就能确定资源是否充足,避免执行到一半因配额耗尽而失败。
总结
IronClaw 的存储与记忆系统展现了一个生产级 AI 助手框架应有的完整存储架构。从三阶段混合搜索(FTS + Vector + RRF)到 AES-256-GCM 加密,从虚拟文件系统到六级资源配额,每个设计决策都体现了对安全、性能和可扩展性的深思熟虑。
| 特性 | 实现技术 | 设计目标 |
|---|---|---|
| 混合搜索 | PostgreSQL FTS + pgvector HNSW + RRF | 语义+关键词双重检索 |
| 向量索引 | HNSW (m=16, ef_construction=64) | 近似最近邻,毫秒级响应 |
| 密钥安全 | AES-256-GCM + HKDF-SHA256 | 零信任加密 |
| 密钥访问 | 一次性租赁模式 | 防重放、可追溯 |
| 人格保护 | Sanitizer 提示注入检测 | 防人格篡改 |
| 资源控制 | 六级层级 + 预留-对账协议 | 精细化成本控制 |
| 部署灵活性 | PostgreSQL / libSQL 双后端 | 云端到本地的全覆盖 |
整个系统遵循零信任安全原则:每个操作都经过作用域验证、权限检查和审计记录。无论是搜索记忆、读取文件还是消费密钥,IronClaw 都在确保 Agent 的智慧既有深度,又有边界。