ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零搭建个人知识库问答机器人:Agent+RAG+LangChain实战

从零搭建个人知识库问答机器人:Agent+RAG+LangChain实战 1. 为什么我要从零搭一个个人知识库问答机器人我自己平时有大量的碎片化阅读习惯公众号文章、技术博客、PDF 报告、随手记的笔记散落在各种 App 和文件夹里。时间一长问题就来了明明记得看过某个东西但就是想不起来在哪看到的搜索关键词也搜不出来因为很多内容当时只是“扫了一眼”根本没进脑子。这种状态持续了大概半年我决定动手做一个个人知识库问答机器人——把散落的内容统一收拢然后用自然语言直接提问让机器人帮我从自己的资料里找答案。这个项目的核心思路其实就三个词Agent、RAG、知识库。Agent 负责理解我的问题、决定要不要查资料、怎么查RAG检索增强生成负责把知识库里的相关内容捞出来喂给大模型知识库则是所有原始资料的存储和索引层。三者串起来就是一个能“记住我自己东西”的问答系统。它适合什么人参考如果你有下面任意一种情况这个项目对你就有直接价值手头有大量 PDF/Word/Markdown 文档想用自然语言快速检索正在学 LangChain想找一个能跑通的完整项目练手对 Agent 和 RAG 的概念停留在“听说过”想看看真实代码长什么样或者你只是想给自己搭一个离线的、数据不出本地的私人问答助手。我下面会把整个搭建过程、踩过的坑、参数怎么选、代码怎么写全部摊开讲清楚你照着抄作业就能跑起来。2. 整体架构设计与技术选型思路2.1 为什么是 RAG 而不是微调很多人一上来会想我直接把资料喂给模型微调不就行了我试过结论是个人知识库场景下微调性价比极低。原因有三点。第一微调需要构造高质量的问答对我手头只有原始文档没有标注数据人工造数据的时间成本远超收益。第二微调后的模型知识是“固化”的我新增一篇文档就得重新训练而我的资料是每天都在增长的。第三微调容易让模型“记住”错误信息而且很难追溯它到底是从哪篇文档里学来的出了问题没法排查。RAG 就完全不一样。它把“知识”和“模型”解耦了模型本身不动知识放在外部向量库里新增文档只需要重新索引那一篇检索结果还能直接给出原文出处方便我核对。对于个人知识库这种数据量中等、更新频繁、要求可溯源的场景RAG 是更合理的选择。这也是为什么现在大量 Agent 项目都默认走 RAG 路线。2.2 Agent 在这里到底扮演什么角色如果只是“提问→检索→回答”其实一个简单的 RAG 链就够了为什么还要套一层 Agent我的体会是真实提问往往不是单一动作。比如我问“我上个月看的那篇讲向量数据库的文章里作者推荐的工具有哪些”这个问题里包含了时间过滤、主题检索、信息抽取三个动作。一个裸的 RAG 链只会拿整句话去检索效果很差。而 Agent 可以先把问题拆解决定先按时间筛、再按主题查甚至调用多个工具组合完成。在这个项目里Agent 的职责主要是三件事判断是否需要检索有些问题模型自己就能答不用浪费检索、决定检索策略用哪个知识库、用什么关键词、要不要多轮检索、整合检索结果生成最终回答。LangChain 提供的 Agent 框架刚好能把这些能力串起来而且和 RAG 的集成非常顺滑所以技术栈就定成了LangChain 向量库 大模型的组合。2.3 技术栈最终选型与理由我把选型过程整理成了一张表方便你对照自己的情况调整组件我的选择备选方案选择理由编排框架LangChainLlamaIndex、Haystack生态最全Agent 和 RAG 集成成熟中文资料多大模型本地 Ollama 部署云端 API个人知识库涉及隐私本地跑数据不出机器向量库ChromaFAISS、Milvus轻量、零配置、支持持久化个人场景够用嵌入模型bge-small-zhtext-embedding-3中文效果好、体积小、本地可跑文档加载LangChain Loaders自己写解析支持 PDF/Word/Markdown 开箱即用前端StreamlitGradio、Web几行代码出界面适合个人用这里重点说两个决策。第一为什么用本地模型而不是云端 API。我的知识库里有很多个人笔记和工作资料虽然不是什么机密但我不希望它们被上传到第三方。Ollama 可以在本地跑 7B 级别的模型问答质量对个人使用完全够用而且断网也能用。第二为什么向量库选 Chroma 而不是 FAISS。FAISS 性能更强但它本质上是个索引库不负责存储和管理元数据。Chroma 自带持久化、元数据过滤、集合管理我新增文档、删除文档、按来源过滤都更方便个人场景下这点便利性比极致性能更重要。提示如果你机器内存小于 16G建议嵌入模型选更小的版本或者把向量库换成 FAISS 减少内存占用。我实测 8G 内存跑 bge-small Chroma 7B 模型会比较吃力。3. 知识库构建的核心细节与实操要点3.1 文档加载别小看这一步坑最多文档加载看起来就是“把文件读进来”但实际操作中这一步出的问题最多。我一开始直接把一个 200 页的 PDF 丢进去结果检索出来的内容全是断句和乱码。后来才明白加载器的选择直接决定了后续切分和检索的质量。LangChain 提供了大量 Loader我常用的几个是PyPDFLoader处理 PDF、UnstructuredMarkdownLoader处理 Markdown、Docx2txtLoader处理 Word、TextLoader处理纯文本。对于公众号文章这种网页内容我会先用工具保存成 Markdown 再加载这样能保留标题层级切分时更合理。这里有个关键细节PDF 里的表格和图片是重灾区。PyPDFLoader 提取表格时经常把行列搞乱图片则完全丢失。我的处理方式是对于表格密集的 PDF先用专门的工具转成 Markdown 再加载对于图片如果图片里有文字我会先用 OCR 提取出来作为文本补充。热词里有人问“RAG 知识库能存储图片吗”答案是向量库本身存的是文本向量图片需要先转成文字描述或 OCR 文本才能被检索。如果你的知识库以图片为主那需要额外接一个多模态模型来生成图片描述这是另一个话题了。from langchain_community.document_loaders import PyPDFLoader, UnstructuredMarkdownLoader from langchain_community.document_loaders import Docx2txtLoader def load_documents(file_path): if file_path.endswith(.pdf): loader PyPDFLoader(file_path) elif file_path.endswith(.md): loader UnstructuredMarkdownLoader(file_path) elif file_path.endswith(.docx): loader Docx2txtLoader(file_path) else: raise ValueError(f不支持的文件类型: {file_path}) return loader.load()3.2 文本切分chunk_size 和 overlap 怎么定切分是 RAG 里最容易被忽视、但影响最大的环节。切太大检索出来的内容冗余模型容易被无关信息干扰切太小语义不完整检索出来的片段答非所问。我试过好几组参数最后稳定在chunk_size500、chunk_overlap100。为什么是这两个数先说 chunk_size。中文一个字符大约对应 1 到 1.5 个 token500 字符大概是 500 到 750 token这个长度能容纳一个完整的段落或一个小节语义相对完整。如果切到 1000 以上检索时经常捞出一大段里只有一两句相关的内容浪费上下文窗口。如果切到 200 以下一句话被切成两半检索出来的片段读都读不通。再说 overlap。overlap 的作用是防止关键信息刚好落在切分边界上被切断。我设 100也就是相邻两个 chunk 有 100 字符的重叠这样即使边界处有重要内容至少有一个 chunk 能完整包含它。overlap 不宜太大否则向量库里会有大量重复内容检索时返回一堆相似片段反而降低信息密度。from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap100, separators[\n\n, \n, 。, , , , , , ] ) chunks text_splitter.split_documents(documents)注意 separators 的顺序。RecursiveCharacterTextSplitter 会按顺序尝试用分隔符切分优先用段落分隔\n\n不行再用换行再不行用中文句号。这个顺序很重要它保证了切分尽量在语义边界处发生而不是硬切。我特意把中文标点加进去因为默认的分隔符是给英文设计的中文文档直接用它切出来会很碎。3.3 向量化与存储嵌入模型的选择嵌入模型决定了“语义检索”的质量。我对比过几个中文嵌入模型最后选了bge-small-zh。它的维度是 512模型体积只有几十兆本地 CPU 就能跑检索效果在中文短文本上表现不错。如果你追求更好的效果可以换 bge-large-zh但推理速度会慢不少内存占用也更大。向量化的时候有个细节批量处理。如果你有几千个 chunk一个一个嵌入会很慢。LangChain 的向量库接口支持批量添加我一般设 batch_size64兼顾速度和内存。另外嵌入过程最好加个进度条不然你不知道跑到哪了几千个 chunk 跑几分钟是常事。from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import Chroma embeddings HuggingFaceEmbeddings( model_nameBAAI/bge-small-zh-v1.5, model_kwargs{device: cpu}, encode_kwargs{normalize_embeddings: True} ) vectorstore Chroma.from_documents( documentschunks, embeddingembeddings, persist_directory./knowledge_db, collection_namepersonal_kb ) vectorstore.persist()normalize_embeddingsTrue这个参数别漏了。它把向量归一化到单位长度这样计算余弦相似度时等价于内积检索更稳定。我第一次跑的时候没加结果相似度分数忽高忽低排查了半天才发现是归一化的问题。3.4 检索策略相似度检索还是 MMR检索环节我踩过一个典型的坑相似度检索会返回大量重复内容。比如我的知识库里有好几篇讲 RAG 的文章我问一个 RAG 相关的问题相似度检索把这几篇文章里最相似的片段全捞出来了内容高度重复模型拿到一堆同质信息回答反而没有增量。后来我换成了MMR最大边际相关性检索。MMR 的思路是既要和问题相关又要和已选片段不重复。它会在“相关性”和“多样性”之间做平衡。LangChain 里用search_typemmr就能开启配合fetch_k和k两个参数。fetch_k是先捞多少候选k是最终返回多少。我一般设 fetch_k20、k5先捞 20 个候选再从中挑 5 个既相关又不重复的。retriever vectorstore.as_retriever( search_typemmr, search_kwargs{k: 5, fetch_k: 20, lambda_mult: 0.7} )lambda_mult控制相关性和多样性的权重1 表示完全看相关性0 表示完全看多样性。我设 0.7偏向相关性但保留一定多样性。这个值可以根据你的知识库特点调如果资料重复度高就调低一点。4. Agent 与 RAG 的整合实现4.1 把检索器包装成工具Agent 要能调用检索第一步是把检索器包装成一个“工具”。LangChain 的create_retriever_tool就是干这个的它把检索器变成一个带名称和描述的 ToolAgent 根据描述决定什么时候调用它。这个描述很关键描述写得越清楚Agent 判断越准。我一开始描述写得很笼统结果 Agent 经常在不该检索的时候乱检索或者该检索的时候不检索。from langchain.tools.retriever import create_retriever_tool retriever_tool create_retriever_tool( retriever, namepersonal_knowledge_search, description用于检索个人知识库中的文档内容。当用户询问关于其个人笔记、 收藏文章、技术文档中的具体信息时使用此工具。 输入应该是具体的问题或关键词。 )描述里我特意强调了“个人笔记、收藏文章、技术文档”这样 Agent 能区分“通用知识问题”和“个人资料问题”。比如用户问“什么是向量数据库”Agent 可能直接用自己的知识回答用户问“我笔记里关于向量数据库的记录”Agent 就知道该调工具了。4.2 Agent 的提示词设计Agent 的提示词决定了它的行为模式。我用的是一套偏“谨慎”的提示词核心原则是能查就查不确定就查查完要标注来源。因为个人知识库场景下用户最怕的是模型胡编乱造明明知识库里没有它却编一个答案出来。from langchain.agents import create_react_agent, AgentExecutor from langchain import hub prompt hub.pull(hwchase17/react-chat) agent create_react_agent(llm, [retriever_tool], prompt) agent_executor AgentExecutor( agentagent, tools[retriever_tool], verboseTrue, max_iterations5, handle_parsing_errorsTrue )max_iterations5是防止 Agent 陷入死循环。我遇到过 Agent 反复检索同一个问题的情况加了迭代上限后就不会无限跑下去了。handle_parsing_errorsTrue是容错有时候模型输出的格式不对解析会报错开启这个参数后 LangChain 会把错误信息返回给模型让它重试而不是直接崩掉。4.3 对话记忆的处理个人知识库问答通常是多轮的用户会追问。比如先问“我笔记里关于 RAG 的内容”再问“那里面提到的工具是哪些”。如果 Agent 没有记忆第二轮就不知道“那里面”指的是什么。LangChain 提供了ConversationBufferMemory来保存对话历史但要注意历史不能无限增长否则会撑爆上下文窗口。我的做法是用ConversationBufferWindowMemory只保留最近 5 轮对话。这样既能支持追问又不会让上下文无限膨胀。另外历史消息在传给模型前最好做一下截断太长的历史对检索判断反而是干扰。from langchain.memory import ConversationBufferWindowMemory memory ConversationBufferWindowMemory( memory_keychat_history, k5, return_messagesTrue )4.4 完整问答流程串起来把上面这些串起来一个完整的问答流程是这样的用户提问 → Agent 读取对话历史 → 判断是否需要检索 → 调用检索工具 → 拿到相关片段 → 结合片段生成回答 → 保存对话历史。我用 Streamlit 做了个简单界面输入框提问下面显示回答和引用的来源文档。import streamlit as st st.title(个人知识库问答机器人) if messages not in st.session_state: st.session_state.messages [] for msg in st.session_state.messages: st.chat_message(msg[role]).write(msg[content]) if prompt : st.chat_input(问点什么...): st.session_state.messages.append({role: user, content: prompt}) st.chat_message(user).write(prompt) with st.spinner(检索中...): response agent_executor.invoke({ input: prompt, chat_history: st.session_state.messages[:-1] }) answer response[output] st.session_state.messages.append({role: assistant, content: answer}) st.chat_message(assistant).write(answer)5. 常见问题与排查技巧实录5.1 检索不到相关内容怎么办这是最常见的问题。我总结了几种原因和对策。第一种文档根本没被正确加载。排查方法是打印 chunks 的数量和内容看看是不是空的或者乱码。第二种切分太碎导致语义丢失。把 chunk_size 调大试试。第三种嵌入模型和查询语言不匹配。如果你用英文嵌入模型处理中文效果会很差换成中文模型。第四种问题表述和文档表述差异太大。比如文档里写“向量检索”你问“语义搜索”虽然意思相近但词面不同。这种情况可以试试查询改写让模型先把你的问题改写成几个不同表述再检索。5.2 回答里出现知识库没有的内容这是 RAG 的经典问题叫“幻觉”。模型会把自己知道的东西和检索到的内容混在一起甚至编造。我的对策是在提示词里明确要求只根据检索到的内容回答如果检索内容不足以回答就直说“知识库中没有相关信息”。另外把检索到的原文片段一起展示给用户让用户自己核对这样即使模型说错了用户也能发现。5.3 速度太慢怎么优化本地跑模型速度是绕不开的问题。我做了几件事来提速。第一嵌入模型用小的bge-small 比 bge-large 快好几倍效果差距在个人场景下可以接受。第二向量库持久化不要每次启动都重新索引Chroma 的 persist 就是干这个的。第三检索时限制 k 值k5 比 k10 快而且信息密度更高。第四模型量化Ollama 支持量化模型4-bit 量化的 7B 模型速度能快一倍质量损失很小。5.4 常见问题速查表问题现象可能原因排查方法解决方向检索结果为空文档未加载/切分过碎打印 chunks 数量检查加载器、调大 chunk_size回答与问题无关检索质量差看检索返回的片段换嵌入模型、用 MMR回答编造内容提示词约束不足检查提示词加“仅根据检索内容回答”响应很慢模型太大/未量化看推理耗时换小模型、用量化版多轮对话失忆记忆未配置检查 memory加 ConversationBufferMemory重复内容多相似度检索看返回片段改用 MMR 检索5.5 几个我踩过的坑坑一PDF 里的换行符。PDF 提取出来的文本经常在句子中间有换行导致切分时把一句话切成两半。我的处理是在加载后先做一次文本清洗把单个换行替换成空格保留双换行作为段落分隔。坑二向量库的 collection 名称。Chroma 默认 collection 名是 “langchain”如果你多次运行代码但没改名字会往同一个 collection 里重复添加数据。我第二次跑的时候发现检索结果里全是重复内容查了半天才发现是这个问题。每次重建索引前记得先删掉旧的 collection。坑三Agent 的 verbose 输出。调试时开 verboseTrue 很有用能看到 Agent 每一步在干什么。但生产环境记得关掉不然日志会刷屏而且会拖慢速度。坑四中文标点的处理。默认的文本切分器对中文标点支持不好我手动把中文句号、问号、感叹号加进 separators 后切分质量明显提升。6. 后续可以怎么扩展这个项目跑通之后我陆续加了一些东西。第一多知识库隔离。把工作资料和个人笔记分成两个 collectionAgent 根据问题类型选择查哪个。第二定时增量索引。写了个脚本监控文件夹变化有新文件就自动加载、切分、入库不用手动跑。第三来源高亮。回答里标注每句话来自哪个文档的哪一段点进去能看原文。第四查询改写。让模型先把用户问题改写成多个检索查询提升召回率。如果你也想动手我的建议是先把最小闭环跑通一个文档、一次检索、一个回答。跑通之后再逐步加功能。我见过太多人一上来就想做“全能助手”结果卡在环境配置上就放弃了。这个项目最核心的价值不是技术多复杂而是它真的能解决“我自己的资料找不到”这个具体问题。从最小可用版本开始边用边改比一次性设计完美架构要靠谱得多。
返回列表