☰
从零搭建个人知识库问答机器人:LangChain + FAISS 实战 RAG 应用
2026/10/5 9:30:55 网站建设 项目流程

1. 为什么我要从零搭一个个人知识库问答机器人

我平时有大量零散资料:技术文档、会议纪要、读书笔记、随手收藏的文章,散落在本地文件夹、笔记软件和浏览器书签里。真正要用的时候,翻半天找不到,找到了也记不清上下文。搜索引擎能解决一部分问题,但它搜的是全网,不是我的资料。于是我就想做一个只属于我自己的问答入口:我把资料丢进去,它来回答我的问题,并且告诉我答案是从哪份资料里来的。

这就是个人知识库问答机器人的出发点。它属于Agent应用里最经典、最容易上手、也最能体现价值的一类:RAG(检索增强生成)。核心链路不复杂——把资料切块、向量化、存进向量库,用户提问时先检索出最相关的片段,再交给大模型组织成自然语言回答。技术栈上我用的是LangChain做编排、FAISS做本地向量检索,模型侧可以接在线 API,也可以接本地模型。

这篇文章适合谁看?如果你写过一点 Python,听说过 LangChain 但没真正跑通过一个完整项目,或者你已经跑通了 demo 但不知道怎么做成一个能天天用的东西,那这篇就是写给你的。我会把整个搭建过程、参数选择、踩过的坑、以及后续怎么扩展,全部摊开讲。不堆概念,只讲我实际怎么做的、为什么这么做。

先说清楚这个机器人能做什么、不能做什么,避免预期错位。它能做的是:基于你提供的资料回答问题,答案有出处,不会凭空编造(前提是检索质量过关)。它不能做的是:回答你资料里没有的东西,也不能保证 100% 准确——它本质是"带着资料去问大模型",资料本身的质量决定了上限。理解这一点,后面的很多设计取舍就顺了。

2. 整体架构设计与技术选型思路

2.1 一条完整的数据流:从资料到答案

在动手写代码之前,我习惯先把数据流画清楚。这个项目的链路可以拆成两条:入库链路和问答链路。

入库链路是这样的:原始资料(txt、md、pdf)→ 加载器读取成文本 → 文本切分成小块(chunk)→ 每块通过嵌入模型转成向量 → 向量连同原文一起存进 FAISS 索引。问答链路是:用户提问 → 问题转成向量 → 在 FAISS 里找最相似的 top-k 个块 → 把这些块拼进提示词 → 交给大模型生成回答 → 返回答案和引用来源。

这两条链路里,入库是一次性的、离线的,问答是实时的、在线的。这个区分很重要,因为它决定了性能优化的方向:入库慢一点无所谓,可以批量跑;问答必须快,因为用户在等。我见过不少人把两者混在一起,每次提问都重新加载文档,结果慢得没法用。

2.2 为什么选 LangChain + FAISS 这套组合

选型这件事,我的原则是:先用最少的组件跑通,再按需替换。LangChain 的价值在于它把"加载、切分、嵌入、检索、生成"这些环节都抽象成了标准接口,我不用自己写胶水代码。FAISS 是 Facebook 开源的向量检索库,纯本地、零依赖服务、速度快,对于个人知识库这种几万到几十万条 chunk 的规模完全够用。

有人会问,为什么不直接用向量数据库比如 Milvus、Qdrant?我的回答是:个人场景下,引入一个需要单独部署的服务,运维成本远大于收益。FAISS 就是一个 Python 库,pip install就能用,索引存成文件,随项目走。等到数据量真的上百万条、需要多用户并发、需要增量更新的时候,再迁移到专业向量库也不迟,LangChain 的接口基本不用改。

嵌入模型的选择上,我建议中文场景优先考虑对中文友好的模型。如果追求零成本、数据不出本地,可以用本地嵌入模型;如果追求效果和省事,用在线 API 的嵌入接口也行。这里有个关键点:入库用的嵌入模型和查询用的嵌入模型必须是同一个,否则向量空间对不上,检索结果会完全乱套。这是新手最容易犯的错之一。

2.3 大模型这一环怎么接

生成环节我用的是"检索到的上下文 + 用户问题"拼成提示词,让模型基于上下文回答。提示词里我会明确要求:只根据提供的资料回答,资料里没有就说不知道,不要编。这个约束非常关键,它把模型从"百科全书"变成了"资料解读员",大幅降低幻觉。

模型可以接在线的,也可以接本地的。本地模型的好处是数据完全不出机器,适合处理敏感资料;代价是效果和速度取决于你的硬件。我的建议是:先用在线模型把流程跑通,确认效果满意后,再考虑是否换本地模型。不要一上来就折腾本地部署,那会把你的精力从"做产品"拖到"调环境"上。

3. 核心细节拆解:切分、嵌入与检索的门道

3.1 文本切分:决定检索质量的第一道关

很多人低估了切分的重要性,觉得随便按字数切就行。实际上,切分策略直接决定检索质量。切得太碎,一个完整的语义被拆散,检索出来的片段缺头少尾;切得太大,一个块里混了好几个主题,向量表示被稀释,检索精度下降。

我的经验值是:中文资料每块300 到 500 字比较合适,块与块之间保留50 到 100 字的重叠。重叠的作用是防止关键信息正好卡在切割边界上被切断。LangChain 里的RecursiveCharacterTextSplitter就是干这个的,它会优先按段落、再按句子、最后按字符来切,尽量保持语义完整。

from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=400, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""] )

注意separators这个参数,我特意把中文标点加进去了。默认的分隔符是英文的,对中文文本效果不好,会切出很多奇怪的断句。这个细节网上很多教程都不提,但实测下来对中文检索质量影响很明显。

提示:如果你的资料里有大量代码或表格,建议单独处理,不要和正文混在一起切。代码块被切断后基本失去意义,检索出来反而干扰结果。

3.2 嵌入与向量化:把文字变成可比较的数字

嵌入模型把一段文本映射成一个高维向量,语义相近的文本在向量空间里距离也近。这就是为什么"如何配置数据库"和"数据库怎么设置"能被检索到一起,哪怕字面完全不同。

这里有个实操要点:嵌入要批量做,不要一条条来。嵌入模型通常支持一次处理多条文本,批量调用比循环单条快好几倍。LangChain 的embed_documents就是批量的,而embed_query是给单个查询用的。入库时用前者,查询时用后者,别搞混。

另一个坑是归一化。有些嵌入模型输出的向量需要归一化后再算相似度,有些不需要。如果你发现检索结果莫名其妙,先检查这一点。FAISS 里用内积(IndexFlatIP)配合归一化向量,等价于余弦相似度,这是最常用的组合。

3.3 FAISS 索引:选对索引类型很关键

FAISS 提供了多种索引类型,从精确检索到近似检索都有。个人知识库我推荐用IndexFlatL2或IndexFlatIP,也就是暴力精确检索。为什么不用更快的近似索引?因为你的数据量根本不大。几万条 chunk 用暴力检索,单次查询也就几毫秒到几十毫秒,完全无感。近似索引(如 IVF、HNSW)是为百万、千万级数据设计的,在小数据上反而可能因为聚类不准而漏掉正确结果。

from langchain_community.vectorstores import FAISS vectorstore = FAISS.from_documents(chunks, embeddings) vectorstore.save_local("my_knowledge_index")

save_local会把索引和对应的原文一起存到本地目录,下次直接load_local加载,不用重新嵌入。这个设计很实用,意味着你的知识库可以持久化,重启程序不用重跑入库流程。

4. 完整实操:从零跑通一个能用的问答机器人

4.1 环境准备与依赖安装

先把环境搭起来。我用的是 Python 3.10,这个版本对各类 AI 库的兼容性最好。建一个虚拟环境,避免污染全局。

python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install langchain langchain-community faiss-cpu pip install langchain-openai # 如果用在线模型 pip install pypdf # 如果要读 PDF

faiss-cpu是 CPU 版本,个人用足够了。如果你有 GPU 且数据量很大,可以换faiss-gpu,但大多数情况下没必要。装完之后建议跑一句import faiss确认没报错,FAISS 在部分系统上需要额外的运行库,提前发现比写到一半报错强。

4.2 资料入库:把散落的文件变成可检索的知识

我写了一个入库脚本,逻辑是:遍历指定目录下的所有文档,加载、切分、嵌入、存索引。这里我做了个设计——给每个 chunk 打上来源标记,这样回答时能告诉用户"这句话来自哪个文件"。

import os from langchain_community.document_loaders import TextLoader, PyPDFLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_community.vectorstores import FAISS from langchain_openai import OpenAIEmbeddings def load_documents(folder): docs = [] for root, _, files in os.walk(folder): for f in files: path = os.path.join(root, f) if f.endswith(".txt") or f.endswith(".md"): docs.extend(TextLoader(path, encoding="utf-8").load()) elif f.endswith(".pdf"): docs.extend(PyPDFLoader(path).load()) return docs def build_index(folder, save_path): docs = load_documents(folder) splitter = RecursiveCharacterTextSplitter( chunk_size=400, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""] ) chunks = splitter.split_documents(docs) embeddings = OpenAIEmbeddings() vectorstore = FAISS.from_documents(chunks, embeddings) vectorstore.save_local(save_path) print(f"入库完成,共 {len(chunks)} 个片段") build_index("./my_docs", "./my_knowledge_index")

跑完这个脚本,你的知识库就建好了。我实测下来,几百份文档的入库时间主要花在嵌入调用上,如果用的是在线 API,注意控制并发别触发限流。

4.3 问答链路:检索加生成

问答部分我封装成一个函数,输入问题,输出答案和来源。核心是similarity_search检索出相关片段,然后拼提示词。

from langchain_community.vectorstores import FAISS from langchain_openai import OpenAIEmbeddings, ChatOpenAI from langchain.prompts import PromptTemplate def build_qa(index_path): embeddings = OpenAIEmbeddings() vectorstore = FAISS.load_local(index_path, embeddings, allow_dangerous_deserialization=True) llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) prompt = PromptTemplate.from_template( "你是一个知识库助手。请只根据下面的资料回答问题," "资料中没有的内容就说不知道,不要编造。\n\n" "资料:\n{context}\n\n问题:{question}\n\n回答:" ) def ask(question): docs = vectorstore.similarity_search(question, k=4) context = "\n\n".join(d.page_content for d in docs) sources = list({d.metadata.get("source", "未知") for d in docs}) answer = llm.invoke(prompt.format(context=context, question=question)) return answer.content, sources return ask ask = build_qa("./my_knowledge_index") answer, sources = ask("我的项目里数据库是怎么配置的?") print(answer) print("来源:", sources)

temperature=0是为了让回答稳定,减少随机性。k=4是检索片段数,这个值需要根据你的资料密度调。太小可能漏掉关键信息,太大则会把无关内容塞进上下文,反而干扰模型。我一般从 4 开始试,效果不好再调。

4.4 参数选择背后的计算逻辑

有人会问,chunk_size=400、k=4这些数字是怎么来的?我解释一下我的思路。

先说 chunk_size。大模型的上下文窗口是有限的,假设是 8k token,中文大概对应 5000 到 6000 字。如果 k=4,每个 chunk 400 字,那上下文就是 1600 字,加上提示词和问题,总共两千字出头,留足了余量。如果 chunk 设成 1000 字,k=4 就是 4000 字,接近上限,容易出问题。所以 chunk_size 和 k 是联动的,要一起考虑。

再说 k。k 太小,检索召回率不够;k 太大,噪声多。经验公式是:k 取 3 到 5 之间,配合 300 到 500 字的 chunk,这个组合在大多数中文知识库上表现稳定。当然,如果你的资料主题非常集中,k 可以小一点;如果主题很杂,k 可以大一点。

5. 常见问题与排查技巧实录

5.1 检索结果不相关怎么办

这是最常见的问题。排查顺序我总结成一张表:

现象可能原因排查方法
检索结果完全不相关嵌入模型不一致确认入库和查询用同一模型
检索结果沾边但不准chunk 切分不合理检查切分后的片段是否语义完整
明明有答案却检索不到k 太小或索引没更新增大 k,确认重新入库
结果时好时坏向量未归一化检查是否需要用归一化向量

我踩过最深的坑是改了资料但忘了重建索引。FAISS 索引是静态的,你往文件夹里加了新文档,不重新跑入库脚本,它根本不知道。所以我在项目里加了个约定:每次更新资料后,必须重新执行入库。后来我干脆写了个脚本,检测文件修改时间,自动判断要不要重建。

5.2 回答出现幻觉怎么压制

幻觉的根源通常是检索没找到相关内容,模型只能自己编。压制手段有三层:第一层是提示词里明确"资料没有就说不知道";第二层是提高检索质量,让相关内容能被找到;第三层是加一个判断——如果检索到的片段相似度都低于某个阈值,直接回复"知识库里没有相关内容",不交给模型。

第三层最有效。FAISS 的similarity_search_with_score会返回距离分数,你可以设一个阈值,低于它就不生成。这个阈值需要根据你的嵌入模型实测确定,不同模型的分数量纲不一样。

5.3 速度慢的优化方向

如果问答响应慢,先定位瓶颈在哪。用在线模型的话,大部分时间花在 API 调用上,尤其是生成环节。优化手段包括:换更快的模型、减少 k、缩短 chunk。如果是本地模型,瓶颈通常在推理速度,可以考虑量化模型或换更小的模型。

还有一个容易被忽略的点:FAISS 索引加载。如果你每次提问都重新load_local,那加载索引的开销会累积。正确做法是程序启动时加载一次,常驻内存,后续查询复用。

注意:allow_dangerous_deserialization=True这个参数是因为 FAISS 用 pickle 反序列化,只在你信任索引文件来源时开启。自己生成的索引没问题,别人给的索引要谨慎。

6. 后续扩展:从能用到好用

跑通基础版本后,我陆续加了一些东西让它更好用。第一个是多轮对话,把历史问答拼进提示词,让机器人能理解"它"、"这个"这类指代。第二个是来源高亮,回答里标注每句话来自哪个文档的哪一段,方便核对。第三个是增量入库,新文档单独嵌入后合并进现有索引,不用全量重建。

再往深了走,可以引入Agent 能力:让机器人自己决定要不要检索、检索几次、要不要调用其他工具。LangChain 的 Agent 框架就是干这个的,但对于个人知识库,简单的固定检索链路往往比复杂的 Agent 更稳定、更可控。我的建议是先把 RAG 链路打磨扎实,再考虑上 Agent。

最后分享一个我自己的体会:知识库的质量比技术栈重要得多。我花在整理资料、统一格式、去除重复内容上的时间,回报远大于调参。一堆杂乱无章的文档,再好的模型也救不回来;而干净、结构化的资料,用最基础的配置就能有不错的效果。所以如果你刚开始做,先把资料整理好,这比纠结用哪个模型、哪个向量库实在得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询