Zilliz公众号文章

从语义检索到快照恢复:PilotDeck 接入 Milvus 3.0实战

传统的 AI Agent 记忆,通常是把每轮对话转成向量塞进数据库,提问时再按余弦相似度捞出前几条。

Zilliz

公众号 · 15 篇文章

图片传统的 AI Agent 记忆,通常是把每轮对话转成向量塞进数据库,提问时再按余弦相似度捞出前几条。
这种黑盒做法跑久了有两个麻烦:一是多项目并行时,不同任务的上下文容易混杂;二是如果 AI 记错了参数,或者在后台自动整理记忆时漏了关键约束,用户既看不到底层存了什么,也没法一键退回。
最近开源的PilotDeck提供了一套白盒记忆(White-box Memory)方案:按工作空间(WorkSpace)隔离记忆,支持人工修改和置顶(Pin);系统空闲时还会启动Dream Mode,自动把零散的短期记录提炼合并为长期记忆,并支持随时回滚。
不过,它默认是用本地 Markdown 文件(FileMemoryStore)存记忆条目,靠拷贝整个 last_dream/ 目录来做回滚,底层并没有建向量索引。
为了让这套白盒记忆具备大规模向量检索能力,同时保留置顶排序、自动过期和快速回滚,我们将它的记忆结构迁移到了Milvus 3.0(服务端 3.0.1,PyMilvus 3.0.2),跑通了一套完整的读写与快照回滚链路。
以下是实验细节与具体实现。

01 

PilotDeck 的白盒记忆结构与 Milvus 3.0 映射

在PilotDeck 中,用户可以通过 YAML frontmatter 记录 project_id、scope、updated_at、dream_updated_at 等元数据,并结合 SQLite 管理后台流水线状态,并直接查看、修改和恢复 Agent 的记忆。
这套设计已经能支持工作空间隔离、记忆管理和 Dream Mode 回滚。但当 Agent 积累了越来越多历史信息时,一个新的问题出现了:面对一个新任务,Agent 能否找到那些没有明显关键词重合、但实际相关的历史记忆?
例如,用户之前要求“所有图表必须保留数据来源”,后来提出“帮我做一份季度营收分析”。即使新任务没有提到“数据来源”,Agent 也应该能找到并遵守之前的要求。
也是因此,我们尝试为PilotDeck引入 Milvus 3.0:为记忆补充向量检索,同时利用数据库的过期处理、排序和快照能力,减少需要单独编写的应用层管理逻辑。
本次实验重点验证这套存储方案的可行性,具体包括四项:
  1. 工作空间过滤与长文本存储:根据 WorkSpace 限定检索范围,同时保存记忆内容及其元数据。
  2. Entity TTL:为临时记忆设置过期时间,测试能否自动清理,同时保留长期记忆。
  3. 服务端排序:验证置顶状态、置信度和更新时间能否直接参与查询排序。
  4. Snapshots:在 Dream Mode 合并记忆前创建快照,测试合并出错后能否恢复先前的记忆状态。
下面通过 Milvus 3.0.1 的实际读写、排序、过期和快照恢复实验,验证这套存储方案的可行性。

02 

建表与写入:工作空间隔离与 Entity TTL 自动过期

本次实验环境使用 Milvus 3.0.1 与 PyMilvus 3.0.2,向量维度设为 1024 维,索引使用 HNSW(COSINE 距离)。
创建集合时,我们将工作空间标识设为 Partition Key,并通过 DataType.TIMESTAMPTZ 配合集合属性 properties={"ttl_field": "expire_at"} 开启 Milvus 3.0 的记录级过期(Entity TTL):
from pymilvus import DataType, MilvusClientclient = MilvusClient(uri="http://192.168.1.1:19531")schema = client.create_schema(auto_id=False)schema.add_field("mem_id", DataType.VARCHAR, max_length=64, is_primary=True)schema.add_field(    "workspace_id", DataType.VARCHAR, max_length=64, is_partition_key=True)schema.add_field("is_pinned", DataType.INT8)  # 1 代表置顶,0 代表普通schema.add_field("confidence", DataType.FLOAT)schema.add_field("updated_at", DataType.INT64)schema.add_field("expire_at", DataType.TIMESTAMPTZ, nullable=True)schema.add_field("content", DataType.VARCHAR, max_length=65535)schema.add_field("embedding", DataType.FLOAT_VECTOR, dim=1024)index_params = client.prepare_index_params()index_params.add_index(    field_name="embedding",    index_type="HNSW",    metric_type="COSINE",    params={"M": 16, "efConstruction": 200},)client.create_collection(    collection_name="pilot_mem_v1",    schema=schema,    index_params=index_params,    properties={"ttl_field": "expire_at"},)client.create_alias(collection_name="pilot_mem_v1", alias="pilot_mem_active")
这里有两个实测中需要注意的建表细节:
1.is_pinned使用INT8而非BOOL。
因为后续服务端 order_by 排序目前只支持数值型(INT8/16/32/64、FLOAT、DOUBLE)和 VARCHAR,用 1 和 0 表示置顶状态可以直接参与服务端排序。
2.长文本字段的存储选择。
单条记忆在 64KB 以内时(例如本次测试写入的 PilotDeck 完整 README.md,共 23,225 字节),直接使用 DataType.VARCHAR(max_length=65535) 即可完整读写。如果业务中单条文档超过 64KB,可以在 Milvus 3.0 服务端配置中开启 StorageV3(common.storage.useLoonFFI=true),再将该字段换为 DataType.TEXT(TEXT LOB);未开启该配置时直接建 TEXT 字段会被服务端拒绝。
建好表并绑定 pilot_mem_active 别名后,我们向 ws_chip_report 工作空间写入 3 条测试数据:一条置顶规范(mem_pin_01)、一条 23KB 的长文档(mem_norm_02),以及一条设置了 4 秒后过期的临时对话记录(mem_ttl_temp_03):
rows = [    {        "mem_id": "mem_pin_01",        "workspace_id": "ws_chip_report",        "is_pinned": 1,        "confidence": 0.98,        "updated_at": 1760774400,        "expire_at": None,  # 传 None (NULL) 代表永不过期        "content": "数据图表统一使用深灰配色,保留原始出处链接。",        "embedding": embed("数据图表统一使用深灰配色..."),    },    {        "mem_id": "mem_norm_02",        "workspace_id": "ws_chip_report",        "is_pinned": 0,        "confidence": 0.92,        "updated_at": 1760774600,        "expire_at": None,        "content": readme_long_doc,  # 23,225 字节的 Markdown 文档        "embedding": embed(readme_long_doc),    },    {        "mem_id": "mem_ttl_temp_03",        "workspace_id": "ws_chip_report",        "is_pinned": 0,        "confidence": 0.65,        "updated_at": 1760774700,        "expire_at": "2026-10-18T09:41:11Z",  # 写入后 4 秒过期        "content": "临时对话片段:用户正在调试本地端口 3001。",        "embedding": embed("临时对话片段..."),    },]client.insert(collection_name="pilot_mem_active", data=rows)
写入后立即查询 ws_chip_report,返回 ["mem_norm_02", "mem_pin_01", "mem_ttl_temp_03"] 共 3 条记录;等待 6 秒后再次执行相同的 query,返回结果变为 ["mem_norm_02", "mem_pin_01"]。expire_at 为 None 的两条长期记忆正常保留,到期的临时记忆已被自动屏蔽,无需外部定时脚本介入。

03 

白盒面板与检索:服务端 order_by 与分组统计

为了测试多字段排序,我们继续往 ws_chip_report 写入 20 条讨论碎片(mem_frag_001 至 mem_frag_020,其中把 mem_frag_015 设为置顶 is_pinned=1)。
当用户打开 PilotDeck 的白盒面板查看当前工作空间的记忆时,直接在 query 中传入 order_by:
panel_list = client.query(    collection_name="pilot_mem_active",    filter='workspace_id == "ws_chip_report"',    output_fields=["mem_id", "is_pinned", "confidence", "updated_at"],    order_by=["is_pinned:desc", "confidence:desc", "updated_at:desc"],    limit=5,    offset=0,)
实测返回的 Top 5 结果依次为:
mem_pin_01(is_pinned=1, confidence=0.98)mem_frag_015(is_pinned=1, confidence=0.70)mem_norm_02(is_pinned=0, confidence=0.92)mem_frag_019(is_pinned=0, confidence=0.90, updated_at=1760775019)mem_frag_014(is_pinned=0, confidence=0.90, updated_at=1760775014)
可以看到,两条置顶记忆优先排在最前;未置顶的记录先按置信度降序,置信度相同时(0.90)再按更新时间从新到旧排列,完全在服务端一次性完成。
在向量搜索(search)路径上,写法与 query 略有不同,需要通过 order_by_fields 传入字典列表:
search_hits = client.search(    collection_name="pilot_mem_active",    data=[query_vector],    filter='workspace_id == "ws_chip_report" and confidence >= 0.6',    limit=5,    order_by_fields=[        {"field": "is_pinned", "order": "desc"},        {"field": "updated_at", "order": "desc"},    ],    output_fields=["mem_id", "is_pinned", "confidence", "updated_at"],)
这里要注意 search 叠加排序的机制边界:它只对 ANN 向量召回的 Top-K 候选集做二次重排,而不是全表排序。实测中,向量相近且置顶的 mem_pin_01 排在第一位,随后是语义相关的 mem_frag_017/016/013/012(按更新时间降序);而同样置顶但向量距离较远的 mem_frag_015 不会强行挤进语义搜索结果里。
Milvus 检索机制示意:query 对工作空间内的匹配记录排序;search 先做 ANN 召回,再对候选重排,置顶记录也须先进入候选集。
如果需要在侧边栏统计各个工作空间当前的有效记忆条数,可以在 query 中使用 group_by_fields(注意在 PyMilvus 3.0.2 中必须传复数形式 group_by_fields 列表,传单数 group_by_field 会报错):
counts = client.query(    collection_name="pilot_mem_active",    filter="confidence > 0",    group_by_fields=["workspace_id"],    output_fields=["workspace_id", "count(*)"],)# 实测输出: [{'workspace_id': 'ws_chip_report', 'count(*)': 22}, {'workspace_id': 'ws_podcast_ops', 'count(*)': 1}]

04 

Dream Mode 整合与快照回滚实测

接下来验证最关键的环节:当 PilotDeck 在后台启动 Dream Mode 合并记忆并删掉旧碎片时,如果模型归纳出错,如何通过 Milvus 3.0 的 Snapshots 快速回滚?
为了贴近真实负载,我们先往集合里额外写入 10,000 条 1024 维背景记忆向量(使集合总规模达到 10,023 条)。
在启动 Dream Mode 之前,先调用 flush 与 create_snapshot 打下快照:
client.flush(collection_name="pilot_mem_active")client.create_snapshot(    collection_name="pilot_mem_active",    snapshot_name="snap_ws_chip_1792316487",    description="Safety point before Dream Mode compaction",)
实测显示,1 万条规模下 flush 耗时 1,746.84 ms,而create_snapshot本身仅耗时约102.5 ms。查看 describe_snapshot 返回的元数据可以看到,快照只是在 MinIO/S3 上生成了一份指向 pilot_mem_v1 现有 16 个分区的元数据 JSON,没有拷贝底层向量文件。
随后我们模拟一次“翻车的 Dream Mode 合并”:写入 1 条错误的归纳记忆(mem_dream_merged_bad),并将原有的 20 条过程碎片(mem_frag_001 ~ 020)全部删除:
client.insert(collection_name="pilot_mem_active", data=[bad_merged_row])client.delete(    collection_name="pilot_mem_active",    ids=[f"mem_frag_{i:03d}" for i in range(1, 21)],)
此时查询 ws_chip_report 工作空间,记忆条数从 22 条骤降到3 条。
现在执行“一键回滚”。在 PyMilvus 3.0.2 中,restore_snapshot 不支持原地覆盖原表,必须同时指定 source_collection_name 和全新的 target_collection_name。因此,完整的无感回滚流程是“将快照异步恢复到 pilot_mem_v2→加载新集合 →将 pilot_mem_active 别名切过去”:
import time# 1. 将快照从原表恢复至新表 pilot_mem_v2job_id = client.restore_snapshot(    snapshot_name="snap_ws_chip_1792316487",    source_collection_name="pilot_mem_v1",    target_collection_name="pilot_mem_v2",)# 2. 轮询恢复状态(Pending -> Executing -> Completed)while True:    state = client.get_restore_snapshot_state(job_id=job_id)    if state.state == "RestoreSnapshotCompleted":        break    if state.state == "RestoreSnapshotFailed":        raise RuntimeError(state.reason)    time.sleep(0.05)# 3. 加载新表并原子切换业务别名client.load_collection(collection_name="pilot_mem_v2")client.alter_alias(collection_name="pilot_mem_v2", alias="pilot_mem_active")
本文实现的恢复流程:集合级快照恢复到 v2,加载后将业务别名切向 v2;原 v1 不会被原地覆盖。
快照回滚 vs. 传统重导重建索引:实测结果对比
回滚完成后,我们再次通过 pilot_mem_active 查询 ws_chip_report 工作空间,22 条原始记忆(含 23,225 字节的长文档)全部找回,那条错误的归纳记忆mem_dream_merged_bad完全消失,集合总行数恢复为 10,023 条。
同时,我们在同一台机器上测试了传统的恢复方式(新建集合 $\rightarro$ 重新写入这 10,000 条向量 $\rightarro$flush$\rightarro$ 重新构建 HNSW 索引 $\rightarro$load),两组实测耗时如下:
仅在 1 万条 1024 维向量的小规模测试下,Snapshot 回滚就已经比重新导入建索引快了2.72 倍。
原因在于 Milvus 3.0 的 restore_snapshot 采用的是 copy-segment 机制:它直接复用快照锁定的数据段与已经建好的 HNSW 索引文件,跳过了最消耗 CPU 的向量距离计算与建图过程。当记忆库规模从 1 万条增长到百万条(约 4.10 GB 原始向量)时,传统重建 HNSW 索引的耗时呈非线性增长,而复用现有索引的 Snapshot 恢复优势还会进一步放大。

写在最后

通过这次实验可以看到,把 PilotDeck 这类白盒记忆系统迁入 Milvus 3.0 后,原本需要靠本地 Markdown 文件目录备份、外部定时清理脚本和应用层内存排序的逻辑,都可以收敛进同一个集合中:
  • 用 Partition Key 隔离不同工作空间;
  • 用 TIMESTAMPTZ + ttl_field 自动淘汰短期碎片记忆;
  • 用 order_by 与 group_by_fields 在服务端直接完成置顶排序和工作空间统计;
  • 用 create_snapshot、restore_snapshot 配合 alter_alias,在几秒钟内无损回退错误的后台记忆合并。
对于需要长期运行、允许人类随时干预和回滚状态的 AI Agent 来说,Milvus 3.0 已经不再只是一个单纯算余弦相似度的召回组件,而是可以直接承接 Agent 记忆状态机的存储底座。

作者介绍

图片

Zilliz黄金写手:尹珉

阅读推荐
Milvus 3.0 Embedding模型升级教程:从向量回填到灰度切流
为什么我们放弃了Kafka、Pulsar,让WoodPecker承担Milvus3.0的写入工作
Aquila语义去重精读:训练数据里的语义重复,白白烧掉了多少 GPU 时?
从条件匹配到相似检索:同程旅行慧行搜索的火车换乘方案向量化实践
AI 科研平台Consensus,如何基于Zilliz Cloud构建4 亿文献的学术检索系统
图片
图片

前往微信阅读全文

内容来自公众号,可前往微信查看原文。

查看作者的更多文章 →订阅更新、RSS 与阅读指南 →