1. 从基础 RAG 到增强版知识库:为什么需要这次升级
做过 RAG 知识库的朋友大概率都经历过这个阶段:用 LangChain 把文档切一切、丢进向量库、接上大模型,跑通一个 demo 特别快,但真正上线之后问题就来了。用户问“这个方案的成本是多少”,检索回来的全是讲方案架构的段落;用户问一个需要跨文档推理的问题,召回的片段各自为政,拼在一起答非所问;用户用口语化表达问一个专业术语,向量检索直接歇菜。
我这次做的“Agent实践3-增强版智能知识库”,核心目标就是解决基础 RAG 的这几个老大难问题。它不是一个从零开始的新项目,而是在前两版 Agent 实践的基础上,把知识库这一块从“能用”推到“好用”的级别。技术栈上依然是LangChain做编排、FAISS做向量检索底座,但在此基础上引入了HyDE(Hypothetical Document Embeddings)做查询增强,并且把整个知识库包装成一个可被 Agent 调用的工具,让 Agent 能够自主决定什么时候查、查什么、查几次。
这篇文章适合谁看?如果你已经跑通过一个最基础的 RAG demo,知道什么是 embedding、什么是向量相似度,但被召回质量、多轮对话、Agent 集成这些问题卡住,那这篇内容就是写给你的。如果你还没接触过 RAG,建议先补一下基础概念再回来,因为我会默认你对 LangChain 的Document、VectorStore、Retriever这些抽象有基本认知。
先说清楚这个增强版到底“增强”在哪里。基础 RAG 的链路是:用户问题 → 向量化 → 相似度检索 → 拼接上下文 → 生成回答。这条链路有三个薄弱点:第一,用户问题和文档内容之间存在语义鸿沟,短查询向量化后信息量太少;第二,检索是一次性的,没有反思和重试机制;第三,知识库和 Agent 是割裂的,Agent 不知道知识库里有什么,只能被动接受检索结果。增强版针对这三点分别做了 HyDE 查询扩展、Agent 驱动的多轮检索、以及知识库工具化封装。下面我会把每一块的原理、实现和踩坑经验都拆开讲。
2. 整体架构设计与技术选型背后的取舍
2.1 为什么是 LangChain + FAISS + HyDE 这个组合
选型这件事,我的原则一直是“够用且可控”。市面上的 RAG 框架很多,从轻量的 LlamaIndex 到重型的全套平台都有,但我最终选了 LangChain + FAISS 这个组合,原因很实际。
LangChain 的优势在于抽象层次合适。它把 LLM、Embedding、VectorStore、Retriever、Tool 这些概念都做了标准化封装,我可以在不改动核心逻辑的前提下替换任何一个组件。比如今天用 FAISS,明天数据量上来了想换 Milvus,只需要改几行初始化代码。这种可替换性在项目迭代期特别重要,因为你永远不知道哪个组件会成为瓶颈。
FAISS 的选择更直接:它是本地向量检索里性能最稳的。我实测过几万到几十万级别的向量,FAISS 的检索延迟基本在毫秒级,而且它支持多种索引类型,可以根据数据规模灵活切换。对于中小规模的知识库场景,FAISS 不需要额外部署服务,一个进程内就能跑,运维成本几乎为零。当然它也有短板,比如不支持分布式、不支持实时增删(部分索引类型),但这些在当前的场景下不是问题。
HyDE 是这个项目里最值得说的一个点。它的核心思想是:与其用短查询去检索,不如先让 LLM 根据查询生成一个“假想的答案文档”,然后用这个假想文档去检索。为什么这样有效?因为用户的问题通常很短,比如“FAISS 支持哪些索引”,向量化之后语义信息很稀疏;而生成的假想文档会包含“Flat、IVF、HNSW”这些具体术语,向量化后和真实文档的语义空间更接近,召回率自然就上去了。
提示:HyDE 不是万能的。它依赖 LLM 生成质量,如果 LLM 对某个领域不了解,生成的假想文档可能跑偏,反而拉低召回。所以我在实现里加了一个开关,可以按查询类型决定是否启用 HyDE。
2.2 增强版知识库的分层结构
整个系统我分成了四层,从下到上依次是存储层、检索层、增强层、Agent 层。这样分层的好处是每一层可以独立测试和替换。
存储层负责文档的加载、切分、向量化和持久化。这里的关键决策是切分策略。我试过固定长度切分和按语义切分两种方案,最终选了“递归字符切分 + 重叠窗口”。固定长度切分的问题是容易把一句话从中间切断,导致语义不完整;纯语义切分又太慢,而且对中文支持不稳定。递归切分按段落、句子、字符逐级尝试,配合 100 到 200 字符的重叠窗口,能在保证语义完整的同时控制成本。
检索层封装了 FAISS 的索引构建和查询。这里有个细节值得说:FAISS 的IndexFlatL2是精确检索,召回率最高但速度随数据量线性下降;IndexIVFFlat是近似检索,需要先训练聚类中心,速度快但会损失一点召回。我的做法是数据量小于 10 万条时用IndexFlatL2,超过之后切换到IndexIVFFlat,并且把nprobe参数调到 16 左右,在速度和召回之间取平衡。
增强层就是 HyDE 和重排序的所在地。HyDE 负责查询扩展,重排序负责对初步召回的结果做精排。重排序我用的是简单的交叉编码思路,把查询和每个候选文档拼在一起打分,虽然比向量相似度慢,但精度提升明显。
Agent 层把整个知识库包装成一个 Tool,让 Agent 可以通过函数调用的方式使用。这一层的关键是工具描述的设计,描述写得好不好,直接决定 Agent 会不会在正确的时机调用知识库。
2.3 和纯 Agent 方案的区别在哪里
有人可能会问,既然都用了 Agent,为什么不干脆让 Agent 自己管理知识,非要搞一个独立的知识库?这个问题我在设计初期也纠结过。
纯 Agent 方案的问题是上下文窗口有限。你不可能把所有文档都塞进 prompt 里,即使塞进去了,模型对长上下文的注意力也会衰减,检索精度反而下降。知识库的价值在于它做了一个“外置记忆”,把海量文档压缩成向量索引,Agent 只需要在需要的时候查询相关片段。这就像人脑的工作记忆和长期记忆的分工,工作记忆容量小但灵活,长期记忆容量大但需要检索才能调用。
另一个区别是可控性。知识库的检索过程是确定性的、可调试的,我可以看到召回了哪些文档、相似度是多少、为什么这个文档被排除了。而纯 Agent 的决策过程是黑盒,出了问题很难定位。所以在需要可解释性和可维护性的场景下,独立知识库 + Agent 调用的架构更合适。
3. 核心细节解析:HyDE 与 FAISS 的实操要点
3.1 HyDE 的实现原理与提示词设计
HyDE 的实现其实不复杂,核心就是一次 LLM 调用加一次向量检索。但提示词的设计直接决定效果,我在这上面花了不少时间调优。
基础版本的提示词是这样的:让 LLM 根据问题生成一段假设性的答案。但实测下来发现两个问题:一是生成的答案太笼统,缺乏专业术语;二是对于事实性问题,LLM 容易编造具体数字,导致检索方向跑偏。
改进后的提示词我加了几个约束。第一,明确要求“生成一段可能出现在技术文档中的段落”,把生成目标锚定到文档风格;第二,要求“包含相关的专业术语和关键词”,提升术语密度;第三,对于事实性问题,要求“不要编造具体数值,用占位符代替”。最后这条特别重要,因为假想文档里的错误数字会污染检索向量。
from langchain.prompts import PromptTemplate hyde_prompt = PromptTemplate( input_variables=["question"], template="""你是一个技术文档撰写助手。请根据下面的问题,生成一段可能出现在技术文档中的段落。 要求: 1. 使用技术文档的客观、说明性语气 2. 包含与问题相关的专业术语和关键词 3. 如果涉及具体数值,用 [数值] 占位,不要编造 4. 长度控制在 100 到 200 字 问题:{question} 假设性文档段落:""" )生成假想文档之后,我把它和原始查询拼在一起做向量化。这里有个细节:是只用假想文档检索,还是原始查询和假想文档都检索然后合并结果?我两种都试过,最终选了后者。因为假想文档虽然语义丰富,但可能偏离原始意图;原始查询虽然信息少,但意图准确。两者合并做加权融合,召回率和准确率都比单用好。
3.2 FAISS 索引构建的参数计算
FAISS 的索引构建有几个关键参数,我拿实际数据算一遍你就明白了。
假设知识库有 5 万条文档片段,每条向量 768 维(这是常见的 embedding 维度)。如果用IndexFlatL2,内存占用大约是 50000 × 768 × 4 字节 ≈ 147 MB,检索一次需要遍历全部 5 万条,延迟在 10 到 20 毫秒。这个规模下IndexFlatL2完全够用。
但如果数据量涨到 50 万条,内存占用变成 1.47 GB,检索延迟涨到 100 到 200 毫秒,就有点难受了。这时候切换到IndexIVFFlat,需要设置聚类中心数量nlist。经验公式是nlist ≈ 4 × sqrt(N),N 是向量总数。50 万条的话,nlist ≈ 4 × 707 ≈ 2828,取整到 2048 或 4096 都行。查询时nprobe控制访问多少个聚类,nprobe越大召回越高但越慢,一般设nlist的 1% 到 5%,也就是 20 到 100 之间。
import faiss import numpy as np dimension = 768 n_docs = 500000 nlist = 2048 # 4 * sqrt(500000) 约等于 2828,取 2 的幂次 # 构建量化器 quantizer = faiss.IndexFlatL2(dimension) # 构建 IVF 索引 index = faiss.IndexIVFFlat(quantizer, dimension, nlist, faiss.METRIC_L2) # 训练(需要至少 nlist * 39 条向量,这里 500000 足够) training_vectors = np.random.random((n_docs, dimension)).astype('float32') index.train(training_vectors) # 添加向量 index.add(training_vectors) # 查询时设置 nprobe index.nprobe = 32注意:
IndexIVFFlat必须先训练才能添加向量,而且训练数据量要足够,否则聚类中心质量差,召回会明显下降。我踩过一次坑,用 1000 条数据训练 2048 个聚类中心,结果检索出来的东西完全不相关。经验是训练数据至少是nlist的 39 倍。
3.3 文档切分策略的取舍
切分策略这块我想多说几句,因为它对召回质量的影响比很多人想象的大。
我最初用的是RecursiveCharacterTextSplitter,默认参数是 chunk_size=1000、chunk_overlap=200。跑下来发现两个问题:一是对于技术文档,1000 字符经常把一个完整的代码示例切成两半;二是对于问答类文档,一个问答对可能只有 200 字符,切完之后上下文丢失严重。
后来我改成了按文档类型动态调整。技术文档用 chunk_size=800、overlap=150,保证代码块尽量完整;FAQ 类文档用 chunk_size=400、overlap=100,因为问答对本身短,切太大反而引入噪声;长篇文章用 chunk_size=1200、overlap=250,保留更多上下文。
还有一个技巧是给每个 chunk 加上元数据,比如来源文件名、章节标题、页码。这些元数据在检索时可以用于过滤,在生成时可以用于引用。LangChain 的Document对象支持metadata字段,用起来很方便。
from langchain.text_splitter import RecursiveCharacterTextSplitter def get_splitter(doc_type): if doc_type == "technical": return RecursiveCharacterTextSplitter( chunk_size=800, chunk_overlap=150, separators=["\n\n", "\n", "。", ";", " ", ""] ) elif doc_type == "faq": return RecursiveCharacterTextSplitter( chunk_size=400, chunk_overlap=100, separators=["\n\n", "\n", "。", "?", " ", ""] ) else: return RecursiveCharacterTextSplitter( chunk_size=1200, chunk_overlap=250, separators=["\n\n", "\n", "。", " ", ""] )中文切分有个特殊问题:英文按空格切分很自然,中文没有空格,默认的 separators 里如果不加中文标点,切分效果会很差。我加了“。”“?”“;”这些中文标点作为分隔符,实测下来语义完整性提升明显。
4. 实操过程:从零搭建增强版知识库
4.1 环境准备与依赖安装
先把环境搭起来。我用的是 Python 3.10,这个版本对 LangChain 和 FAISS 的兼容性最好。依赖清单如下:
pip install langchain langchain-community langchain-openai pip install faiss-cpu # 有 GPU 的话可以装 faiss-gpu pip install sentence-transformers # 本地 embedding 模型 pip install rank-bm25 # 用于混合检索这里有个选型决策:embedding 模型用 API 还是本地?我两种都试过。API 的好处是省事、效果好,但成本和延迟不可控;本地模型的好处是免费、可控,但需要自己部署,而且中文效果参差不齐。最终我选了本地模型BAAI/bge-large-zh-v1.5,它在中文语义相似度任务上表现稳定,768 维向量,单条推理在 CPU 上大约 50 毫秒,可以接受。
如果你追求更好的效果且预算充足,可以用 API 的 embedding 模型,维度通常是 1536 或 3072,检索精度会更高,但成本也相应增加。我的建议是先用本地模型跑通流程,等确定要上线了再根据预算决定是否切换。
4.2 文档加载与向量化入库
文档加载这块,LangChain 提供了各种DocumentLoader,PDF、Word、Markdown、网页都能加载。我用得最多的是DirectoryLoader配合UnstructuredMarkdownLoader,因为我的知识库主要是 Markdown 格式的技术文档。
加载完之后是切分,切分策略上一节讲过了。切分完得到一堆Document对象,接下来是向量化。向量化我用HuggingFaceEmbeddings包装本地模型,批量处理提升效率。
from langchain_community.document_loaders import DirectoryLoader, UnstructuredMarkdownLoader from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import FAISS # 加载文档 loader = DirectoryLoader( "./knowledge_base", glob="**/*.md", loader_cls=UnstructuredMarkdownLoader ) documents = loader.load() # 切分 splitter = get_splitter("technical") chunks = splitter.split_documents(documents) # 向量化 embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-large-zh-v1.5", model_kwargs={"device": "cpu"}, encode_kwargs={"normalize_embeddings": True} ) # 构建 FAISS 索引并持久化 vectorstore = FAISS.from_documents(chunks, embeddings) vectorstore.save_local("./faiss_index")normalize_embeddings=True这个参数很重要。它把向量归一化到单位长度,这样内积就等于余弦相似度,检索时用 L2 距离和余弦距离等价,避免因为向量长度差异导致的相似度偏差。我一开始没加这个参数,发现有些长文档的向量模长特别大,检索时总是排前面,加了归一化之后就正常了。
4.3 HyDE 检索链的组装
检索链的组装是整个项目的核心。我把 HyDE 生成、向量检索、结果融合、重排序串成一条链。
第一步是 HyDE 生成假想文档。第二步是把原始查询和假想文档分别向量化,各自检索 top-k,然后合并去重。第三步是对合并后的候选做重排序,取 top-n 作为最终上下文。
from langchain_openai import ChatOpenAI from langchain_core.output_parsers import StrOutputParser llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) def hyde_retrieve(question, vectorstore, k=5): # 生成假想文档 hyde_chain = hyde_prompt | llm | StrOutputParser() hypothetical_doc = hyde_chain.invoke({"question": question}) # 原始查询检索 docs_by_query = vectorstore.similarity_search(question, k=k) # 假想文档检索 docs_by_hyde = vectorstore.similarity_search(hypothetical_doc, k=k) # 合并去重 seen = set() merged = [] for doc in docs_by_query + docs_by_hyde: key = doc.page_content[:100] if key not in seen: seen.add(key) merged.append(doc) return merged合并去重这里我用内容前 100 字符做 key,简单但有效。更严谨的做法是用文档 ID,但 LangChain 的Document默认没有唯一 ID,需要自己生成。如果你的文档有稳定的来源标识,建议用来源加位置做 key,避免不同文档内容相同被误去重。
重排序我用了一个轻量方案:把查询和每个候选文档拼成“查询 [SEP] 文档”的形式,用交叉编码器打分。交叉编码器比双编码器慢,但精度高。候选数量控制在 20 条以内,重排序的延迟可以接受。
4.4 把知识库封装成 Agent 工具
最后一步是把整个检索链封装成一个 Tool,让 Agent 可以调用。LangChain 的Tool抽象很简单,给一个名字、一段描述、一个函数就行。但描述怎么写很有讲究。
我最初的描述是“查询知识库获取相关信息”,结果 Agent 经常在该调用的时候不调用,或者在不该调用的时候乱调用。后来我把描述改得更具体:“当用户询问产品功能、技术方案、配置参数等需要查阅文档的问题时使用此工具。输入应该是一个完整的问题描述,而不是单个关键词。”这样改了之后,Agent 的调用准确率明显提升。
from langchain.tools import Tool def knowledge_base_query(question: str) -> str: docs = hyde_retrieve(question, vectorstore, k=5) context = "\n\n".join([d.page_content for d in docs]) return context kb_tool = Tool( name="knowledge_base", description="当用户询问产品功能、技术方案、配置参数等需要查阅文档的问题时使用。输入应该是完整的问题描述。", func=knowledge_base_query )Agent 的初始化用initialize_agent或者更现代的create_react_agent都行。我用的是 ReAct 模式,因为它对工具调用的推理过程可解释,方便调试。Agent 的 prompt 里要明确告诉它“先思考是否需要查知识库,如果需要就调用工具,拿到结果后再回答”。
5. 常见问题与排查技巧实录
5.1 召回质量差的排查思路
召回质量差是最常见的问题,排查要按链路一步步来。
先看查询本身。如果用户查询特别短,比如就一个词,向量化后信息量太少,召回肯定差。这时候 HyDE 能帮上忙,但如果 HyDE 生成的假想文档也跑偏,就要检查提示词。我遇到过一次,用户问“怎么配置”,HyDE 生成了一大段关于“配置管理”的通用内容,和实际的产品配置完全不相关。后来在提示词里加了“结合技术文档的语境”这个约束,生成质量就好了很多。
再看切分。如果切分粒度太粗,一个 chunk 里混了好几个主题,向量化后语义被稀释,检索时匹配度下降。反过来切分太细,一个完整概念被拆散,检索到的片段缺乏上下文。我的经验是 chunk_size 在 400 到 1200 之间,具体看文档类型,然后用几个典型查询测试召回,根据结果微调。
最后看 embedding 模型。不同模型对中文的支持差异很大。我对比过几个常见模型,bge-large-zh系列在中文技术文档上表现最稳,text-embedding-ada-002对英文更好但中文一般。如果你的知识库中英混合,可以考虑用多语言模型,或者对中英文分别建索引。
5.2 Agent 不调用知识库怎么办
这个问题我遇到过好几次,排查下来通常是三个原因。
第一个原因是工具描述不够清晰。Agent 判断是否调用工具,主要看工具描述和当前任务的匹配度。如果描述太笼统,Agent 就不知道该什么时候用。解决办法是把描述写具体,列出典型的使用场景和输入格式。
第二个原因是 Agent 的 prompt 里没有强调知识库的存在。有些 Agent 的默认 prompt 会让它优先用自己的知识回答,而不是查工具。这时候需要在 system prompt 里明确写“对于事实性问题,优先查询知识库,不要凭记忆回答”。
第三个原因是工具返回的结果太长,Agent 处理不过来。如果一次返回 5 个文档片段,每个 800 字符,总共 4000 字符,加上 Agent 自己的推理,很容易超出上下文限制。解决办法是控制返回的文档数量和长度,或者让 Agent 分多次查询。
5.3 性能瓶颈的定位与优化
性能问题通常出现在三个地方:向量化、检索、LLM 调用。
向量化的瓶颈在批量处理。如果一条条向量化,5 万条文档可能要跑几个小时。用批量处理,一次 32 或 64 条,速度能提升十几倍。另外,如果文档不经常变,向量化结果应该持久化,不要每次启动都重新算。
检索的瓶颈在索引类型。前面讲过,数据量小的时候IndexFlatL2够用,大了要换IndexIVFFlat。还有一个容易忽略的点是nprobe参数,默认值可能偏小,导致召回不足。我一般会把它调到 32 或 64,在可接受的延迟内尽量提升召回。
LLM 调用的瓶颈在 HyDE 生成和最终回答。HyDE 生成可以用小模型,比如gpt-4o-mini或本地的小参数模型,因为它的任务是生成假想文档,不需要太强的推理能力。最终回答用大模型,保证质量。这样分工可以在成本和效果之间取得平衡。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 召回内容不相关 | 查询太短或 embedding 模型不匹配 | 打印查询向量和文档向量的相似度分布 | 启用 HyDE,或换用更适合中文的 embedding 模型 |
| Agent 不调用知识库 | 工具描述不清晰或 prompt 未强调 | 查看 Agent 的推理日志 | 细化工具描述,在 system prompt 中明确要求查知识库 |
| 检索延迟高 | 索引类型不合适或 nprobe 过大 | 用 time 模块测量各阶段耗时 | 切换 IVF 索引,调小 nprobe,或减少候选数量 |
| 回答包含幻觉 | 上下文不足或 LLM 过度推理 | 检查返回的上下文是否包含答案 | 增加召回数量,或在 prompt 中要求“仅根据上下文回答” |
| 中文切分效果差 | separators 未包含中文标点 | 查看切分后的 chunk 内容 | 在 separators 中加入中文标点 |
| 内存占用过高 | 向量维度大或数据量大 | 用 psutil 监控内存 | 降低向量维度,或使用量化索引 |
提示:排查问题时,建议把每个阶段的输入输出都打日志。RAG 链路的调试难点在于中间环节多,没有日志就像盲人摸象。我习惯在检索后打印召回的文档标题和相似度分数,一眼就能看出问题出在哪。
6. 几个让我印象深刻的踩坑记录
第一个坑是 FAISS 的持久化。save_local保存的索引文件在不同版本的 FAISS 之间不兼容,我升级了一次 FAISS 之后,旧索引直接加载失败。后来养成了习惯,每次升级依赖前先备份索引,或者干脆重新构建。重建的成本其实不高,5 万条文档用批量向量化也就十几分钟。
第二个坑是 HyDE 的延迟。每次查询都要调一次 LLM 生成假想文档,延迟增加了 1 到 2 秒。对于交互式场景,这个延迟用户能感知到。我的优化方案是加缓存,相同或相似的查询直接复用之前的假想文档。用查询的 embedding 做 key,相似度超过阈值就命中缓存。实测下来缓存命中率在 30% 左右,平均延迟降了一半。
第三个坑是 Agent 的循环调用。有一次 Agent 连续调了 5 次知识库,每次都拿到相似的结果,但就是不生成最终回答。排查发现是工具返回的内容里没有明确答案,Agent 不甘心,反复尝试。解决办法是在工具描述里加一句“如果返回结果不包含答案,请直接告知用户未找到相关信息,不要重复查询”。加了之后循环调用的问题基本消失了。
第四个坑是中文标点的处理。有些文档用的是全角标点,有些是半角,切分时如果不统一处理,会导致切分结果不一致。我在预处理阶段加了一步标点归一化,把半角标点转成全角,切分效果稳定了很多。
7. 后续可以继续深挖的方向
这套增强版知识库跑通之后,我还在继续折腾几个方向。
一个是混合检索。纯向量检索对语义匹配好,但对精确关键词匹配弱。比如用户搜一个具体的错误码,向量检索可能召回一堆语义相关但不含该错误码的文档。加入 BM25 做关键词检索,和向量检索做融合,能互补短板。LangChain 里有EnsembleRetriever可以直接用,我试了一版,召回率有提升,但融合权重的调参还需要再打磨。
另一个是知识图谱的引入。纯向量 RAG 对多跳推理的支持有限,比如“A 依赖 B,B 依赖 C,问 A 和 C 的关系”,向量检索很难把 A 和 C 关联起来。知识图谱可以把实体和关系显式建模,配合向量检索做混合推理。这块我还在调研阶段,Ontology RAG 是个值得关注的方向。
还有一个是 Agent 的记忆机制。现在的知识库是无状态的,每次查询都是独立的。如果能让 Agent 记住之前的查询和结果,在多轮对话中复用,体验会更好。LangChain 有ConversationBufferMemory和ConversationSummaryMemory,可以接进来试试。
最后再分享一个小技巧:知识库的评估不要靠感觉,要建一个测试集。我整理了 50 个典型问题,每个问题标注了应该召回的文档 ID,每次改动之后跑一遍,看召回率和准确率的变化。这个测试集帮我避免了好几次“感觉变好了实际变差了”的误判。建测试集花不了多少时间,但收益是长期的。