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 技术栈最终选型与理由
我把选型过程整理成了一张表,方便你对照自己的情况调整:
| 组件 | 我的选择 | 备选方案 | 选择理由 |
|---|---|---|---|
| 编排框架 | LangChain | LlamaIndex、Haystack | 生态最全,Agent 和 RAG 集成成熟,中文资料多 |
| 大模型 | 本地 Ollama 部署 | 云端 API | 个人知识库涉及隐私,本地跑数据不出机器 |
| 向量库 | Chroma | FAISS、Milvus | 轻量、零配置、支持持久化,个人场景够用 |
| 嵌入模型 | bge-small-zh | text-embedding-3 | 中文效果好、体积小、本地可跑 |
| 文档加载 | LangChain Loaders | 自己写解析 | 支持 PDF/Word/Markdown 开箱即用 |
| 前端 | Streamlit | Gradio、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_size=500、chunk_overlap=100。
为什么是这两个数?先说 chunk_size。中文一个字符大约对应 1 到 1.5 个 token,500 字符大概是 500 到 750 token,这个长度能容纳一个完整的段落或一个小节,语义相对完整。如果切到 1000 以上,检索时经常捞出一大段里只有一两句相关的内容,浪费上下文窗口。如果切到 200 以下,一句话被切成两半,检索出来的片段读都读不通。
再说 overlap。overlap 的作用是防止关键信息刚好落在切分边界上被切断。我设 100,也就是相邻两个 chunk 有 100 字符的重叠,这样即使边界处有重要内容,至少有一个 chunk 能完整包含它。overlap 不宜太大,否则向量库里会有大量重复内容,检索时返回一堆相似片段,反而降低信息密度。
from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=100, 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_size=64,兼顾速度和内存。另外,嵌入过程最好加个进度条,不然你不知道跑到哪了,几千个 chunk 跑几分钟是常事。
from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import Chroma embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-small-zh-v1.5", model_kwargs={'device': 'cpu'}, encode_kwargs={'normalize_embeddings': True} ) vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./knowledge_db", collection_name="personal_kb" ) vectorstore.persist()normalize_embeddings=True这个参数别漏了。它把向量归一化到单位长度,这样计算余弦相似度时等价于内积,检索更稳定。我第一次跑的时候没加,结果相似度分数忽高忽低,排查了半天才发现是归一化的问题。
3.4 检索策略:相似度检索还是 MMR
检索环节我踩过一个典型的坑:相似度检索会返回大量重复内容。比如我的知识库里有好几篇讲 RAG 的文章,我问一个 RAG 相关的问题,相似度检索把这几篇文章里最相似的片段全捞出来了,内容高度重复,模型拿到一堆同质信息,回答反而没有增量。
后来我换成了MMR(最大边际相关性)检索。MMR 的思路是:既要和问题相关,又要和已选片段不重复。它会在“相关性”和“多样性”之间做平衡。LangChain 里用search_type="mmr"就能开启,配合fetch_k和k两个参数。fetch_k是先捞多少候选,k是最终返回多少。我一般设 fetch_k=20、k=5,先捞 20 个候选,再从中挑 5 个既相关又不重复的。
retriever = vectorstore.as_retriever( search_type="mmr", 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就是干这个的,它把检索器变成一个带名称和描述的 Tool,Agent 根据描述决定什么时候调用它。这个描述很关键,描述写得越清楚,Agent 判断越准。我一开始描述写得很笼统,结果 Agent 经常在不该检索的时候乱检索,或者该检索的时候不检索。
from langchain.tools.retriever import create_retriever_tool retriever_tool = create_retriever_tool( retriever, name="personal_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( agent=agent, tools=[retriever_tool], verbose=True, max_iterations=5, handle_parsing_errors=True )max_iterations=5是防止 Agent 陷入死循环。我遇到过 Agent 反复检索同一个问题的情况,加了迭代上限后就不会无限跑下去了。handle_parsing_errors=True是容错,有时候模型输出的格式不对,解析会报错,开启这个参数后 LangChain 会把错误信息返回给模型让它重试,而不是直接崩掉。
4.3 对话记忆的处理
个人知识库问答通常是多轮的,用户会追问。比如先问“我笔记里关于 RAG 的内容”,再问“那里面提到的工具是哪些”。如果 Agent 没有记忆,第二轮就不知道“那里面”指的是什么。LangChain 提供了ConversationBufferMemory来保存对话历史,但要注意历史不能无限增长,否则会撑爆上下文窗口。
我的做法是用ConversationBufferWindowMemory,只保留最近 5 轮对话。这样既能支持追问,又不会让上下文无限膨胀。另外,历史消息在传给模型前最好做一下截断,太长的历史对检索判断反而是干扰。
from langchain.memory import ConversationBufferWindowMemory memory = ConversationBufferWindowMemory( memory_key="chat_history", k=5, return_messages=True )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 值,k=5 比 k=10 快,而且信息密度更高。第四,模型量化,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 输出。调试时开 verbose=True 很有用,能看到 Agent 每一步在干什么。但生产环境记得关掉,不然日志会刷屏,而且会拖慢速度。
坑四:中文标点的处理。默认的文本切分器对中文标点支持不好,我手动把中文句号、问号、感叹号加进 separators 后,切分质量明显提升。
6. 后续可以怎么扩展
这个项目跑通之后,我陆续加了一些东西。第一,多知识库隔离。把工作资料和个人笔记分成两个 collection,Agent 根据问题类型选择查哪个。第二,定时增量索引。写了个脚本监控文件夹变化,有新文件就自动加载、切分、入库,不用手动跑。第三,来源高亮。回答里标注每句话来自哪个文档的哪一段,点进去能看原文。第四,查询改写。让模型先把用户问题改写成多个检索查询,提升召回率。
如果你也想动手,我的建议是先把最小闭环跑通:一个文档、一次检索、一个回答。跑通之后再逐步加功能。我见过太多人一上来就想做“全能助手”,结果卡在环境配置上就放弃了。这个项目最核心的价值不是技术多复杂,而是它真的能解决“我自己的资料找不到”这个具体问题。从最小可用版本开始,边用边改,比一次性设计完美架构要靠谱得多。