ARTICLE DETAIL

资讯详情

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

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

从零搭建个人知识库问答机器人:RAG与LangChain实战 1. 为什么我要从零搭一个个人知识库问答机器人我手头攒了大概七八年的技术笔记散落在各种地方早期是印象笔记后来迁到 Obsidian中间还有一堆 Markdown 文件躺在硬盘里加上浏览器书签、PDF 论文、公众号收藏总量保守估计上千篇。以前找东西靠全文搜索关键词记得住还行记不住就只能一篇篇翻。真正让我下决心动手的是某次排查一个线上问题时我明明记得自己两年前写过一篇关于连接池参数调优的笔记但死活搜不出来——因为当时用的词和现在脑子里想的词对不上。这就是传统关键词搜索的死穴它匹配的是字面不是语义。而大模型恰好补上了这块短板。把笔记切块、向量化、存进向量库用户提问时先检索出语义最相关的片段再交给大模型组织成答案——这套流程就是现在被讲烂了的 RAG检索增强生成。但讲烂了和跑通了是两回事我前后折腾了三个周末踩的坑比想象中多得多。这篇东西写给两类人一类是手里有大量个人文档、想搭个能问能答的私有助手但不知道从哪下手的另一类是把 RAG 当黑盒调过 API、但没自己完整搭过链路的。我会把整个链路拆开讲包括我为什么这么选型、每一步的参数怎么定、以及那些文档里不会写但实际会卡住你的细节。核心关键词就几个Agent、知识库、问答机器人、RAG、LangChain全部围绕它们展开。先说清楚这个机器人到底能干什么。它不是那种你问一句它编一句的聊天玩具而是严格基于我自己的文档回答——问它某个配置怎么写它会告诉我出自哪篇笔记问它一个我文档里没有的东西它应该老实说没找到相关内容而不是硬编。这个不瞎编的约束是整个项目里最难也最值钱的部分。2. 拆解需求个人知识库问答到底难在哪2.1 和通用聊天机器人的本质区别很多人第一反应是我直接把文档贴给大模型不就行了。文档少的时候确实行但我的笔记加起来几十万字任何模型的上下文窗口都塞不下就算塞得下成本和延迟也完全不可接受。更关键的是每次提问都全量喂进去模型会被大量无关内容干扰答案质量反而下降。所以核心矛盾是上下文窗口有限但知识总量无限。RAG 的解法是把全量塞入换成按需检索——先从一个可检索的索引里捞出最相关的几段只把这几段喂给模型。这就把问题从模型能不能记住转化成了检索能不能找对。我后来所有的优化八成精力都花在检索质量上而不是模型本身。2.2 个人场景特有的三个约束企业级 RAG 和个人知识库 RAG 的诉求差别很大不能照搬。我总结了自己场景下的三个硬约束数据格式极度杂乱有纯 Markdown、有带 frontmatter 的、有从网页剪藏下来的带一堆 HTML 标签的、还有扫描版 PDF。清洗这步的工作量远超预期。没有标注数据企业可以找人标一批问题-答案对来评估检索效果我一个人没有这个条件只能靠肉眼抽查和少量手工构造的测试问题。算力和预算有限不可能上大规模 GPU 集群本地跑 embedding 模型、调用云端大模型 API 是更现实的选择。这三个约束直接决定了后面的技术选型。比如为什么我选本地 embedding 而不是全走云 API就是因为笔记里有不少涉及内部项目细节的内容向量化这步放在本地更放心而且省掉了每次全量重建索引的 API 费用。2.3 一个容易被忽略的需求可追溯问答机器人最容易让人不信任的地方是它给了一个答案但你不知道从哪来的。对个人知识库来说答案必须能溯源到具体文档和段落。这不只是为了验证更是因为很多时候我需要的不是一句总结而是那篇笔记的原文在哪我要自己再看一遍。所以我在设计时就把返回引用来源当成一等公民而不是事后补的功能。检索阶段保留每个 chunk 的元数据文件路径、标题、行号生成阶段要求模型在答案里标注引用编号。这个决定后面会反复影响我的数据结构设计。3. 技术选型LangChain 这条链路我为什么这么搭3.1 为什么是 LangChain 而不是自己撸坦白说RAG 的核心逻辑不复杂切块、向量化、检索、拼 prompt、调模型。理论上几百行代码能写完不用框架也行。我一开始也想过自己写但很快发现重复造轮子的地方太多文档加载器要适配十几种格式、向量库要对接不同后端、prompt 模板要管理、还要处理流式输出和重试。LangChain 的价值不在于它多聪明而在于它把这些胶水活标准化了。它的Document抽象、TextSplitter、VectorStore接口、Retriever协议让我换一个向量库或换一个模型时改动量控制在几行。对个人项目来说降低后期维护成本比初期少写代码更重要因为你一定会反复调整。不过要提醒一句LangChain 版本迭代快API 经常变网上很多教程是旧版本的。我踩过最坑的一次是照着半年前的教程写RetrievalQA结果新版里这个类已经被标记废弃推荐用 LCELLangChain Expression Language重写。所以看文档一定要认准版本号。3.2 各环节的选型对照我把关键环节的选型和理由整理成一张表方便你对照自己的场景调整环节我的选择备选方案选择理由文档加载LangChain 各类 Loader 自定义清洗自己写解析格式多Loader 省事脏数据自己补文本切分RecursiveCharacterTextSplitter按标题切、语义切对 Markdown 友好保留段落边界Embedding本地 bge-small-zhOpenAI embedding、bge-large中文效果好、本地跑、免费、够快向量库ChromaFAISS、Qdrant、Milvus轻量、零配置、支持元数据过滤大模型云端 API按量付费本地 7B 模型生成质量差距明显个人量小成本可控编排框架LangChain LCELLlamaIndex、自己写生态成熟社区案例多这张表不是标准答案是在个人、中文、预算有限、注重隐私这四个条件下的最优解。如果你的文档是英文为主embedding 可以换成英文更强的模型如果你追求极致轻量向量库用 FAISS 也行就是元数据过滤没 Chroma 方便。3.3 关于Agent这个词的澄清标题里带了 Agent但我要泼盆冷水这个项目严格来说不是 Agent是 RAG 流水线。Agent 的核心特征是模型能自主决定调用哪些工具、走几步、什么时候停而我的问答机器人流程是固定的——检索、拼上下文、生成没有自主决策环节。那为什么还叫 Agent 实践因为它是通往真正 Agent 的第一步。当你有了一个可靠的知识库检索工具下一步就可以把它包装成一个 tool让一个真正的 Agent 在需要查资料时自主调用它。我后面会讲怎么从这个固定流水线平滑升级到带工具调用的 Agent。先把地基打牢别一上来就追求自主智能体那是本末倒置。4. 动手实现从文档到可问答的完整链路4.1 环境准备与依赖安装我用的 Python 3.11太新的版本有些库还没适配太旧的又缺特性。虚拟环境一定要建LangChain 生态的依赖冲突是出了名的。python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install langchain langchain-community langchain-chroma pip install sentence-transformers chromadb pip install unstructured markdown beautifulsoup4这里有个坑langchain主包和langchain-community要版本对齐否则会出现ImportError。我建议装完后pip freeze记一下版本方便以后复现。另外sentence-transformers第一次跑会下载模型权重国内网络可能慢可以提前把模型下到本地缓存目录。4.2 文档加载与清洗脏数据才是真正的敌人加载这步看着简单实际最耗时间。我的笔记来源有三种处理方式完全不同Obsidian 的 Markdown相对干净但带 frontmatter 和大量[[双链]]语法需要把双链转成普通文本否则会污染检索。网页剪藏最脏混着导航栏、广告、脚本残留。我用 BeautifulSoup 先剥掉script、style、nav标签再提取正文。PDF扫描版没法直接抽文字得先 OCR文字版用unstructured抽但表格和公式经常乱。from langchain_community.document_loaders import DirectoryLoader, TextLoader import re def clean_markdown(text): # 去掉 frontmatter text re.sub(r^---.*?---, , text, flagsre.DOTALL) # 双链转普通文本 text re.sub(r\[\[(.*?)\]\], r\1, text) # 去掉多余空行 text re.sub(r\n{3,}, \n\n, text) return text.strip() loader DirectoryLoader(./notes, glob**/*.md, loader_clsTextLoader) docs loader.load() for d in docs: d.page_content clean_markdown(d.page_content)注意清洗规则一定要在切分之前做否则脏字符会被切进 chunk 里检索时匹配到一堆噪声。我一开始偷懒没清双链结果问XX 怎么配置检索出来的片段全是[[XX]]这种链接语法模型看了也懵。4.3 文本切分chunk 大小直接决定检索质量切分是 RAG 里最被低估的一步。切太大一个 chunk 里混了好几个主题检索出来噪声多切太小一句话被拆散语义不完整。我试过 256、512、1024 三档最后定在chunk_size500chunk_overlap80。为什么是 500因为中文一个字符大约对应 1 个 token 上下500 字符差不多 500 token正好是一段完整论述的长度塞进模型上下文也不占地方。overlap 设 80 是为了防止关键信息正好卡在切分边界上被切断——相邻 chunk 有重叠边界信息就不会丢。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap80, separators[\n## , \n### , \n\n, \n, 。, ], ) chunks splitter.split_documents(docs)注意separators的顺序很关键。我把 Markdown 标题放在最前面这样切分器会优先在标题处断开尽量保证一个 chunk 不跨主题。这个细节对技术笔记特别有用因为笔记天然是按标题组织的。4.4 向量化与入库本地模型的实际表现Embedding 我选了bge-small-zh理由是中文语义检索效果好、模型小约 100MB、CPU 上跑也够快。上千篇笔记全量向量化我的老笔记本大概跑了十几分钟可以接受。from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_chroma 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./chroma_db, collection_namemy_notes, )normalize_embeddingsTrue这行别省。它把向量归一化到单位长度这样余弦相似度计算更稳定检索结果一致性更好。我对比过开和不开开了之后同一问题的 top-3 结果明显更聚焦。入库时 Chroma 会自动把每个 chunk 的元数据来源文件、chunk 序号一起存进去这就是后面做溯源的基础。如果你想让检索支持按目录过滤可以在切分时给每个 chunk 的 metadata 加上category字段检索时用filter参数限定范围。4.5 检索与生成把链路串起来检索这步我用了 MMR最大边际相关性而不是纯相似度。纯相似度的问题是top-5 结果可能全是同一篇笔记里的相邻段落信息冗余。MMR 在保证相关性的同时惩罚重复让结果更多样。retriever vectorstore.as_retriever( search_typemmr, search_kwargs{k: 5, fetch_k: 20, lambda_mult: 0.5}, )fetch_k20表示先取 20 个候选再从中挑 5 个最多样的lambda_mult0.5是相关性和多样性的平衡系数0 偏多样1 偏相关0.5 是我实测比较均衡的值。生成环节用 LCEL 把检索和模型串起来from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnablePassthrough prompt ChatPromptTemplate.from_template( 基于以下资料回答问题如果资料中没有相关信息直接说未找到相关内容不要编造。 回答时用 [1][2] 标注引用来源。 资料 {context} 问题{question} ) def format_docs(docs): return \n\n.join( f[{i1}] {d.page_content} for i, d in enumerate(docs) ) chain ( {context: retriever | format_docs, question: RunnablePassthrough()} | prompt | llm | StrOutputParser() )prompt 里那句如果资料中没有相关信息直接说未找到是整个防幻觉的关键。我试过不加这句模型遇到检索不到的问题时会用它的通用知识硬答看起来很像那么回事但其实是错的——对个人知识库来说这种自信的错误比不知道危险得多。5. 实测中暴露的问题与针对性优化5.1 检索不准问题往往出在查询本身跑通之后我拿十几个真实问题测发现一个规律用户提问的措辞和文档里的措辞经常对不上。比如我问连接池怎么调优但笔记里写的是数据库连接数配置建议字面差异大向量检索也可能漏掉。解决办法是查询改写query rewriting先让模型把用户问题改写成几个不同角度的查询分别检索再合并结果。这一步能明显提升召回率代价是多一次模型调用。我实测下来对模糊问题提升最明显对精确问题反而可能引入噪声所以做成了可开关的选项。另一个技巧是混合检索向量检索负责语义BM25 负责关键词两者结果加权融合。对于包含专有名词、代码标识符的问题关键词检索往往比向量更准因为那些词向量模型没见过。LangChain 里有EnsembleRetriever可以直接组合两种检索器。5.2 答案不溯源引用编号对不上早期版本我让模型标引用结果它标的编号经常和实际来源对不上比如答案里写 [2]但 [2] 那段其实没支撑这个结论。根因是模型看不到编号和文档的对应关系它只是猜。修复方法是在拼 context 时就把编号和来源绑定并且要求模型引用时只允许用给定的编号。更进一步我在返回结果里把每个引用编号对应的文件路径和原文片段一起返回前端可以做成可点击的。这样即使模型标错了用户也能一眼看出来。5.3 长文档被截断上下文塞不下怎么办有些问题需要综合多篇笔记检索回来的 5 个 chunk 加起来可能超过模型上下文限制。我的处理是按相关性排序后从高到低塞塞满为止而不是平均分配。因为最相关的那个 chunk 往往就包含了核心答案后面的更多是补充。如果确实需要跨多篇综合我会触发一个多轮检索逻辑先检索一轮生成初步答案如果答案里出现资料不足之类的信号就用初步答案里的关键词再检索一轮。这其实就是往 Agent 方向靠了——让系统根据中间结果决定下一步动作。5.4 增量更新别每次全量重建索引笔记是持续增长的如果每次加一篇就全量重建向量库上千篇跑一遍太浪费时间。Chroma 支持按 id 增删我给每个 chunk 生成一个基于文件路径和内容哈希的稳定 id新增文件只向量化新增部分修改文件先删旧 chunk 再插新的。import hashlib def make_id(doc): raw doc.metadata[source] doc.page_content return hashlib.md5(raw.encode()).hexdigest() ids [make_id(c) for c in chunks] vectorstore.add_documents(chunks, idsids)这个 id 设计还有个好处内容没变的 chunk 重复入库时 id 相同Chroma 会自动去重不会产生重复条目。6. 从固定流水线走向真正的 Agent6.1 把知识库检索包装成一个工具前面说过这个项目本质是固定流水线。要让它变成 Agent第一步是把检索能力封装成一个标准工具让模型能自主决定什么时候调用。from langchain_core.tools import tool tool def search_notes(query: str) - str: 搜索个人知识库输入查询语句返回相关笔记片段。 docs retriever.invoke(query) return format_docs(docs)有了这个 tool再配一个能调用工具的模型就得到了一个最小 Agent它收到问题后自己判断要不要查知识库、查几次、用什么查询词。这比固定流水线灵活得多比如遇到对比 A 和 B这种问题它可以分别查 A 和 B 再综合。6.2 什么时候该上 Agent什么时候别上我的经验是流程确定、步骤固定的场景别上 Agent。固定流水线更快、更便宜、更可控。Agent 的价值在于处理不确定要走几步的任务比如需要多轮检索、需要组合多个工具、需要根据中间结果调整策略。对个人知识库问答来说80% 的问题是单轮检索能解决的上 Agent 反而增加延迟和不确定性。我的做法是保留固定流水线作为默认路径只在检测到复杂问题时比如问题里出现对比综合分别这类词才切到 Agent 模式。这种分级处理的思路比无脑上 Agent 务实得多。6.3 并发与性能个人场景其实不用太操心热词里有人问AI Agent 怎么扛并发但个人知识库场景基本不涉及。我的机器人就是自己用QPS 撑死个位数。真正影响体验的是单次响应延迟而延迟大头在两处embedding 计算和模型生成。embedding 这步可以缓存——相同查询的向量存起来下次直接取。模型生成这步用流式输出能显著改善体感虽然总时间没变但用户看到字一个个蹦出来等待感会低很多。LangChain 的astream方法直接支持流式前端配合 SSE 就能实现打字机效果。7. 几个我踩过、你大概率也会踩的坑第一个坑是中文标点导致的切分异常。RecursiveCharacterTextSplitter默认的 separators 是英文标点中文句号。不在列表里导致切分器找不到断点只能硬切。解决办法就是像前面那样把中文标点显式加进 separators。第二个坑是embedding 模型和检索语言不匹配。我一开始图省事用了英文模型结果中文检索效果惨不忍睹语义相近的中文句子向量距离很远。换成中文模型后立刻正常。选 embedding 模型时一定要看它的训练语料是否覆盖你的文档语言。第三个坑是prompt 里的指令被长 context 淹没。当 context 很长时模型容易忽略开头的指令尤其是不要编造这种约束。我的应对是把关键指令放在 context 之后、问题之前因为模型对靠近问题位置的指令更敏感。这个位置调整让防幻觉效果提升了不少。第四个坑是元数据丢失。切分后如果忘了把原始文件的 metadata 传递到每个 chunk后面就做不了溯源。LangChain 的 splitter 默认会继承父文档的 metadata但如果你自己写了切分逻辑一定要手动带上。第五个坑是过度依赖单一检索结果。我一度只看 top-1 结果觉得最相关的就够了。实际测试发现正确答案经常在 top-3 到 top-5 里top-1 有时是标题匹配但内容不相关的段落。所以 k 值别设太小5 是个比较稳的起点。8. 后续可以怎么继续折腾这套东西跑通之后能扩展的方向不少。我目前在做的是给 chunk 加上标题层级信息——把 Markdown 的 H1/H2/H3 路径拼进 chunk 内容里这样检索时标题里的关键词也能参与匹配对结构化笔记提升明显。另一个方向是引入重排序rerank。检索出 top-20 后用一个专门的重排模型对结果精排取 top-5 给生成模型。重排模型比 embedding 更懂语义相关性能把真正有用的片段顶上来。代价是多一次模型推理但对质量要求高的场景值得。再远一点就是前面说的多工具 Agent除了知识库检索再挂上计算器、代码执行、网页摘要等工具让它能处理更复杂的任务。但我的建议始终是——先把检索质量打磨好再谈 Agent 的花活。检索不准工具再多也是空中楼阁。这个项目最大的收获不是学会了某个框架而是真正理解了检索质量决定 RAG 上限这句话的分量。
返回列表