ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

从零搭建个人知识库问答机器人:LangChain + FAISS 实战 RAG 1. 为什么我要从零搭一个个人知识库问答机器人我手头攒了大概七八年的技术笔记、项目复盘、会议纪要散落在 Markdown、PDF、Notion 导出文件、微信收藏里。以前靠 Everything 加 grep 硬搜关键词对不上就抓瞎。比如我想找上次那个限流方案是怎么算桶容量的搜限流能出一堆但真正有用的那条藏在某个复盘文档的第三段里关键词是令牌补充速率。这种语义层面的检索传统全文搜索根本搞不定。大模型出来之后我第一反应就是把这些私有资料喂进去做问答。但直接塞进上下文有几个硬伤一是贵二是长文档超上下文窗口三是每次问都要重传慢得离谱。所以正确的路子是RAG检索增强生成——先把知识切片、向量化、存进向量库提问时先检索出最相关的几段再连同问题一起交给大模型生成答案。这样既省钱又准还能溯源到原文。这篇要聊的就是我用LangChain FAISS搭的一套个人知识库问答机器人属于 Agent 实践系列的第一篇。它解决的问题很具体让 AI 基于我自己的资料回答问题而不是靠它脑子里那点训练数据瞎编。适合谁看有一定 Python 基础、想动手做 RAG 但不知道从哪下手的同学也适合已经在用 LangChain 但检索效果不理想、想调优的老哥。整套东西跑在本地数据不出门成本几乎为零。我踩过的坑不少切片切得太碎导致语义断裂、embedding 模型选错导致中文检索拉胯、FAISS 索引没持久化每次重启重算、prompt 没约束导致模型自由发挥。下面把这些一次性讲透。2. 整体架构设计与技术选型思路2.1 这套 RAG 系统的四个核心模块任何 RAG 系统拆开看都是四块加载Load→ 切分Split→ 向量化与存储Embed Store→ 检索生成Retrieve Generate。听起来简单但每一块的选型都会直接影响最终效果。加载把各种格式的文档读成纯文本。PDF 用 PyPDFMarkdown 直接读Word 用 python-docx。切分把长文本切成合适大小的块chunk。这是最容易被低估的一步切不好后面全白搭。向量化与存储用 embedding 模型把每个 chunk 转成向量存进 FAISS 索引。检索生成用户提问 → 问题向量化 → FAISS 找最相似的 top-k chunk → 拼进 prompt → 大模型生成答案。我选 LangChain 不是因为它是唯一选择而是它把这四块的抽象做得最顺DocumentLoader、TextSplitter、VectorStore、Retriever一套接口统一换组件成本低。FAISS 则是 Facebook 开源的向量检索库纯本地、无依赖服务、速度快个人知识库这种几万到几十万 chunk 的规模完全够用不需要上 Milvus、Qdrant 这种重型方案。2.2 为什么是 FAISS 而不是别的向量库很多人一上来就问要不要上专业向量数据库。我的判断标准很简单数据量、并发量、运维成本。方案适用规模部署成本我的评价FAISS万级到百万级 chunk极低一个文件个人知识库首选Chroma万级低可嵌入式上手快但持久化略麻烦Qdrant/Milvus百万级以上需要独立服务团队/生产环境才值得pgvector已有 PG 时中复用现有数据库时香个人知识库撑死几十万 chunkFAISS 的IndexFlatL2或IndexIVFFlat完全扛得住。而且 FAISS 索引可以save_local存成一个文件下次直接load_local不用重算 embedding。这一点对个人项目太重要了——重算一次 embedding 可能要几分钟到几十分钟。注意FAISS 本身只存向量和 ID不存原文。LangChain 的FAISS.load_local会同时存一个index.pkl保存 docstore所以原文是跟着一起持久化的别担心。2.3 embedding 模型的选择直接决定检索质量这是我最想强调的一点。很多人 RAG 效果差第一反应是换大模型其实问题往往出在 embedding 上。embedding 负责把文本映射到向量空间检索准不准全看它。中文场景我实测下来BAAI/bge-large-zh-v1.5或bge-m3效果明显好于 OpenAI 的text-embedding-ada-002对中文而言。如果追求本地化、零成本用sentence-transformers加载 bge 系列配合HuggingFaceEmbeddings就行。如果图省事且能接受 API 调用OpenAI 或国内的通义 embedding 也可以但中文长文本还是 bge 更稳。选 embedding 要看三个维度语言匹配度、向量维度、是否支持长文本。bge-large-zh 是 1024 维bge-m3 支持多语言且能吃 8192 token 的长文本后者更适合 chunk 切得比较大的场景。3. 核心细节解析与实操要点3.1 文档切分chunk_size 和 overlap 怎么定切分是 RAG 的命门。切太大检索出来的块里噪音多模型容易被无关内容带偏切太小一个完整语义被拆散检索到了也答不全。我的经验值中文技术文档 chunk_size 取 500-800 字符overlap 取 50-100 字符。为什么是这个量级因为一段完整的技术论述通常 300-600 字500-800 能覆盖一个完整小节overlap 保证跨块句子不被硬切断。LangChain 提供好几种 splitter我常用RecursiveCharacterTextSplitter它会按[\n\n, \n, 。, , , , ]这个优先级递归切尽量在段落、句子边界断开比粗暴按字数切好太多。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size600, chunk_overlap80, separators[\n\n, \n, 。, , , , , , ], length_functionlen, )实操心得Markdown 文档建议先用MarkdownHeaderTextSplitter按标题层级切再对每个 section 用 Recursive splitter 二次切。这样每个 chunk 都带着标题上下文检索时语义更完整。我试过直接切标题和正文被分开检索限流方案时经常漏掉正文。3.2 向量化与 FAISS 索引构建embedding 这步没什么花活但有两个细节要注意。一是批量处理别一条条 embed慢且浪费二是归一化如果用余弦相似度记得把向量归一化FAISS 的IndexFlatIP配合归一化向量等价于余弦相似度。from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import FAISS embeddings HuggingFaceEmbeddings( model_nameBAAI/bge-large-zh-v1.5, model_kwargs{device: cpu}, encode_kwargs{normalize_embeddings: True}, ) vectorstore FAISS.from_documents(chunks, embeddings) vectorstore.save_local(faiss_index)normalize_embeddingsTrue这行别省它把向量单位化配合 FAISS 默认的 L2 距离检索结果更稳定。我第一次没加发现相似度排序偶尔会乱。3.3 检索策略top-k、相似度阈值与 MMR检索不是简单取 top-k 就完事。取太少可能漏取太多噪音大。我一般k4 到 6再配合相似度阈值过滤掉明显不相关的。更高级一点用MMR最大边际相关性它在相关和多样之间做平衡避免检索出来的几段内容高度重复。比如你问限流算法有哪些普通检索可能返回三段都在讲令牌桶MMR 会强制返回令牌桶、漏桶、滑动窗口各一段。retriever vectorstore.as_retriever( search_typemmr, search_kwargs{k: 5, fetch_k: 20, lambda_mult: 0.5}, )fetch_k20表示先取 20 个候选再从中挑 5 个多样性最好的。lambda_mult越小越强调多样性0.5 是均衡点。3.4 prompt 设计让模型只基于检索内容回答这是防止幻觉的关键。如果不约束模型会拿它自己的知识瞎编检索到的内容形同虚设。我的 prompt 模板长这样PROMPT_TEMPLATE 你是一个严谨的知识库助手。请严格根据下面提供的上下文回答问题。 如果上下文中没有相关信息直接回答根据现有资料无法回答不要编造。 上下文 {context} 问题{question} 回答关键就三句严格根据上下文、没有就说没有、不要编造。实测下来加了这三句幻觉率大幅下降。另外建议在回答末尾附上引用来源chunk 的 metadata 里的文件名方便溯源。4. 完整实操流程与关键环节实现4.1 环境准备与依赖安装先把环境搭起来。Python 3.10 以上建议用虚拟环境隔离。python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install langchain langchain-community langchain-huggingface pip install faiss-cpu sentence-transformers pip install pypdf python-docx unstructured pip install openai # 如果用 OpenAI 兼容接口faiss-cpu是 CPU 版个人用足够有 GPU 且数据量大可以装faiss-gpu。sentence-transformers用来加载 bge 模型第一次运行会自动下载模型权重约 1.3GB耐心等。注意bge 模型下载慢的话可以提前从 HuggingFace 镜像站拉下来放本地然后用model_name/本地路径加载。别问我怎么知道的第一次下模型等到怀疑人生。4.2 文档加载与预处理我写了个统一的加载函数按扩展名分发from langchain_community.document_loaders import PyPDFLoader, TextLoader, Docx2txtLoader import os def load_documents(folder_path): docs [] for root, _, files in os.walk(folder_path): for f in files: path os.path.join(root, f) ext f.lower().split(.)[-1] try: if ext pdf: loader PyPDFLoader(path) elif ext docx: loader Docx2txtLoader(path) elif ext in (md, txt): loader TextLoader(path, encodingutf-8) else: continue loaded loader.load() for d in loaded: d.metadata[source] path docs.extend(loaded) except Exception as e: print(f加载失败 {path}: {e}) return docs每个 document 都打上source元数据后面引用溯源用得上。PDF 加载经常出问题扫描版 PDF 没有文字层PyPDF 读出来是空的这种得先做 OCR不在本文范围。4.3 构建索引并持久化把加载、切分、向量化串起来docs load_documents(./my_notes) chunks splitter.split_documents(docs) print(f共 {len(docs)} 个文档切出 {len(chunks)} 个 chunk) vectorstore FAISS.from_documents(chunks, embeddings) vectorstore.save_local(faiss_index)第一次跑可能要几分钟取决于文档量和机器性能。跑完faiss_index目录下会有index.faiss和index.pkl两个文件。以后启动直接加载vectorstore FAISS.load_local( faiss_index, embeddings, allow_dangerous_deserializationTrue )allow_dangerous_deserializationTrue是因为 pkl 反序列化有安全风险但本地自己生成的索引无所谓加上就行。4.4 问答链路串联最后把检索和生成串起来。我用 OpenAI 兼容接口举例你换成任何兼容的模型服务都行from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.runnables import RunnablePassthrough from langchain_core.output_parsers import StrOutputParser llm ChatOpenAI( modelgpt-4o-mini, base_urlhttps://your-endpoint/v1, api_keyyour-key, temperature0, ) prompt ChatPromptTemplate.from_template(PROMPT_TEMPLATE) def format_docs(docs): return \n\n.join( f[来源: {d.metadata.get(source,未知)}]\n{d.page_content} for d in docs ) chain ( {context: retriever | format_docs, question: RunnablePassthrough()} | prompt | llm | StrOutputParser() ) answer chain.invoke(令牌桶的补充速率怎么算) print(answer)temperature0是为了答案稳定知识库问答不需要创造性。format_docs把检索到的 chunk 拼成上下文并带上来源标记。4.5 参数计算实例chunk 数量估算假设你有 500 篇文档平均每篇 3000 字总字数 150 万。按 chunk_size600、overlap80 计算每个 chunk 净增 520 字chunk 数约 1500000 / 520 ≈ 2885 个。这个规模 FAISS 用IndexFlatL2毫秒级返回完全无压力。如果文档量涨到 10 万篇chunk 数到 60 万就该考虑IndexIVFFlat加聚类了把搜索复杂度从 O(n) 降到 O(sqrt(n))。5. 常见问题与排查技巧实录5.1 检索不准的三大元凶第一embedding 模型和语言不匹配。用英文模型检索中文效果惨不忍睹。换成 bge 中文系列立刻改善。第二chunk 切分不合理。切太碎语义断裂切太大噪音多。用MarkdownHeaderTextSplitter加二次切分让每个 chunk 语义完整。第三没做查询改写。用户口语化提问和文档书面语之间有 gap。可以加一步 query rewriting让大模型先把问题改写成更适合检索的形式再拿去检索。5.2 常见问题速查表现象可能原因解决方向检索结果完全不相关embedding 模型语言不匹配换 bge 中文模型答案答非所问prompt 没约束模型自由发挥加严格根据上下文约束每次启动重算很久索引没持久化save_local load_local相似度排序乱向量没归一化normalize_embeddingsTrue检索到重复内容没用 MMRsearch_typemmrPDF 读出来是空的扫描版无文字层先做 OCR 预处理内存爆了chunk 太多全量加载用 IVF 索引或分批5.3 我踩过的几个真实坑坑一overlap 设成 0。结果跨块的句子被硬切检索到前半句答不出后半句。后来设 80 字符 overlap问题消失。坑二用默认的CharacterTextSplitter。它只按单一分隔符切中文文档经常切出半句话。换成RecursiveCharacterTextSplitter并自定义中文分隔符后好很多。坑三忘了给 chunk 加 metadata。后来想溯源发现不知道答案来自哪个文件只能重建索引。所以加载时就把source打上一劳永逸。坑四temperature 设太高。一开始用默认 0.7答案每次都不一样还爱加戏。知识库问答必须 0。独家技巧检索时可以给不同来源的文档加权。比如你更信任自己写的笔记就给这些 chunk 的相似度乘个 1.1 的系数。LangChain 的EnsembleRetriever或者自定义 retriever 都能实现实测对结果排序有正向帮助。5.4 性能与并发的一点思考个人用单线程足够但如果想做成服务给团队用就得考虑并发。FAISS 的检索本身是线程安全的读操作瓶颈通常在 embedding 计算和 LLM 调用。embedding 可以用 batch 加缓存相同问题直接命中缓存LLM 调用是 IO 密集用异步或线程池并发。真要扛高并发把 FAISS 换成支持分布式检索的方案或者给检索层加 Redis 缓存热点查询结果。不过对个人知识库来说这些都属于过度设计先把效果调好再说。6. 后续可以怎么扩展这套基础版跑通之后能扩展的方向不少。一是加多轮对话记忆用 LangChain 的ConversationBufferMemory或 LangGraph 管理会话状态让追问能接上上下文。二是混合检索把向量检索和 BM25 关键词检索结合用EnsembleRetriever融合对专有名词、代码符号这类向量不敏感的内容提升明显。三是接入 Agent 能力让机器人能根据问题自主决定是查知识库、还是调工具算数、还是联网搜这就是从 RAG 走向 Agent 的关键一步也是我这个系列下一篇要聊的。我自己用下来最深的体会是RAG 的效果 80% 取决于数据质量和切分策略模型反而是次要的。与其花时间换更大的模型不如先把文档整理干净、把 chunk 切明白。另外别追求一步到位先跑通最小闭环再针对具体 bad case 逐个优化比一开始就设计复杂架构靠谱得多。
返回列表