☰
从零搭建增强版智能知识库:FAISS与LangChain实战
2026/10/6 5:14:10 网站建设 项目流程

1. 从零搭建增强版智能知识库的整体设计思路

1.1 为什么基础RAG不够用

做过RAG项目的人都有一个共同感受:demo跑通很容易,真正上线之后问题一大堆。最典型的就是检索回来的内容跟用户问题“沾边但不精准”,模型拿着半相关的片段硬答,结果要么答偏,要么直接胡编。基础RAG的流程无非是文档切分、向量化、存进向量库、检索Top-K、拼进Prompt让模型回答。这套流程在文档量小、问题简单的时候看着还行,一旦文档上到几千上万条,问题稍微绕一点,召回质量就断崖式下跌。

我自己踩过的坑是这样的:一个技术文档知识库,用户问“FAISS里IndexIVFPQ的nprobe参数怎么调”,基础RAG检索回来的却是“FAISS安装教程”和“向量检索简介”这种大路货。原因很简单,向量相似度只看了语义的“粗粒度”接近,没有考虑查询本身的意图结构,也没有对召回结果做二次筛选。这就是为什么需要“增强版”——在基础链路上叠加查询改写、多路召回、结果重排、去冗余这几层,把召回精度从“差不多”拉到“能用”。

1.2 增强版知识库的四层架构

我把整个系统拆成四层,每一层解决一个具体问题,这样排查起来也方便。

第一层是文档处理层,负责把各种格式的原始资料(Markdown、PDF、网页、Word)统一转成纯文本,再做合理的切分。切分不是随便按字数砍,而是要保留语义完整性,比如按标题层级切、按段落切,必要时做重叠。

第二层是索引存储层,核心是向量库选型和索引参数配置。FAISS是绕不开的选择,它轻量、快、支持多种索引类型,本地跑完全没问题。但FAISS本身只管向量,元数据、原文、来源这些还得配一个文档存储,我一般用SQLite或者直接存JSON。

第三层是检索增强层,这是“增强版”的灵魂。包括HyDE(假设文档嵌入)、MMR(最大边际相关性去冗余)、多查询生成、以及可选的混合检索(向量+关键词)。这一层决定了召回质量的上限。

第四层是Agent调度层,用LangChain的Agent机制把检索、工具调用、多轮对话串起来。用户的问题不一定一次检索就能解决,可能需要先查概念、再查参数、最后查示例,Agent负责编排这个流程。

1.3 技术选型的取舍逻辑

选LangChain不是因为它是唯一方案,而是因为它把RAG的各个组件都抽象好了,改起来快。FAISS选它是因为纯本地、无依赖、性能足够,几百万向量在单机上跑起来毫无压力。MMR和HyDE这两个增强手段,是我实测下来性价比最高的——实现成本低,效果提升明显。

有人会问为什么不直接上知识图谱或者Ontology RAG。我的看法是:知识图谱适合实体关系密集、需要推理的场景,比如医疗诊断、金融风控;而大多数知识库场景(技术文档、产品手册、内部Wiki)本质上是“找片段”,向量检索加增强就够了。上图谱的维护成本太高,实体抽取、关系定义、图谱更新,每一步都是坑,投入产出比不划算。所以这个项目定位很明确:面向文本片段的增强检索,不碰图谱。

2. 核心细节解析与实操要点

2.1 文档切分的颗粒度控制

切分是RAG的地基,切不好后面全白搭。我的经验是:按语义单元切,不按固定字数切。具体做法是优先按Markdown标题层级切,一级标题下的内容作为一个大块,如果超过800字再按段落二次切分,块与块之间保留100到150字的overlap。

为什么是800字?这是实测出来的。太短了语义不完整,检索回来一句话没法回答复杂问题;太长了向量表示会被稀释,一个2000字的块里可能只有200字是相关的,但向量是整块的平均,相关性就被拉低了。800字左右大概是一个完整技术概念的篇幅,既能自包含,又不会太泛。

overlap的作用是防止关键信息正好卡在切分边界上。比如一个参数说明跨了两段,没有overlap的话两段各拿一半,谁都答不全。100到150字的overlap能覆盖大部分边界情况,再大就冗余了。

from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter headers_to_split_on = [ ("#", "h1"), ("##", "h2"), ("###", "h3"), ] md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on) header_splits = md_splitter.split_text(raw_markdown) char_splitter = RecursiveCharacterTextSplitter( chunk_size=800, chunk_overlap=120, separators=["\n\n", "\n", "。", "!", "?", " ", ""] ) final_chunks = [] for split in header_splits: if len(split.page_content) > 800: sub_chunks = char_splitter.split_text(split.page_content) for sc in sub_chunks: final_chunks.append({"text": sc, "metadata": split.metadata}) else: final_chunks.append({"text": split.page_content, "metadata": split.metadata})

注意:metadata一定要带上来源文件名和标题路径,后面做引用展示和结果过滤全靠它。很多人切完只存文本,检索回来不知道出处,用户体验直接崩。

2.2 FAISS索引类型的选择与参数

FAISS的索引类型很多,选错了要么慢要么不准。我按数据量给个直接的建议:

向量数量推荐索引说明
1万以内IndexFlatL2暴力检索,100%准确,速度也够
1万到50万IndexIVFFlat倒排索引,需要训练,nlist设sqrt(N)
50万以上IndexIVFPQ乘积量化压缩,省内存,精度略降

nlist的设置有个经验公式:nlist = 4 * sqrt(N)。比如10万条向量,sqrt(100000)约316,nlist设1200左右。nprobe是检索时扫描的倒排列表数量,设得越大越准但越慢,一般从nlist的1%开始调,比如nlist=1200就设nprobe=12,然后根据召回率往上加。

import faiss import numpy as np dimension = 768 n_vectors = 100000 nlist = int(4 * np.sqrt(n_vectors)) quantizer = faiss.IndexFlatL2(dimension) index = faiss.IndexIVFFlat(quantizer, dimension, nlist, faiss.METRIC_L2) train_vectors = np.random.random((n_vectors, dimension)).astype('float32') index.train(train_vectors) index.add(train_vectors) index.nprobe = 12

提示:IndexIVF系列必须先train再add,train的向量数量建议至少是nlist的39倍,否则聚类效果差。我见过有人直接add没train,结果检索全乱套。

2.3 MMR去冗余的实操配置

MMR(Maximal Marginal Relevance)解决的是“召回结果高度重复”的问题。基础检索Top-5可能返回5个几乎一样的片段,浪费了上下文窗口。MMR在相关性和多样性之间做平衡,公式是:

MMR = λ * sim(query, doc) - (1-λ) * max(sim(doc, selected_docs))

λ取0.5到0.7之间比较合适。λ=1就退化成普通相似度检索,λ=0就只看多样性不管相关性。我一般设0.6,兼顾两头。

from langchain.vectorstores import FAISS from langchain.embeddings import OpenAIEmbeddings vectorstore = FAISS.from_texts(texts, OpenAIEmbeddings()) retriever = vectorstore.as_retriever( search_type="mmr", search_kwargs={ "k": 6, "fetch_k": 20, "lambda_mult": 0.6 } )

fetch_k是先取20个候选,再从里面挑6个既相关又多样的。fetch_k设大一点没坏处,反正MMR的计算很快。k是最终返回数量,一般5到8个够用,太多会稀释Prompt里的有效信息。

2.4 HyDE假设文档嵌入的原理与落地

HyDE的思路很巧妙:用户的问题往往很短、很口语化,而文档是正式的书面语,两者在向量空间里距离可能很远。HyDE的做法是先让模型根据问题“编”一个假设性的答案文档,用这个假设文档去检索,因为假设文档的用词和风格更接近真实文档,检索命中率会明显提升。

举个例子,用户问“FAISS怎么调nprobe”,直接检索可能匹配到“FAISS简介”。但HyDE会让模型先生成一段“nprobe是IndexIVF检索时扫描的倒排列表数量,调大可以提高召回率但降低速度……”这样的假设文档,再用它去检索,就能精准命中参数说明那一段。

from langchain.chains import HypotheticalDocumentEmbedder from langchain.llms import OpenAI from langchain.embeddings import OpenAIEmbeddings base_embeddings = OpenAIEmbeddings() llm = OpenAI(temperature=0.7) hyde_embeddings = HypotheticalDocumentEmbedder.from_llm( llm=llm, base_embeddings=base_embeddings, prompt_key="web_search" ) query = "FAISS里nprobe参数怎么调" hyde_vector = hyde_embeddings.embed_query(query)

注意:HyDE会增加一次LLM调用,延迟和成本都上去了。我的做法是只在检索结果置信度低的时候才触发HyDE,正常查询走普通检索。判断置信度可以用Top-1的相似度分数,低于阈值再走HyDE。

3. 实操过程与核心环节实现

3.1 环境搭建与依赖安装

整个项目在Mac和Linux上都能跑,Python版本建议3.10以上。依赖不多,核心就是langchain、faiss-cpu、以及一个LLM的SDK。

python -m venv venv source venv/bin/activate pip install langchain langchain-community faiss-cpu pip install openai tiktoken pip install fastapi uvicorn

FAISS在Mac上装faiss-cpu就行,不需要GPU版本。如果向量量级到了千万级再考虑faiss-gpu,但大多数知识库场景CPU完全够用。我实测100万条768维向量,IndexIVFFlat在M2 MacBook上单次检索大概20到30毫秒,完全满足交互需求。

3.2 文档入库完整流程

入库流程分五步:加载、切分、向量化、建索引、存元数据。我把它写成一个脚本,方便重复执行。

import os import json import faiss import numpy as np from langchain.embeddings import OpenAIEmbeddings from langchain.text_splitter import RecursiveCharacterTextSplitter def load_documents(doc_dir): docs = [] for filename in os.listdir(doc_dir): if filename.endswith(".md") or filename.endswith(".txt"): filepath = os.path.join(doc_dir, filename) with open(filepath, "r", encoding="utf-8") as f: content = f.read() docs.append({"text": content, "source": filename}) return docs def chunk_documents(docs, chunk_size=800, overlap=120): splitter = RecursiveCharacterTextSplitter( chunk_size=chunk_size, chunk_overlap=overlap, separators=["\n\n", "\n", "。", "!", "?", " ", ""] ) chunks = [] for doc in docs: sub_texts = splitter.split_text(doc["text"]) for i, text in enumerate(sub_texts): chunks.append({ "text": text, "source": doc["source"], "chunk_id": f"{doc['source']}_{i}" }) return chunks def build_index(chunks, index_path="faiss_index"): embeddings = OpenAIEmbeddings() texts = [c["text"] for c in chunks] vectors = embeddings.embed_documents(texts) vectors = np.array(vectors).astype('float32') dimension = vectors.shape[1] n_vectors = len(vectors) nlist = int(4 * np.sqrt(n_vectors)) quantizer = faiss.IndexFlatL2(dimension) index = faiss.IndexIVFFlat(quantizer, dimension, nlist, faiss.METRIC_L2) index.train(vectors) index.add(vectors) index.nprobe = max(1, nlist // 100) faiss.write_index(index, f"{index_path}.faiss") with open(f"{index_path}_meta.json", "w", encoding="utf-8") as f: json.dump(chunks, f, ensure_ascii=False, indent=2) return index, chunks

这个脚本跑完,你会得到两个文件:faiss_index.faiss存向量,faiss_index_meta.json存原文和元数据。检索的时候两边配合用,先查向量拿到索引位置,再从meta里取原文。

3.3 增强检索链的组装

检索链是整个系统的核心,我把MMR、HyDE、多查询生成串在一起,形成一个完整的检索管道。

from langchain.vectorstores import FAISS from langchain.embeddings import OpenAIEmbeddings from langchain.retrievers.multi_query import MultiQueryRetriever from langchain.llms import OpenAI embeddings = OpenAIEmbeddings() vectorstore = FAISS.load_local("faiss_index", embeddings) base_retriever = vectorstore.as_retriever( search_type="mmr", search_kwargs={"k": 6, "fetch_k": 20, "lambda_mult": 0.6} ) llm = OpenAI(temperature=0.3) multi_query_retriever = MultiQueryRetriever.from_llm( retriever=base_retriever, llm=llm ) query = "FAISS的nprobe参数怎么调优" results = multi_query_retriever.get_relevant_documents(query) for r in results: print(r.page_content[:200]) print("---")

MultiQueryRetriever会自动把原始问题改写成3到5个不同角度的查询,分别检索后合并去重。比如“nprobe怎么调优”会被改写成“nprobe参数设置方法”“IndexIVF检索精度优化”“FAISS检索速度与精度平衡”等。这样能覆盖用户可能没想到的表述方式,召回率提升很明显。

3.4 Agent调度与多轮对话

单次检索解决不了的问题,就交给Agent。比如用户问“帮我对比FAISS和Milvus在知识库场景下的优劣”,这需要先查FAISS的特点,再查Milvus的特点,最后做对比。Agent可以拆解这个任务,分步检索,最后汇总。

from langchain.agents import Tool, AgentExecutor, initialize_agent from langchain.memory import ConversationBufferMemory def search_knowledge_base(query: str) -> str: docs = multi_query_retriever.get_relevant_documents(query) return "\n\n".join([d.page_content for d in docs[:4]]) tools = [ Tool( name="KnowledgeBase", func=search_knowledge_base, description="查询技术知识库,输入具体的技术问题" ) ] memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True) agent = initialize_agent( tools=tools, llm=OpenAI(temperature=0), agent="chat-conversational-react-description", memory=memory, verbose=True ) response = agent.run("FAISS和Milvus在知识库场景下各有什么优劣") print(response)

Agent的价值在于它能根据问题动态决定检索几次、检索什么。简单问题一次检索搞定,复杂问题多轮检索。memory保证多轮对话的上下文连贯,用户追问“那第二个方案呢”的时候不会断片。

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

4.1 检索结果不相关的排查路径

这是最高频的问题。我整理了一个排查顺序,按这个走基本能定位到原因。

排查项检查方法常见原因
切分质量随机抽10个chunk看内容切得太碎或太长,语义不完整
向量模型用相同文本测相似度模型不适合中文或领域不匹配
索引参数检查nprobe是否过小nprobe太小导致扫描不充分
查询表述换几种问法测试用户问法和文档表述差距大
元数据过滤检查是否有过滤条件误伤filter条件写错导致召回为空

我遇到过一次特别隐蔽的问题:检索“如何配置超时时间”,怎么都召回不到正确文档。后来发现文档里写的是“timeout设置”,中文“超时”和英文“timeout”在向量空间里距离较远。解决办法是在入库时对关键术语做同义词扩展,或者在查询时用MultiQuery生成包含英文术语的变体。

4.2 向量维度与模型不匹配

换embedding模型的时候最容易出这个问题。比如之前用768维的模型建了索引,后来换成1536维的模型,直接加载旧索引会报维度错误。解决办法只有一个:重新建索引。所以选模型的时候要慎重,尽量选一个稳定的、长期可用的。我一般用OpenAI的text-embedding-3-small,1536维,性价比高,中文效果也够用。

如果非要在不重建索引的情况下换模型,可以考虑降维对齐,但这会损失精度,不推荐。重建索引虽然费时间,但一劳永逸。

4.3 上下文窗口超限的处理

召回太多片段会导致Prompt超长,模型要么报错要么截断。我的做法是动态控制召回数量:先按MMR取6个,然后按相似度分数排序,从高到低累加token数,超过预算就停。预算一般设模型上下文窗口的60%,留40%给系统提示和模型输出。

import tiktoken def select_chunks_by_budget(chunks, max_tokens=3000): enc = tiktoken.get_encoding("cl100k_base") selected = [] total = 0 for chunk in sorted(chunks, key=lambda x: x["score"], reverse=True): tokens = len(enc.encode(chunk["text"])) if total + tokens > max_tokens: break selected.append(chunk) total += tokens return selected

提示:tiktoken算token很快,但要注意不同模型的编码器不一样。用OpenAI的模型就用cl100k_base,用别的模型要换对应的编码器。

4.4 增量更新的正确姿势

知识库不是建一次就完事,文档会更新、会新增。全量重建索引太慢,需要增量更新。FAISS支持add新向量,但IndexIVF在add之前如果没train过新数据,聚类中心可能偏移。我的做法是:小批量新增(几百条以内)直接add,大批量新增(上千条)就重建索引。

删除比较麻烦,FAISS的IndexIVF不支持直接删除。变通方法是维护一个删除ID列表,检索时过滤掉。或者用IndexIDMap包装,通过ID来remove。但remove在IVF上性能不好,频繁删除还是重建划算。

index = faiss.read_index("faiss_index.faiss") new_vectors = embeddings.embed_documents(new_texts) new_vectors = np.array(new_vectors).astype('float32') index.add(new_vectors) with open("faiss_index_meta.json", "r+", encoding="utf-8") as f: meta = json.load(f) for i, text in enumerate(new_texts): meta.append({"text": text, "source": "new_doc", "chunk_id": f"new_{i}"}) f.seek(0) json.dump(meta, f, ensure_ascii=False, indent=2)

4.5 实操心得与避坑清单

最后分享几条我踩坑换来的经验,都是文档里不会写的。

第一条:embedding模型不要频繁换。每次换都要重建索引,而且不同模型的向量空间不可比。选一个主流模型,长期用下去。

第二条:chunk的metadata要尽可能丰富。除了来源和标题,还可以加时间戳、文档类型、作者等。后面做过滤、排序、展示都用得上。我一开始只存了来源,后来想按时间过滤发现没存时间,只能重建。

第三条:检索日志一定要记。记录每次查询的原始问题、改写后的查询、召回的chunk ID、相似度分数。出问题的时候翻日志,比瞎猜快十倍。我用SQLite存日志,一张表搞定。

第四条:HyDE不是万能的。它对“问题短、文档长”的场景效果好,但如果问题本身就很详细,HyDE生成的假设文档可能引入噪声。我的策略是相似度低于0.7才触发HyDE,高于0.7直接走普通检索。

第五条:MMR的lambda要按场景调。技术文档场景lambda设0.6到0.7,偏重相关性;如果是头脑风暴、创意生成场景,lambda可以降到0.4,让结果更多样。没有万能值,要测。

第六条:Agent的tool描述要写清楚。LangChain的Agent靠tool的description来决定什么时候调用。描述写得太泛,Agent会乱调;写得太窄,该调的时候不调。我的写法是“查询技术知识库,适用于FAISS、LangChain、RAG相关的技术问题”,把适用范围列出来。

这套增强版知识库我前后迭代了三个版本,从最基础的向量检索,到加MMR,到加HyDE和多查询,每一步都有实测数据支撑。现在召回准确率从最初的60%左右提升到了85%以上,复杂问题的多轮检索也能稳定工作。后面如果文档量继续涨,可能会考虑上混合检索(向量+BM25),但那是另一个话题了。

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

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

立即咨询