混合搜索——pgvector + RRF 的记忆系统

📑 目录

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:#fff

2.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);
参数说明
m16每个节点的最大连接数,控制图密度
ef_construction64构建时的搜索宽度,影响索引质量
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
V8Settings 键值存储
V9灵活嵌入维度(从固定 1536 改为任意维度)
V12Token 预算控制
V14用户管理系统
V16文档版本历史
V17OAuth 身份关联

七、工作区文件系统 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:#fff

8.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_SIZE32 bytesAES-256 密钥长度
NONCE_SIZE12 bytesGCM nonce 长度(96 位,NIST 推荐)
SALT_SIZE32 bytesHKDF salt 长度
TAG_SIZE16 bytesGCM 认证标签
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 的智慧既有深度,又有边界。