Memory Recall:个人记忆召回是什么、如何实现
一句话定义:Memory Recall 是多路召回中专门检索「用户个人动态记忆」的那一路——按用户隔离、按语义/关键词匹配偏好与历史事实,再与知识库召回结果融合后注入 LLM。
前置阅读:01-记忆系统(三层记忆与通用召回)、RAG 核心概念与原理(多路召回框架)。
1. Memory Recall 是什么
在完整检索链路里,召回层往往不止「搜知识库」一条路——这就是 多路召回 (详见 §2)。Memory Recall 是其中一路,专门回答这类问题:
| 用户说法 | 答案实际在哪 |
|---|---|
| 「按我上次说的风格写个接口」 | 张三偏好 Go+Gin,李四偏好 Java+Spring |
| 「我上次提到的那个项目怎么部署?」 | 用户历史会话里抽出的项目事实 |
| 「推荐个午餐」 | 「对花生过敏」这类个人健康偏好 |
知识库里可能有《部署手册》《过敏原科普》,但 「这个用户」的偏好与经历不在共享文档里 。Memory Recall 从「按用户隔离的记忆库」里捞这些内容。
1.1 和相近概念的区别
| 概念 | 检索对象 | 是否按用户隔离 | 典型生命周期 |
|---|---|---|---|
| Knowledge Recall | 企业文档、手册、Wiki | 否(租户/权限过滤) | 文档更新时变更 |
| Memory Recall | 偏好、事实、经历、决策 | 是 (user_id 强隔离) | 可纠错、可过期、可冲突 |
| Conversation Recall | 当前会话窗口 / 近期对话 | 是(session 级) | 会话结束或摘要后衰减 |
| Working Memory | 本任务 scratchpad | 是(task 级) | 任务结束即丢 |
一句话: Knowledge 答「世界上/公司里有什么」;Memory 答「这个人怎么样、经历过什么」。
1.2 为什么不能直接复用知识库 RAG
两边都用 Embedding + ANN + BM25,但记忆多出三类工程难题(见 RAG 文 1.1.2):
- 主体归属 :每条记忆必须挂
user_id(及可选org_id/agent_id),检索时强制过滤,防止串号。 - 冲突判定 :用户说「我现在可以吃花生了」,旧记忆「花生过敏」不能继续高分召回。
- 生命周期 :长期偏好 vs「最近在学 Rust」这类短期状态,需要过期、衰减或显式失效。
不做这三点,向量召回再准,也会「记错人、记旧账」。
2. 多路召回:有哪些路
2.1 为什么要多路
单路召回各有盲区:只靠向量会漏专有名词;只靠关键词不懂同义改写;只搜知识库答不出「我上次说的那个」。多路的价值是 互补 —— 各路并行粗筛,再融合去重,交给 Rerank / Prompt。
「路」其实有两层含义,别混:
| 层级 | 问的是 | 例子 |
|---|---|---|
| 数据源层 | 答案在哪个库/系统里? | 知识库、个人记忆、对话、SQL、图谱、Web… |
| 检索算法层 | 同一个库里怎么搜? | ANN、BM25、元数据过滤、时间排序… |
Agent 生产系统通常两层都做:先选对数据源,每个源内部再多算法召回。
2.2 数据源层:常见召回路一览
flowchart TD
A[Query Rewrite] --> B[Metadata Filter]
B --> R[Recall 多路并行]
R --> K[Knowledge Recall 知识库]
R --> M[Memory Recall 个人记忆]
R --> C[Conversation Recall]
R --> G[Graph Recall]
R --> S[SQL Recall]
R --> W[Web Search]
R --> T[Tool Recall]
K --> K1[ANN 语义]
K --> K2[BM25 关键词]
M --> M1[ANN / BM25 / 类型与时间]
K1 --> F[融合 RRF 等]
K2 --> F
M1 --> F
C --> F
G --> F
S --> F
W --> F
T --> F
F --> RR[Rerank]
RR --> P[注入 Prompt]
P --> L[LLM]
Memory Recall 为本文主角;Knowledge 内部常见 ANN + BM25 两路。
| 召回路 | 搜什么 | 典型问题 | 擅长 | 弱点 |
|---|---|---|---|---|
| Knowledge / ANN | 共享文档的语义近邻 | 「退款规则是什么」 | 同义改写、意思相近 | ID、编号、冷门专名 |
| Knowledge / BM25 | 文档关键词命中 | 「错误码 E-2049」 | 精确词、编号 | 换词就漏 |
| Memory Recall | 该用户的偏好/事实/经历 | 「按我的习惯写接口」 | 千人千面、跨会话 | 需隔离、冲突、遗忘 |
| Conversation Recall | 当前或近期对话回合 | 「还是卡」「上面那个」 | 指代消解、短时上下文 | 会话外无效 |
| Graph Recall | 实体与边(人-项目-服务) | 「支付服务依赖哪些中间件」 | 多跳关联 | 图谱建设成本高 |
| SQL Recall | 表字段精确条件 | 「我名下未完成的工单」 | 精确、可审计 | 自然语言→SQL 易错 |
| Web Search | 公开网页/新闻 | 「今天某某 API 挂了吗」 | 实时性 | 噪音大、需摘录 |
| Tool Recall | 任意工具返回值 | 「查一下当前库存」 | 活数据、业务系统 | 延迟与权限 |
没有固定「必须开全套」。客服助手常开 Knowledge + Memory + Conversation;数据分析 Agent 加重 SQL;运维排障常加 Graph / 日志 Tool。
2.3 检索算法层:同一库里怎么多路
即便只做一个 Memory Store / 向量库,内部也常多路:
flowchart TD
Q["Query(已带 user 过滤)"] --> ANN[ANN 向量召回]
Q --> BM25[BM25 关键词召回]
Q --> META[元数据召回 同 type]
Q --> TIME[时间/重要度召回]
ANN --> TOP1[Top-20]
BM25 --> TOP2[Top-20]
META --> TOP3[Top-20]
TIME --> TOP4[Top-20]
TOP1 --> RRF[RRF 合并去重]
TOP2 --> RRF
TOP3 --> RRF
TOP4 --> RRF
RRF --> CAND[候选集]
CAND --> RR[Rerank 可选]
RR --> OUT[Top-K]
| 算法路 | 擅长抓什么 | 弱点 |
|---|---|---|
| ANN(向量) | 「忌口」≈「过敏」这类语义关联 | 订单号、trace ID、冷门专名 |
| BM25 | 「花生」「payment-svc」字面命中 | 不懂同义改写 |
| 元数据 | 按 type=preference 收窄 |
无语义,依赖打标质量 |
| 时间/重要度 | 「上次」「最近」类指代 | 与语义无关,需与别路融合 |
Memory Recall 实现里, 至少 ANN + BM25 ;再加时间路,对「上次那个」类问题很管用。细节与代码见 §5.2。
2.4 怎么选路、怎么融合
选型口诀 :问题答案在哪一类数据里,就开哪一路;单路明显漏再补互补路。
| 场景 | 建议组合 |
|---|---|
| 企业知识问答 | Knowledge:ANN + BM25 |
| 个人助理 / 千人千面 | Memory(ANN+BM25+时间)+ Conversation |
| 「我的订单/余额」 | SQL(主)+ Memory(辅,记偏好) |
| 「A 服务依赖谁」 | Graph + Knowledge |
| 「今天外面是否故障」 | Web / 状态页 Tool + Knowledge |
各路分数尺度不同(余弦 0~1 vs BM25 动辄十几), 不要直接加分 。常见做法:
- 各路取 Top-N → RRF(倒数排名融合) 合并(原理见 RAG 专文 §6.5)
- 或 分槽注入 :
[Memory]/[Knowledge]/[History]分区写进 Prompt,个人约束不与通用文档抢同一排名 - 候选集再可选 Rerank 精排
2.5 和 Memory Recall 的关系
- 对外 :Memory Recall 是数据源层的一路,与 Knowledge、Conversation 等并列。
- 对内 :Memory Recall 自己再用 ANN ∥ BM25 ∥ 时间等多算法路。
- 用户问「我上次提到的那个 Go 项目怎么部署?」——知识库可能有通用部署文档,但「哪个项目、什么偏好」往往要靠 Memory + Conversation ;部署步骤再靠 Knowledge。三路一起,才答得完整。
3. 记什么:记忆类型与数据结构
3.1 常见记忆类型
| 类型 | 例子 | 召回触发 |
|---|---|---|
| 偏好 Preference | 代码风格、口味、沟通语气 | 「帮我写…」「推荐…」 |
| 事实 Fact | 过敏、职级、常用项目名 | 涉及约束/身份的问题 |
| 经历 Episode | 「上周那个支付接口故障」 | 「上次那个…」指代消解 |
| 决策 Decision | 「已决定用 Redis 做缓存」 | 后续方案讨论 |
| 指令 Instruction | 「以后回复用中文、少废话」 | 几乎每轮(可常驻注入) |
3.2 建议的记忆条目结构
{
"id": "mem_01hxyz",
"user_id": "u_123",
"text": "用户对花生过敏",
"embedding": [0.12, -0.34, "..."],
"type": "preference",
"status": "active",
"confidence": 0.95,
"importance": 0.9,
"source": "user_utterance",
"created_at": "2026-06-28T10:00:00Z",
"updated_at": "2026-06-28T10:00:00Z",
"expires_at": null,
"supersedes": null,
"keywords": ["花生", "过敏", "忌口"]
}
| 字段 | 作用 |
|---|---|
user_id |
强制过滤 ,Memory Recall 的第一道闸 |
status |
active / superseded / expired,冲突与遗忘 |
importance + 时间 |
排序时做衰减:score' = score × decay(age) × importance |
supersedes |
指向被替代的旧记忆 id,便于审计 |
keywords |
供 BM25 / 精确词匹配,补向量盲区 |
4. 端到端流程:写入 → 召回 → 注入 → 更新
写入路径
flowchart TD
A[用户对话 / 工具结果] --> B[LLM 抽取候选记忆]
B --> C[冲突检测]
C --> D[upsert 新建/覆盖/superseded]
D --> E[同步写向量索引 + 关键词索引]
召回路径(Memory Recall)
flowchart TD
Q[当前 Query] --> RW[Query Rewrite 可选]
RW --> F["强制 filter: user_id + status=active"]
F --> MULTI[多路 ANN / BM25 / 近期·高重要度]
MULTI --> RRF[RRF 融合]
RRF --> RR[Rerank 可选]
RR --> INJ["Top-K 注入 Prompt 的 Memory 区"]
与知识库 RAG 的对称关系:
| 环节 | 知识库 | Memory Recall |
|---|---|---|
| 离线/写入 | 文档切块 Embedding | 对话中抽取事实 Embedding |
| 过滤 | tenant / 权限 / 文档类型 | user_id / status / type |
| 召回 | ANN + BM25 | 同左,再加时间/重要度路 |
| 融合 | RRF | RRF(可与知识库结果再融一层) |
| 特有 | Chunk 策略 | 冲突仲裁、遗忘、主体隔离 |
向量相似度、HNSW、RRF、Rerank 的原理见 01-记忆系统 §6.6–6.7 与 RAG 专文,本文不重复展开。
5. 如何实现
5.1 最小可用架构
flowchart TD
API[Agent / Chat API] --> MS[Memory Service]
MS --> VS[Vector Store]
MS --> KW[Keyword / BM25]
MS --> DB[Metadata DB]
Memory Service 负责 extract / recall / resolve / forget;Vector Store 按 user 分区或硬过滤;Metadata DB 管 status / 冲突 / expires。
选型参考:
- 向量:pgvector / Milvus / Qdrant / Chroma( 查询必须带 user_id filter )
- 关键词:同一库的全文检索,或 ES / meilisearch
- 元数据与冲突状态:Postgres 即可;小规模可全放向量库 payload
5.2 召回实现(核心代码骨架)
from dataclasses import dataclass
from typing import List, Optional
import time
@dataclass
class MemoryHit:
id: str
text: str
score: float
type: str
meta: dict
class MemoryRecall:
def __init__(self, embedder, vector_db, bm25_index, reranker=None):
self.embedder = embedder
self.vector_db = vector_db
self.bm25 = bm25_index
self.reranker = reranker
def recall(
self,
user_id: str,
query: str,
*,
top_k: int = 5,
candidate_k: int = 20,
types: Optional[List[str]] = None,
) -> List[MemoryHit]:
# 1) 可选:Query Rewrite,把「那个项目」扩成可检索表述
queries = rewrite_queries(query) # 至少含原 query
base_filter = {
"user_id": user_id,
"status": "active",
}
if types:
base_filter["type"] = {"$in": types}
# 2) 多路召回
ann_hits, bm25_hits, recent_hits = [], [], []
for q in queries:
q_vec = self.embedder.encode(q)
ann_hits.extend(
self.vector_db.search(
q_vec, top_k=candidate_k, filter=base_filter
)
)
bm25_hits.extend(
self.bm25.search(q, top_k=candidate_k, filter=base_filter)
)
# 近期 / 高重要度兜底(指代「上次」「最近」时很有用)
recent_hits = self.vector_db.search(
filter={**base_filter},
order_by="updated_at",
limit=candidate_k // 2,
)
# 3) RRF 融合(不拼原始分数,拼排名)
fused = reciprocal_rank_fusion(
[ann_hits, bm25_hits, recent_hits], k=60
)
# 4) 时间 × 重要度衰减
now = time.time()
for h in fused:
age_days = max(0, (now - h.meta["updated_at"]) / 86400)
decay = 0.5 ** (age_days / h.meta.get("half_life_days", 90))
h.score *= decay * h.meta.get("importance", 0.5)
fused.sort(key=lambda x: x.score, reverse=True)
# 5) 可选精排
candidates = fused[:candidate_k]
if self.reranker:
candidates = self.reranker.rank(query, candidates)
return candidates[:top_k]
硬约束 :任何搜索 API 都默认带上 user_id;漏过滤等于记忆串号,属安全事故。
5.3 写入与冲突判定
def ingest_memory(user_id: str, utterance: str, reply: str, store, llm):
# 1. 抽取:是否值得记、类型、规范化文本
candidates = llm.extract_memories(utterance, reply)
# → [{"text": "用户对花生过敏", "type": "preference", "conf": 0.95}]
for c in candidates:
if c["conf"] < 0.7:
continue
# 2. 在同用户 active 记忆里找语义近邻,判断冲突/重复
near = store.recall(user_id, c["text"], top_k=5)
decision = llm.resolve_conflict(c, near)
# decision.action: ignore | create | update | supersede
if decision.action == "ignore":
continue
if decision.action == "supersede":
store.mark_superseded(decision.old_id, reason=c["text"])
if decision.action in ("create", "update", "supersede"):
store.upsert(
user_id=user_id,
text=c["text"],
type=c["type"],
confidence=c["conf"],
importance=c.get("importance", 0.5),
keywords=c.get("keywords", []),
)
冲突仲裁示例:
| 旧记忆 | 新信息 | 策略 |
|---|---|---|
| 花生过敏 | 「我现在可以吃花生了」 | supersede 旧条,写新条 |
| 喜欢川菜 | 「今天想吃粤菜」 | 不覆盖长期偏好;短期意图放会话/工作记忆 |
| 用 Go 写接口 | 「这个需求用 Python」 | 范围限定:全局偏好 vs 单次任务,写入时带 scope |
5.4 注入 Prompt
[System] 你是个人助理……
[Memory] (仅当前用户、已召回的 Top-K)
- [preference] 用户对花生过敏 (2026-06-28, conf=0.95)
- [fact] 当前主力项目为 payment-svc (2026-07-01)
[History] 最近 N 轮对话
[User] 推荐个午餐
实践建议:
- Memory 放在 System 之后、History 之前,当作「背景约束」。
- 条数严格控制(常见 3–8 条);指令型记忆可常驻,事实型按需召回。
- 展示时带类型与时间,方便模型判断是否仍适用。
5.5 与知识库多路融合
当 Agent 同时查文档和个人记忆时:
flowchart TD
Q[Query] --> K[Knowledge Recall]
Q --> M[Memory Recall]
Q --> C[Conversation Recall]
K --> F[分路 RRF 或记忆优先拼接]
M --> F
C --> F
F --> RR[统一 Rerank 可选]
RR --> P[Prompt]
简单稳妥的策略: 记忆与知识分区分注入 ([Memory] / [Knowledge]),不强行混排分数——个人约束(过敏、偏好)通常应压过通用文档里的「推荐菜谱」。
6. Query Rewrite:Memory Recall 的胜负手
用户原话常常不是好检索词:
| 原问题 | 直接检索 | 改写后 |
|---|---|---|
| 「按我的习惯写」 | 太空 | 「用户代码风格 偏好 语言 框架」 |
| 「上次那个还卡吗」 | 缺指代 | 「用户反馈过的页面加载慢问题」 |
| 「推荐个午餐」 | 难命中过敏 | 「用户饮食偏好 过敏 忌口」 |
实现要点:
- 改写时 带上少量近期对话 做指代消解。
- 可生成多 query 并行召回,再 RRF(与知识库 RAG 相同套路)。
- HyDE(先假设一条「记忆长什么样」再检索)对偏好类记忆往往有效。
7. 遗忘、隐私与评测
7.1 遗忘策略
| 策略 | 做法 |
|---|---|
| 显式删除 | 用户说「忘掉我的过敏信息」→ status=expired |
| 过期时间 | expires_at;短期状态默认 30 天 |
| 重要度衰减 | 长期未命中则降低 importance,召回自然靠后 |
| 容量上限 | 每用户 Top-N by importance,溢出归档或摘要合并 |
7.2 隐私
- 默认按
user_id隔离;管理端查询需审计。 - 敏感类(健康、证件)可单独 type + 更短 TTL / 加密字段。
- 提供「导出 / 清空我的记忆」接口,满足合规。
7.3 怎么评 Memory Recall 好不好
| 指标 | 含义 | 经验目标 |
|---|---|---|
| Recall@K | 该召回的个人记忆有没有进候选 | Recall@20 ≥ 0.9 |
| 串号率 | 召回结果是否混入其他用户 | 必须为 0 |
| 冲突正确率 | 过时记忆是否被正确 superseded | 人工抽检 ≥ 0.95 |
| 端到端 | 注入后回答是否遵守偏好 | 场景用例回归 |
构造评测集时,每条样例至少包含:user_id、query、应命中的 memory_id、(可选)不应命中的过时 id。
8. 落地清单(按优先级)
- 先做对隔离 :所有读写带
user_id,加集成测试防串号。 - 再做抽取 + 向量召回 :能跨会话记住「过敏 / 偏好」即闭环。
- 补 BM25 + RRF :专有名词、项目名、订单号不再靠语义碰运气。
- 上冲突与遗忘 :否则记忆越多越害人。
- Query Rewrite +(可选)Rerank :抬「上次那个」类指代与排序精度。
- 与知识库分槽注入 :个人约束和共享文档不要混成一锅分数。
9. 和本目录其他文的关系
| 文档 | 分工 |
|---|---|
| 01-记忆系统 | 三层记忆、通用匹配/召回/跨 Embedding |
| 本文 | Memory Recall + 多路召回有哪些路、差异与实现 |
| Memory.md 规范 | 用 Markdown 持久化记忆的轻量实践 |
| RAG 多路召回专文 | 知识库主线;Memory 作为并列召回路出现 |
10. 学习要点
- 多路召回分两层: 数据源 (Knowledge / Memory / Conversation / SQL / Graph / Web / Tool)与 检索算法 (ANN / BM25 / 元数据 / 时间)。
- Memory Recall = 按用户隔离的个人记忆粗筛 ,是数据源层一路;对内再跑 ANN∥BM25∥时间。
- 实现三件套: 强制 user 过滤 +(ANN∥BM25)召回 + 冲突/遗忘 。
- 写入质量(抽取、去重、supersede)往往比调 HNSW 参数更能提升体验。
- 与知识库结果 分槽注入 或 RRF 融合,避免个人约束被通用文档淹没。
11. 参考
- 01-记忆系统 §6.6–6.8(召回原理与多算法路)
- RAG 专文 §6.3–6.5(多路框架与 RRF)
- Mem0、LangChain / LangGraph Memory、Letta (MemGPT) 设计
- Generative Agents(记忆检索与反思)
- CoALA:Cognitive Architectures for Language Agents(记忆章节)
评论
评论加载中…