Agent结构化记忆实战:用Knowhere把复杂文档变成可查询知识层
做 Agent 项目有一段时间的人大概率都碰到过同一个瓶颈智能体本身写得再顺一到要用“某个具体业务知识”的时候就开始胡说。不是模型不行而是你喂给它的材料根本没法用。直接把几万字的产品文档、合同文本、操作手册塞进上下文一来 token 成本扛不住二来真正到回答问题时模型往往只记住开头结尾中间的关键约束全都丢了。我在几个项目里试过 RAG、试过给文档打标签、试过把内容拆成小段存向量库最后真正稳定下来的一套做法是把复杂文档加工成 Agent 能够按需调用的结构化记忆。这个方案的核心就是用一个叫 Knowhere 的知识记忆框架把文档里的实体、关系、事件、约束全部抽出来组织成可查询、可更新、可追溯的记忆层。这套东西适合谁适合正在做 agent 开发、agent 框架选型或者在折腾 agent 记忆方案的人。如果你手头也有那种结构混乱的老文档、多版本合同、几千行的操作手册这篇文章能帮你省掉不少自己摸坑的时间。1. 为什么 Agent 需要结构化记忆而不是“塞全文”1.1 上下文窗口不是记忆很多人觉得模型上下文已经到 128K、200K 了直接塞进去不就行了。实测下来上下文窗口更像是一个“工作台”而不是“仓库”。工作台上的东西多了模型反而不知道该看哪一件而且每次对话都要把所有内容重新过一遍成本和时间都线性上涨。更麻烦的是长上下文里如果同时存在“旧版本规定”和“新版本修订”模型很容易被冲突信息干扰答出来的内容前后矛盾。我在实际项目里的经验是超过 5000 字的核心业务文档直接塞上下文的效果开始明显下滑超过 2 万字基本只能靠模型“猜”。而结构化记忆解决的是另一个维度的问题——不是让模型“记住”所有内容而是让模型在需要的时候知道去哪里查、查哪一条。这也和现在 agent 架构里的“检索—工具—记忆”三层设计一致Knowhere 承担的就是记忆层它不负责生成只负责把文档转化成精确可查的知识记录。还有一点常被忽略Agent 的“记忆”和人的记忆一样需要组织。人读一份合同不会逐字背下来而是记住谁是甲方、谁是乙方、金额多少、时间节点是什么。Agent 想要达到这种效果就必须先把文档里的这些要素抽出来而不是把原文堆在提示词里让它自己找。Knowhere 在我这里扮演的正是这个“把原文翻译成记忆”的角色。1.2 文档到底“复杂”在哪里“复杂文档”不是一个形容词它有几类非常具体的坑。第一类是版式问题PDF 里两栏排版、带页眉页脚、表格跨页直接解析出来文本顺序是乱的。第二类是内容异构一份招标文件里既有技术参数表格又有时间节点还有资质要求单一的结构化 schema 很难覆盖。第三类是隐含信息有些关键信息不在正文里而在备注、注释、修订记录甚至扫描图片里。如果不用结构化方案这些问题在传统 RAG 里几乎无解——向量检索按语义相似度找片段但片段之间的逻辑关系哪条是主文件、哪条是修订、哪条是例外完全丢失。Knowhere 的做法是先把文档“读”成一个带结构的中间表达再进一步抽取出实体关系。也就是说它把“找相似的段落”升级成“找到某个实体对应的完整上下文”。举个例子一份设备说明书写着“本设备在低温环境下需预热 30 分钟”。传统 RAG 检索“设备启动时间”可能返回这句话也可能返回另一段和设备无关的预热描述。但在结构化记忆里“预热 30 分钟”会被挂到“设备—启动条件”这条关系下Agent 一查设备实体相关约束自然就出来了。1.3 Knowhere 在 agent 记忆体系里的定位Knowhere 不是一个简单的向量数据库它是一套偏工程化的知识记忆框架输入可以是 PDF、Word、Markdown、扫描图片输出是两类产物——一类是结构化记录实体表、关系表、事件表另一类是配套的检索服务。Agent 通过它提供的 API可以按实体精确查、按语义模糊查、按时间范围查甚至支持把对话中产生的新知识写回记忆库。跟主流的 agent 框架区分一下agent harness 或编排层管的是智能体怎么规划、调用工具、执行步骤Knowhere 管的是智能体用什么“底料”来思考。两者配合起来才能做到“agent 能从文档记忆里找到依据而不是从参数里猜”。很多人纠结 harness 和 agent 有什么区别我的理解很简单——harness 是骨架记忆层是血肉缺了记忆这块骨架再完整也跑不出有质量的业务答案。2. 整体架构设计从文档到记忆的四层流水线2.1 接入层多格式文档的统一解析我落地的方案里接入层固定做四件事格式识别、版式还原、OCR 兜底、文本清洗。格式识别多说一句不要只看扩展名很多 PDF 其实内嵌的是扫描图Word 里有嵌入表格Markdown 里有代码块——这些都影响后续抽取。对于 PDF我优先用 pdfplumber 加 layout 解析先把页面里的文本块、图片块、表格块坐标拿回来再按阅读顺序重组。遇到扫描版再用 PaddleOCR 之类的 OCR 模型兜底。做 OCR 时有几个坑要留意一是保留版面结构不能只输出识别文本否则表格顺序乱掉二是要区分“标题级别”和“正文”这对后面切分特别重要三是扫描件本身的 DPI 至少要在 200 以上否则小字号数字识别率很低。接入层还有一个容易被忽视的动作编码和格式归一。比如把繁体转简体、全角转半角、去掉不必要的空行和页脚。这些步骤不复杂但做好了能显著减少后续模型抽取时的噪声。这个阶段的产出物是一份“干净的、按阅读顺序排列的、带块级元信息的 Markdown 或 JSON”。我一般会把每一块的来源页码、块类型、层级记录在元信息里方便后续定位和纠错。2.2 解析层切分与语义增强接入层拿到干净文本后进入切分环节。这里的关键不是“每 500 字切一刀”而是按“语义完整性”切。比如一个表格块最好不要被拆散一条合同条款最好整体保留一个标题加它下面的正文段落应该作为一个候选单元。在 Knowhere 的配置里我一般把切分策略设为“结构优先 最大块长惩罚”。具体来说优先按文档已有的标题层级切如果某个标题下内容过长超过 1500 token再按段落和句号二级切表格保留为独立块不做强行合并。切完之后会给每个块做语义增强包括自动生成标题摘要、提取关键词、标注文档类型和所属业务域。这一步的目的是为了后续结构化抽取的时候模型能更快找到正确上下文。切分质量直接影响抽取质量。我见过很多项目在 RAG 阶段随便切到了结构化抽取阶段发现信息被拦腰截断。所以我会反复强调切分这一步做得越细后面实体抽取就越顺。有些团队为了省事直接用字符长度硬切结果一个完整条款被切成两半抽取出来全是残片后面再去缝合成本高得多。2.3 建模层实体—关系—事件的结构化抽取这是 Knowhere 的核心环节也是整个流水线里最依赖大模型“理解力”的地方。流程上我先把每个语义单元送给一个抽取 Agent让它按我们预设的 JSON Schema 输出结构化结果。Schema 至少要覆盖三类内容实体人、组织、产品、地点、资质、角色、金额等每个实体要有类型、名称、别名、属性。关系实体之间的指向关系比如“甲方—委托—乙方”“产品—具备—资质”每条关系要有主体、谓词、客体、来源片段、置信度。事件带时间线的动作或里程碑比如“2024 年 3 月签订合同”“2024 年 7 月完成验收”每个事件要有时间、类型、参与实体、描述。抽取不是一次完事。我的经验是分两轮第一轮做“宽松抽取”宁可多抽一些候选实体第二轮做“对齐去重”把同一个实体的不同叫法合并比如“XX 公司”和“XX 集团有限公司”到底是不是同一个主体这个判断必须交给规则加模型混合处理。全部抽完之后把结果写入 Knowhere 的图存储和向量索引里形成可查询的记忆底稿。这一层最忌讳的是“一步到位”心态。我刚开始做的时候想用一个超级复杂的提示词一次性把实体、关系、事件全抽准结果来回调了一周效果依然不稳定。后来改成“宽松抽取 精炼对齐”两步走效果立竿见影。抽取阶段允许模型多给候选对齐阶段再收敛整个流水线反而更可控。2.4 记忆服务层Agent 怎么“想起来”最后的记忆服务层决定了 Agent 用起来顺不顺。我在这层封装了三个核心 APIsearch_memory(query)语义检索适合 Agent 不知道具体实体名、只知道大概意思的场景。get_entity(name)精确取实体返回该实体全部属性、关系、关联片段。query_events(time_start, time_end)按时间范围取事件适合“最近发生了什么”“某段时间内有哪些里程碑”这类问题。另外还做了一个 write_memory 接口允许 Agent 在对话过程中把用户补充的信息写回记忆库比如“用户说他们是 X 公司的华南区代理”这样的新关系。这样记忆可以持续生长而不是一次性导入后就不更新。整体来看这一层把底层向量数据库、图数据库、文档原文仓库的差异都遮住了Agent 只需要面对一套简单稳定的记忆 API 即可。封装的意义在于底层存储将来想换、想加缓存、想加权限都不会影响上层 Agent 的调用逻辑。这也是我在多个项目里坚持“先定义 API再选存储”的原因。3. 核心环节实操把一个复杂 PDF 变成 Agent 记忆3.1 文档预处理从 PDF 到干净文本单元我用一个实际的例子来演示。假设我要把一个 30 页的产品技术白皮书转成 Agent 记忆第一步是解析 PDFimport pdfplumber from knowhere.sdk import DocPipeline pdf_path product_whitepaper.pdf blocks DocPipeline.extract_blocks( pdf_path, layout_modereading_order, table_modepreserve, ocr_fallbackTrue, ) clean_md DocPipeline.to_markdown(blocks, keep_metadataTrue)这块代码背后做了几件事坐标排序、表格识别、OCR 兜底、文本清洗。输出的 clean_md 会保留 level 信息和页码信息。我建议你在命令行里先看一眼输出确认表格没有被拆错、两栏文本顺序有没有乱。这一步错了后面全白做。处理完的 clean_md 我会按块结构拆成多个语义单元文件每个文件就是一个候选“记忆片段”。这里有一个平时文档不会写的小细节拆分时把层级路径拼在块的开头比如“3.2 安装环境 / 3.2.1 温度要求”这样后续抽取模型不需要自己推断上下文直接从路径里就知道这块属于哪个章节。3.2 结构化抽取让大模型写“结构化笔记”把文本单元交给 Knowhere 的抽取引擎。抽取引擎其实就是一个带约束的 LLM 调用我在配置里会给定一个 JSON Schema 和抽取提示词{ entities: [{name: string, type: string, aliases: [string]}], relations: [{subject: string, predicate: string, object: string}], events: [{time: string, type: string, description: string}] }提示词里最重要的三句话只抽取文档中明确出现的信息不要补全常识同一实体在不同片段出现时使用同一标准名称并添加别名如果某个字段无法确定留空而不是编造。此外我还会加一条凡是出现“仅适用于”“不包括”“例外”“但书”这类约束性表述必须单独抽成约束事件并绑定到相关实体上。抽取是逐块进行的之后把所有块的 JSON 结果合并交给“对齐去重”模块。这个模块会做三件事名称归一化去掉公司后缀差异、实体融合合并同实体不同叫法、冲突处理同一属性出现多个值时按文档优先级保留最新或最权威的一个。对齐去重看起来不复杂但规则需要持续迭代每处理一批新文档都会遇到新的命名变体。3.3 记忆入库把抽取结果写进 Knowhere结构化结果写回记忆库的代码from knowhere.sdk import MemoryStore store MemoryStore(profiledefault) store.ingest( doc_idwp_2025_001, entitiesmerged_entities, relationsmerged_relations, eventsmerged_events, source_segmentsclean_segments, index_policy{graph: True, vector: True}, )入库时要注意几个参数doc_id 要稳定后续做增量更新和失效处理都靠它source_segments 是原文映射任何一条记忆都能回溯到原文这对业务方审核很关键index_policy 决定是否建图索引和向量索引如果文档偏问答场景向量索引必须开如果偏关系推理图索引优先级更高。入库后我习惯跑一个校验作业随机抽样 20 条记忆人工核对对应的原文片段确认抽取没有张冠李戴。这一步在项目初期尤其重要能帮我把抽取提示词调准。校验数据我建议沉淀下来做成一个小的回归测试集每次改完提示词或者换模型后重新跑一遍能及时发现抽取质量的回退。3.4 召回接口Agent 在对话中按需取记忆Agent 侧的调用很简单一个查询函数就够了from knowhere.sdk import MemoryClient client MemoryClient(endpointhttp://localhost:8701) def recall(question: str, entities: list[str] None, time_window: tuple None): result client.search( queryquestion, entitiesentities, time_rangetime_window, top_k8, ) return result # 示例 answer_context recall(这款产品支持哪些认证资质, entities[XM-2000])我在 Agent 的提示词里会告诉模型回答业务问题时优先调用 recall 获取记忆上下文并把来源片段附在回答后面。这样既提升了准确性也方便追溯。实际跑下来结构化记忆上下文和纯原文上下文相比答案里引用具体条款、具体参数的比例明显提升含糊其辞的比例下降很多。4. 踩坑实录与排查技巧4.1 表格永远是重灾区表格解析是整个流水线里我踩坑最多的地方。常见问题有三个跨页表格被拆成两半表头丢失合并单元格解析结果混乱表格里有小字号备注混进相邻列。处理办法是切块阶段把表格整体作为一个块不做跨表格的文本切分入库时给表格块单独加类型标签抽取提示词里明确“表格内容逐行读取”。如果表格特别复杂比如带多层表头或者行列合并很多我还会单独跑一个表格识别模型把结果以 markdown 格式并入原文档再走下游流程。这里给个实际参考普通文本表格用 pdfplumber 基本够用但那种大宽表、带合并单元格的财务报表最好还是走专门的表格结构识别方案省得后期人工返工。4.2 同一实体被写成多种叫法一份文档里“XX 集团”“XX 集团有限公司”“XX 集团股份有限公司”可能是同一个主体也可能不是。盲目合并会把两家不同公司搅在一起。我的经验是建立“别名表 规则 模型校验”三层机制先从文档中收集所有候选名称再用规则消解明显的公司后缀差异最后让模型结合上下文判断是否同一个主体。判断结果必须可追溯会记录依据片段方便人工复核。这个问题的隐蔽之处在于它不像格式问题那样一眼能看出来而是会悄悄影响后续所有关系查询。比如“张三”和“张总”如果没合并那么“张三参与的项目”和“张总负责的客户”在记忆里就是两条断裂的信息。Agent 查不到完整关系表现就是回答时“好像漏了什么”。4.3 检索召回率虚高但答非所问向量检索评估时很容易出现一种假象语义相似度分数很高但召回的内容根本不是用户想问的。多半原因是切块太小、上下文缺失。比如只切了“该设备功率为 220V”没有前面的“型号条件”召回出来就不知道是哪台设备。解决办法是切块时带上父级标题和相邻上下文检索时对候选块做一次重排优先选择包含完整“实体属性约束”的块。我推荐在检索链路里加一个轻量级重排步骤不需要很重的模型用规则判断候选块是否包含问题里的核心实体词就行。这一招在业务文档场景下效果很明显能把不少“相似但不相关”的噪声块过滤掉。4.4 记忆写多了反而污染Agent 支持 write_memory 之后要小心垃圾记忆越写越多。我在项目里发现如果用户随口一句“应该是这样吧”被写进记忆后面所有查询都会多出一条噪声。处理策略是给写入接口加置信度门槛和审核回调低置信度的写请求先进入待确认队列由人类确认后再生效。此外每条记忆都带失效时间或版本号合同更新后可以标记旧版记忆过期避免新旧信息同时出现导致回答前后矛盾。这个“过期机制”是我在某次事故后补上的当时一份合同更新了金额但旧金额还留在记忆里Agent 回答时抽到哪条就答哪条结果金额时对时错业务方差点以为系统坏掉了。4.5 常见问题速查表我把实际排坑中最高频的几个问题整理成一张表现象可能原因处理建议表格字段错位跨页表格未合并表格整体成块单独识别后再切分实体重复且信息分散别名未归一化增加别名表与模型融合校验Agent 回答案例张冠李戴切块切断了上下文块内带父标题与相邻上下文检索后重排新写入记忆污染历史答案低置信度写回写接口加置信度门槛与人工审核同一实体多个版本属性冲突无版本管理属性带版本号旧版本标失效OCR 数字识别错误扫描 DPI 太低扫描件 DPI 提到 300关键数字人工抽查这张表我每次调新项目都会拿出来对一遍能省很多排查时间。遇到新问题我也会往里加时间长了就是一套非常实用的排障手册。5. 效果评估与实际使用体会5.1 怎么衡量结构化记忆的质量结构化记忆好不好不能只看“抽取 accuracy”。我用了四个维度召回完整度文档中人工标出的关键信息被抽取出来的比例、关系准确率随机抽样关系判断对不对、记忆去重率重复实体记录占比、以及最终问答效果同一组问题用结构化记忆上下文和用原文上下文分别测准确率。在最近一次合同类文档项目中四个维度分别做到了 92%、95%、4% 和 91%比单纯塞原文的 78% 高了不少而且 token 消耗降到了原来的六分之一。这里多说一句问答效果和抽取指标不一定完全正相关最直接的度量还是回到业务本身看 Agent 回答的准确率和可追溯率。推荐每个项目都保留一组固定测试问题每次调整模型或者提示词后统一跑分避免凭感觉判断好坏。5.2 我实际调过的几个关键参数切分最大块长1500 token 左右最稳太长模型抽取容易漏细节太短容易没上下文。实体对齐置信度阈值0.85 以上自动合并0.6 到 0.85 之间进入人工复核队列。检索 top_k默认 8业务问题偏具象时降到 5偏综合性时调到 12。write_memory 置信度门槛低于 0.7 一律不直接写入先转人工。这些参数在不同项目里要微调但方向是通用的。如果让我说一条最核心的体会结构化记忆这件事八成功夫在解析和清洗两成在模型抽取。把接入层和切分层的脏活干利索了后面的准确率自然就上来了。很多人一开始把精力都放在调 prompt 上忽略了解析质量结果换个文档格式就崩根子其实在数据接入。另外再分享一个小技巧。很多复杂文档里都有“修订记录”和“免责声明”这样容易被忽略的章节里面往往藏着关键约束比如“本条款仅适用于华东区域”。这类信息如果丢失Agent 很容易把局部规则当全局规则用。我的做法是在抽取提示词里单独加一条指令要求模型把所有“条件限制类”的语句单独抽成约束事件并绑定到对应实体上。这个小改动让我们的问答系统在边缘 case 上的表现好了不少。