
1. 先看腾讯 ima 架构官方把哪几件事说透了腾讯官方把 ima 的架构文章发出来那天我所在的几个技术群里都在转。这类产品架构稿我平时看个开头就划走了但这篇不太一样——它把一个大模型知识库产品从意图识别到检索增强再到 Agent 协作的完整链路拆开了信息密度相当高。我盯着那几张架构图看了两天忽然冒出一个念头既然官方把设计思路都摊开了那我能不能在本地把这套架构完整跑通于是就有了这篇手搓记录。我先说明一下我做的不是把 ima 的代码扒下来重编译而是照着它公开的架构理念用开源组件在本地重新实现一套个人知识库问答系统。核心链路保持一致意图识别、知识检索、答案生成、记忆模块、Agent 工具调度一个环节都不缺。这篇文章包含我的架构拆解思路、技术选型对比、完整实操过程和踩坑实录适合想做个人知识库、想搞懂 RAG 和 Agent 落地的开发者参考。哪怕你只是想给自己的文档库加一个能问答的入口这套方案也能直接抄作业。先说我理解官方架构文章后提炼出来的东西。ima 本质上是一个“会记事的检索增强助手”它面对的用户场景不像通用聊天那样海阔天空而是集中在一个相对明确的私域知识体系里。这就带来一个关键差异通用大模型对话靠的是模型参数里的世界知识而知识库问答必须把外部文档当成第一信源。官方架构文章里反复强调的其实就是怎么把“用户问题”翻译成“检索指令”再把检索结果组织成“高质量回答”最后把整个交互过程沉淀成“可复用的记忆”。拆开来看官方架构里有四个关键词我必须重点说一下。第一个是意图识别。不是所有问题都需要走知识库检索有些问题可能就是闲聊、追问或者需要调工具。如果所有请求都无脑进检索链路召回质量会惨不忍睹还会浪费算力。ima 的架构在入口处做了意图路由根据对话上下文判断当前请求属于哪种类型然后分流到对应的处理链路。第二个是知识检索。这是知识库问答的命门官方架构里用的是典型的向量召回加多路融合思路。文档先被切块、向量化用户问题也被向量化然后在向量库里做相似度匹配。但只靠向量召回是不够的因为向量模型对专有名词和精确短语的匹配能力有限所以还要结合关键词召回做互补最后再做一次重排把距离答案更近的内容排到前面。第三个是答案生成。召回质量决定了下限生成质量决定了上限。官方架构并没有把检索到的内容直接丢给大模型而是经过一层上下文组织把用户问题、对话历史、检索片段、知识来源整合成一个结构化的 prompt再交给生成模型输出。这里有一个细节很关键知识来源要可追溯回答要能在原文里找到对应依据否则就退化成“一本正经地瞎编”。第四个是记忆管理。这是我个人觉得 ima 架构里最有产品深度的一层。通用大模型的对话是无状态的但知识库助手必须记住“用户之前问过什么”“哪些知识被调用过”“用户的偏好是什么”这样才能在后续对话里提供连续性的服务。官方架构把记忆拆成了多级结构既有短期对话上下文也有长期事实记忆实现起来比想象中复杂。这四个关键词基本就是官方架构的主干。我的本地版 ima.copilot就是照着这四个字逐层实现的。2. 本地化改造哪些能抄哪些必须改手搓之前我做了几轮方案取舍。毕竟官方的架构跑在云端有大规模分布式存储、任务调度、模型服务集群我本地只有一台带 24G 显存的机器很多东西必须降级处理。但架构骨架是可以抄的关键在于每个零件怎么换。2.1 整体思路保留骨架换掉零件本地版的分层和官方架构保持一致我把它简化成四层入口层负责意图识别和会话管理逻辑层负责检索编排和 prompt 构造工具层提供 Agent 可调用的各类能力存储层负责向量库、知识库和记忆数据。对照表我放在下面方便你直接看原版和本地版的对应关系。架构层官方方案按架构文章推断本地版方案取舍理由意图识别大规模意图分类模型加上下文路由大模型 prompt 分类加规则兜底本地模型分类能力足够降低维护成本知识召回多路召回加大规模向量检索BM25 加向量召回再轻量重排没有分布式集群但双路召回是必须保留的生成模型云端大模型服务本地 Qwen 系模型主打隐私可控离线可跑记忆管理多级记忆存储与更新SQLite 存事实记忆加上下文压缩单用户场景用数据库足够Agent 调度服务化工具注册与编排本地工具函数注册表每一轮调用由模型自行决策这套映射关系花了我不少时间。我一开始的心态是“能全抄就全抄”后来发现本地环境根本不需要那么重的组件。比如官方架构里的分布式向量检索单机场景下用 Qdrant 就能扛住官方架构里的多级缓存本地直接砍掉内存读取已经够快。2.2 模型选型Embeding、Rerank、生成分开选我先后试过几套组合最终固定下来的是Embedding 用 bge-m3重排用 bge-reranker-base生成用 Qwen2.5-14B-Instruct 配合 Ollama 运行。选这三个模型的原因各不相同。Embedding 模型我对比过 openai 的 text-embedding-3-large 和 m3e、bge-m3。如果不考虑隐私openai 的接口效果确实好维度低、语义贴合度高。但我的知识库存了大量私有文档全走 API 意味着文档内容全部要过一遍外部服务这在本地化场景里是原则问题直接排除。bge-m3 是智源开源的支持中文效果极好输出 1024 维向量单机跑起来大概占 2G 左右显存对我这台机器来说完全能接受。重排模型一开始我根本没想用。我天真地以为向量召回取 top5 就够了结果检索出来的片段经常出现“关键词相似但语义不对”的问题。后来加了 bge-reranker-base 做二次排序效果立竿见影回答的准确率提升非常明显。这个模型很小CPU 也能跑推理一两个批次基本是毫秒级属于性价比极高的投入。生成模型是最纠结的。我尝试过纯 API 方案也试过本地 7B 和 14B 的模型。14B 的 Qwen2.5-Instruct 是一个不错的平衡点显存占用约 10G推理速度能接受中文指令跟随能力明显强于 7B。如果你显存更大可以上 32B 版本效果会再上一个台阶但 14B 在日常文档问答里已经足够用。2.3 向量库取舍为什么选了 Qdrant本地向量库的选择我纠结了挺久。市面上一堆方案Chroma 最轻量装完就能用适合快速原型验证Milvus 功能最全但要起 ZooKeeper 和一堆依赖单机场景显得过度设计Qdrant 正好卡在中间单机模式部署简单性能又足够靠谱。最终我选了 Qdrant理由是三个。第一它支持过滤条件结合向量检索比如按标签过滤这对管理多主题的知识库非常关键第二它提供了同步的 Python 客户端不需要额外起 HTTP 服务也能嵌入运行第三它的持久化机制做得干净崩溃恢复后不会出现数据索引不一致的问题。Chroma 我试了一个周末就放弃了主要问题是数据量到几万条之后查询延迟明显上升而且过滤条件的语法写起来别扭。3. 实操记录把架构变成能跑的代码架构想清楚了接下来就是动手。这一节我把整个实现过程分成几个阶段每一步都会给出关键代码和当时的思路。基础环境是 Ubuntu 22.04、Python 3.10、RTX 3090 24G。3.1 项目目录结构一开始就分好层很多手搓项目到后期烂尾不是因为功能实现不了是因为代码全堆在一个文件里改一处崩三处。所以我动手之前先定了目录结构按架构分层来组织。ima_local/ ├── config.yaml # 全局配置模型路径、向量库参数 ├── app.py # 入口交互式命令行对话 ├── core/ │ ├── intent.py # 意图识别与路由 │ ├── memory.py # 短期与长期记忆管理 │ ├── agent.py # Agent 工具调度循环 │ └── llm.py # 本地模型封装 ├── knowledge/ │ ├── loader.py # 文档加载与切分 │ ├── embedder.py # 向量化封装 │ ├── retriever.py # 双路召回与重排 │ └── store.py # 向量库读写 └── tools/ ├── registry.py # 工具注册表 └── builtin_tools.py # 系统内置工具这个结构的好处是每一层只依赖它下面一层的接口。我后面调 Agent 工具逻辑的时候完全没有动到知识检索的代码。分层这件事早期多花半小时后期能省出三个晚上。3.2 知识入库切块策略决定了检索上限知识入库是整条链路里最“一锤定音”的环节。检索效果差八成是切块切得不好。我的测试文档里有一批是产品技术文档段落结构清晰标题层级分明另一批是会议纪要和零散笔记没有结构内容跳跃。对这两类文档我最后采用了不同的切分策略。结构清晰的文档按标题层级切块保留标题作为上下文前缀。零散文档按固定长度切块但切的时候遵守两个原则第一优先在句子边界断开第二相邻块之间保留重叠区间。固定长度我最终选的是 350 个字符重叠 60 个字符。这个参数是试出来的不是拍脑袋定的。我用了最老实的切分方式先按段落粗分再对超长段落按句号生硬切分。中文切分没有现成的完美工具我直接用正则按句末标点断句。每块的超长部分做截断同时记录它在原始文档中的页码和位置元数据方便后面回答时标注来源。def split_document(text, chunk_size350, overlap60): sentences re.split(r(?[。]), text) chunks [] current for sent in sentences: if len(current) len(sent) chunk_size: current sent else: if current: chunks.append(current) # 保留上一个块的尾部内容作为重叠区间 tail current[-overlap:] if len(current) overlap else current current tail sent if current: chunks.append(current) return chunks这段代码看起来很简陋但实际跑下来效果很好。切块后进入了向量化流程每个块用 bge-m3 编码成 1024 维向量连同原始文本和元数据一起写入 Qdrant。向量化的耗时没法避免350 个字符的块在我本机上大概一秒处理 15 个块如果知识库有几万块建议做好进度条和断点续传设计一次中断就得从头跑是非常折磨的事。3.3 双路召回向量检索加关键词再重排入库之后就是查询侧。我的检索链路分三步向量召回、关键词召回、重排合并。向量召回用的是 Qdrant 的相似度搜索query 先用同一个 bge-m3 模型编码然后查 top20 候选。仅做这一步效果只能打 60 分。我试过一个案例用户问“如何配置数据库连接池”向量召回返回的结果里有大量关于“数据库性能优化”的内容因为这两个描述在语义空间里离得很近但用户实际想要的是具体配置步骤。所以我在向量召回之外加了 BM25 关键词召回。BM25 的实现用的 Rank-BM25 库它对精确短语特别敏感“连接池”这个词一旦在文档中出现就会被单独命中。这一步把候选集扩大到 30 条左右然后合并两路结果去掉重复项交给重排模型打分。重排我用的 bge-reranker-base直接输入“问题”和“候选片段”输出相关性分。这一步把最终进入生成上下文的片段控制在 4 条以内。实测下来重排后的 top4 内容准确率比单纯向量召回的 top4 高了很多最关键的是避免了“看似相关实则离题”的干扰项。3.4 生成链路Prompt 结构比模型能力更重要我对生成链路的体会是prompt 里的约束条件直接决定模型会不会“不懂装懂”。知识库问答最怕的是模型检索到了正确答案却用自己的世界知识回答了一堆别的。所以我把 prompt 结构化分成三块角色与任务、检索上下文、对话历史。检索上下文里明确标注“只依据上述内容回答”并要求模型在找不到答案时直接说“知识库中没有相关内容”禁止猜测。这一句“禁止猜测”的权重比想象中大得多。SYSTEM_PROMPT 你是一个知识库问答助手。 回答必须严格依据检索到的资料内容严禁使用资料之外的知识作答。 如果资料中没有明确答案直接回答“知识库中未找到相关内容”不要编造。 回答时请标注引用片段编号格式如[1][2]。 def build_prompt(question, context_chunks, history): context_text \n\n.join( f[{idx1}] {chunk} for idx, chunk in enumerate(context_chunks) ) return f {SYSTEM_PROMPT} 对话历史 {history} 检索到的资料 {context_text} 用户问题{question} 请给出回答生成模型是 14B 的 Qwen2.5加载在 Ollama 里。需要注意的一点是我把 prompt 模板写在应用层没有依赖模型的 system prompt 能力。这样换模型的时候不用改代码只调 prompt 模板即可。实测下来这个 prompt 结构在 Qwen、Llama 和 DeepSeek 模型上都能稳定工作。3.5 记忆模块让系统记住“你在问什么”记忆模块是我最开始忽略、后来觉得最值钱的部分。没有记忆的版本有个尴尬场景用户先问“部署步骤是什么”再问“第三步的命令怎么执行”如果模型没有记住上一轮的检索结果它根本不知道“第三步”指的是什么。我的短期记忆方案是一个环形 buffer保存最近 6 轮对话的摘要和原始记录。每轮对话结束后把问题和回答追加进 buffer超过轮数时淘汰最旧的一条。更关键的是长期记忆我用 SQLite 建了两张表一张存用户事实画像一张存知识调用记录。def update_longterm_memory(session_id, question, answer, facts): # facts 是从对话里抽取的结构化记忆比如用户偏好简洁回答 with sqlite3.connect(memory.db) as conn: conn.execute( INSERT INTO longterm_memory (session_id, fact, created_at) VALUES (?, ?, ?), (session_id, facts, time.time()) )长期记忆的抽取我额外写了一个轻量的 prompt 任务每次对话结束后让模型判断“这段对话里有没有值得长期记住的事实”。这个设计我是从官方架构文章里对“记忆分层”的描述得到的启发不是所有对话都值得记只记那些对后续交互有用的事实。实际效果是当用户第二次问相似问题时系统会主动参考记忆里的偏好设置回答风格和内容深度会明显不同。3.6 Agent 工具调度让模型自己决定调什么我的本地版在最开始只是简单的“检索加生成”后来在官方架构文章里看到 Agent 协作模块意识到知识库问答里的有些请求不该走检索链路。比如用户问“现在几点”或者“帮我算一下 128 的平方根”这类问题丢给检索就是灾难应该直接调用工具。我的实现方式是维护一个工具函数注册表每个工具包含名称、描述和可执行函数。生成模型在回答前会先做一次“工具选择”判断如果模型认为需要调用工具就输出一个结构化指令程序解析指令后执行对应函数把结果插入 prompt 再让模型生成最终回答。TOOLS { current_time: { description: 获取当前时间用于回答关于现在几点的问题, function: lambda: time.strftime(%Y-%m-%d %H:%M:%S) }, calculate: { description: 执行数学计算参数是一个数学表达式字符串, function: lambda expr: str(eval(expr)) }, search_knowledge: { description: 搜索本地知识库获得相关资料, function: search_knowledge } }这是一个非常朴素的 Agent 雏形没有复杂的状态机就靠模型理解能力加结构化输出。我把 search_knowledge 也注册成了工具这样“意图识别”和“工具调用”实际上合并成了一条链路模型先判断该不该搜该搜就走检索不该搜就直接回答或调其他工具。架构文章里的意图路由思想落到单机环境后我用这个方案做了一个轻量实现。4. 调参与效果优化那些跑通一半才发现的问题手搓框架很快真正耗时间的是调参。这一节把我在这个项目里反复试验得到的几个关键参数和调优思路写出来都是可以直接拿去用的经验。4.1 分块参数350 字加 60 字符重叠的实测依据我前面提到最终选了 350 字分块、60 字符重叠这个数字不是拍脑袋定的而是做了对比实验。我拿一批约 200 篇的产品文档做了分组测试对比不同分块长度下的回答质量。分块大小重叠长度检索命中率回答完整度上下文占用150 字30 字偏低差信息太碎小350 字60 字高好完整段落中600 字100 字中等时好时坏上下文超限大易超限分块太小的问题很明显一个完整的知识点被切到几个块里每个块只有一半信息检索回来也是残缺的。分块太大则会让单块内容过于混杂向量化之后语义不聚焦而且塞给生成模型的上下文很快就超过窗口限制。350 字对于中文技术文档是一个比较合适的粒度大约能承载一到两个完整知识点的内容。重叠区间的作用是为了处理“信息跨块”的情况。切分是把连续文本硬切开的如果知识点恰好落在切割点附近没有重叠就会丢信息。60 字的重叠长度大约能覆盖一句话的长度实际测试跨块信息的召回率提升明显。4.2 检索参数top-k 和阈值怎么定检索参数我折磨了最久。top-k 设太大无关内容太多重排模块也会被干扰设太小万一相关答案在候选集之外重排再怎么排也救不回来。我最终定的是向量召回取 20 条BM25 召回取 10 条合并去重后重排取前 4 条进入生成上下文。这个组合的合理之处在于重排模型能力强但输入数量有限制输入太多条会导致处理时间线性增长。30 条候选经过重排后选择前 4 条既能保证正确答案在候选集里又能控制生成 prompt 的长度。如果某一轮检索后重排分都很低说明知识库里可能真的没有相关内容这时我会加一个阈值判断低于阈值的统一返回“未找到”。相似度阈值我设在了 0.45 左右基于 bge-m3 的向量余弦值分布统计出来的。质量高的匹配通常都在 0.6 以上0.45 到 0.6 区间属于模糊相关可以进入候选但要靠重排再过滤。低于 0.45 的基本就是噪声直接丢弃不会遗憾。4.3 Prompt 里的两种幻觉上下文污染与默认知识生成环节我踩过两个大坑都是关于幻觉的。第一个是上下文污染型幻觉。我把检索片段和用户问题挨在一起模型经常把上一段检索内容里提到的概念当成下一段的默认前提然后跳出资料范围开始自由发挥。解决办法是给每个检索片段前加上“资料N”这样的前缀同时系统 prompt 里强制要求“每个观点后标注来源编号”。模型在输出时能明确知道自己在引用哪个片段如果不确定来源就倾向选择不回答而不是编造。第二个是默认知识型幻觉。用户问“什么是 XX 协议”这种通用问题知识库里明明有更准确的定义但模型会优先调用自己训练参数里的记忆因为那更“顺口”。我试过在系统 prompt 里加一句“即使问题看似常见也必须优先采用检索内容中的表述”效果有改善但不彻底。最终对策是对高置信提问场景加强检索阈值要求如果检索评分不高宁可让模型回答“资料不足”也不要它自由发挥。4.4 知识库更新全量重建还是增量写入知识库的更新是个容易被忽略的坑。我最初的版本是每次新文档进来就重新跑一遍全量入库知识量到几千条之后一次全量入库要等十几分钟完全不可接受。增量写入才是正解新文档只需计算新内容的向量写入 Qdrant 时带上自己的文档 ID 和标签查询时就能精确区分新旧内容。删改方面也有讲究。Qdrant 支持按 payload 过滤删除我删文档的时候会先把该文档的所有点按标签删除再重新写入新的切块。这样避免了旧数据残留造成的脏索引。我还留了一个重构标记如果某天分块策略调整了就写一个脚本按文档 ID 全量重新入库权当一次集中清洗。5. 常见问题与排查技巧实录项目跑起来之后我在实际使用中积累了相当多的排查经验。这里挑几个最典型的问题整理成速查表再挑三个我折腾最久的展开讲讲。5.1 问题速查表现象可能原因排查手段检索结果明显不相关分块太大或切点切断了知识点调小分块检查切块内容是否完整回答里出现知识库外内容prompt 缺少禁止猜测约束强化系统 prompt 约束加入来源标注要求中文句子在回答中被截断生成模型输出长度限制调整模型 max_tokens或在 prompt 中限制输出长度向量库查询越来越慢增量写入导致数据量膨胀检查是否需要归档或重建索引模型拒绝回答知识库内的问题检索阈值设得太高降低向量相似度阈值或增加候选集数量同一问题不同轮次回答差异大短期记忆干扰或温度参数太高检查记忆缓冲内容降低 temperature 到 0.2 以下重排后相关片段排在后面重排模型未正确处理长文本截断重排输入长度到 512 token 以内5.2 让我折腾最久的三个坑第一个坑是中文 token 膨胀。Qwen 系列模型的中文编码效率已经算好但长文本检索片段进入 prompt 后依然占用大量上下文窗口。我的 prompt 经常写着写着就超过 8K 上下文模型开始遗忘检索内容。解决方法是严格控制进入上下文的片段数量最多 4 条同时每条片段在送入 prompt 前做一次截断只保留最核心的部分。重排后的片段已经排序前 4 条里每条取前 400 字就够用。这个策略让我从频繁的超限报错里彻底解脱出来。第二个坑是模型“检索到了但不用”。最气人的是模型明明拿到了正确的资料回答时却绕开资料用自己的话重写。后来我发现根因是 prompt 里没有充分强调“资料是唯一信源”。我加了一句话“回答时逐条引用资料中的原话避免自行概括转述。”效果立竿见影回答风格明显变得有依据了。还有一个配套操作是在输出后加一个简单的自检环节让我用规则检查回答里是否包含检索片段中的关键实体词如果缺失就提示模型重新生成。第三个坑是 Ollama 在并发请求下卡死。刚开始我只开了一个模型实例对话请求和记忆抽取任务同时压上去直接 OOM。后来我把记忆抽取任务拆出来单独跑一个小的 7B 模型实例与大模型分开部署又把 Ollama 的并发参数调低确保同一时刻只有一个生成请求。做知识库问答这种交互式应用并发控制比加速更重要宁可排队也不要崩。5.3 手搓过程中最有用的小技巧有几个技巧不在任何文档里但解决了我实际使用中一大半的体验问题。第一个是引入问题改写。用户提问经常是很口语的片段比如“刚才那个部署步骤呢”这种问题直接拿去检索基本是白搭。我在检索前加了轻量改写先把问题结合对话历史改写成完整表述再执行检索。这个环节用小的 7B 模型跑就足够成本低收益极高。第二个技巧是检索结果里带上文件路径和页码。一开始我的回答只有文字没有来源用户想回原文确认得全库搜一遍。后来我把元数据拼进 prompt让模型在回答末尾标注“来源xxx 文档第 x 节”。这个体验提升是质的飞跃直接让回答从“看起来有用”变成“可信可用”。第三个技巧是热加载知识库。我用 watchdog 监听知识库目录有新文件放进去自动触发入库任务。这样在整理资料时不需要任何操作放文件进去就能直接问非常顺手。这套本地版 ima.copilot 从构思到跑通前后花了大概两周的业余时间。我最大的体会是官方架构文章的价值不在于给你一份现成的部署清单而在于把产品设计背后的系统性思考摊给你看——意图该在哪里分流、记忆该怎样分层、检索和生成如何配合。顺着这套思考逻辑你会发现用开源组件复刻一个缩小版并没有想象中那么难难的是在每一个环节做对的取舍。我现在还在继续调优这套系统下一步打算把工具注册表扩展成可插拔的插件格式让新工具接入不用改主逻辑。这个需求是我在用它管理日程、笔记和灵感库的过程中自然长出来的。如果你也在折腾自己的知识库助手欢迎拿我这套方案当起点去改反正架构是公开展示的剩下的发挥完全看你的场景有多野。