做知识库问答的这几年,我碰到过太多这样的需求:企业内部攒了几百份技术文档,员工问“XX服务的超时时间怎么配置”,用传统关键词搜索经常搜不到,翻半天目录才能找到;可是直接去问大模型,它又会根据训练语料里的旧版本信息,一本正经地给出三年前的老配置。把这两类问题放在一起看,其实暴露的是同一个矛盾——静态的文档知识库没法理解语义,动态的大模型又拿不到私有知识。我在这类场景落地时碰到的大量诉求,最后几乎都是用同一个框架解决的:RAG,全称 Retrieval-Augmented Generation,检索增强生成。它的思路用一句大白话说就是“先查资料,再动笔”:在让大模型生成回答之前,先到你的知识库里把相关材料检出来,把这些材料一起提交给模型,让它“基于材料”组织答案。
这套东西粗看好像不复杂,但实际落地时,从文档切分、向量化、召回策略到提示词构造,每一环都有很多坑。这篇文章我会按我实践中的完整链路来写:RAG到底解决什么问题、整体架构怎么设计、从零搭建一个最小可用系统需要什么、以及真正跑生产后你会踩到的常见问题和排查手段。无论你是准备做企业内部知识库、客服辅助,还是想给产品接入私有知识问答,这篇都值得对照着看一遍。
1. RAG解决的核心问题:为什么知识库问答非得用它
1.1 三个绕不开的痛点
先说大模型自身的问题。第一个是知识截止。模型训练好之后,它知道的东西就定格在那个时间点,之后发生的事一概不知。对于企业内部场景,这个缺陷几乎是致命的——公司制度、产品版本、接口参数每隔几个月就变一次,你不可能天天重训模型。第二个是幻觉。模型在不确定的时候会“编”,而且编得特别自信。做知识问答如果直接让模型回答,它很有可能会把两个相似版本的功能参数混在一起,给你拼出一个看起来合理、实际不存在的答案。第三个是私有知识完全不可见。业务数据、售后记录、内部流程这些内容根本不会出现在公开训练语料里,模型再大也“不知道你公司的事”。
这三个问题叠加在一起,决定了“直接问模型”在知识库场景里走不通。而 RAG 的思路正好是绕开了所有三个坑:知识截止问题靠检索外部最新文档来解决,幻觉问题靠把回答范围限制在召回文档内来解决,私有知识问题靠把业务文档提前索引进向量库来解决。
1.2 为什么优先选RAG而不是微调
做方案选型的时候,一定会有人问:这需求能不能直接微调一个模型?我见过不少团队在这上面折腾了一两个月,最后还是换回了 RAG。从成本和效果两个维度看,RAG 的优势非常明显。
先说成本。微调一套基础大模型,需要准备大量优质问答对数据,需要 GPU 资源做训练,最关键的是每次业务规则一更新,你几乎要重来一轮。而 RAG 没有训练过程,知识更新就是你重新跑一遍索引,把新文档塞进向量库,几分钟就能完成。再说效果的可控性。RAG 可以给出引用的来源文档,用户能点进去核对,这在合规场景里几乎是刚需。微调只能说“知识进入模型参数”,没人能解释它为什么答成这样,一出问题排查难度极大。在实际项目中,微调更适合做的是“说话风格的定制”,比如让模型更口语化、更简洁;知识类内容我建议一律走 RAG。
1.3 什么样的场景收益最大
我自己做过的落地场景里,收益最明显的是这么几类:企业内部制度与流程问答、产品文档/技术手册问答、电商客服售前售后知识问答、法律合同与合规条款审查辅助。它们的共同特点是:知识量中等偏大、更新频繁、对答案准确性和可溯源性要求高。这类场景如果靠人工维护问答库,几百上千条规则能让人崩溃;直接上 RAG,把原始文档扔进去就好,维护成本就大大降下来了。
2. RAG系统的整体架构与关键技术选型
2.1 经典四段式链路
任何 RAG 系统,不管复杂程度如何,底层都是四段式:文档加载、文档切分与向量化、向量存储与检索、生成增强。
文档加载负责把 PDF、Word、Markdown、网页、数据库记录等不同来源的内容变成纯文本。文档切分是把长文本切成适合检索的片段,这一步直接决定检索粒度和召回质量。向量化是把每个片段用嵌入模型转成高维向量,语义相近的文本向量距离就近。生成增强则是把召回的片段作为上下文,和用户问题一起提交给大模型,让它只基于这些内容作答。
很多项目把重点放在向量数据库和模型选型上,但我实际做下来发现,真正决定 RAG 天花板的是切分策略和检索链路。向量库只是存储工具,只要稳定可靠就行,不是性能瓶颈。
2.2 关键选型一:嵌入模型怎么评估
嵌入(Embedding)模型的作用,是把一段文本变成一个多维向量数组。选哪个模型,直接决定检索效果。评估一个嵌入模型,不要只看宣传的精度数字,要用自己的数据跑一遍召回测试。中文场景下,我会关注两个方面:第一,模型对中文语义的理解能力,尤其是否适配你这个垂直领域(法律、医疗、制造等行业术语差异很大);第二,输入长度限制,比如有的模型上限 512 token,那你的切块长度就得迁就它。
一个比较实用的评估方法是:准备 50 到 100 条真实问答对,把答案对应的原文片段作为正例,混入一些不相关文档作为负例,用模型分别做嵌入,再计算召回率。我一般会跑至少三轮,分别测短问短答、长问短答、同义改写提问这三类情况。同义改写这个要特别注意,很多模型能命中关键词对,但换一种说法就丢,这种模型在生产里很容易让你“翻车”。
2.3 关键选型二:向量数据库怎么选
向量数据库这个环节,常见的方案包括专门向量数据库、带向量插件的传统数据库、以及轻量级本地方案。我的建议是:试原型阶段用轻量方案最快,生产环境按并发和数据量选型。
轻量级方案适合单机和开发环境,部署简单,比如 FAISS 或类似的本地索引库,几千到几万个片段完全够用。生产上如果数据量到几十万片段、并发请求多、需要动态更新,我会优先选择支持向量检索的成熟数据库,要么是开源的全文检索加向量插件,要么是商业向量库。这里多说一句,不要把向量库当成“万金油”,数据量没到一定规模时,上一套分布式向量集群纯属给自己添运维负担。
2.4 关键选型三:生成模型怎么选
生成模型负责把召回文档组织成答案。这个环节我只看三个指标:上下文长度、中文生成质量、部署成本。上下文长度决定你能塞多少召回片段进去,现在主流模型动辄支持几十万 token 上下文,但实际经验是,塞太多干扰信息反而会拉低回答准确度,所以模型上下文大不等于你要全用。中文生成质量上,几个主流商用模型都不错,开源权重模型也在快速追赶。部署成本上,如果公司有 GPU 资源,用开源权重模型内部部署是首选,数据不出内网;如果要快速验证效果,直接用商用 API 是大模型最快的方式,等验证完备后再考虑迁移。
3. 实操:从零搭建一套最小可用的RAG系统
3.1 环境准备与依赖安装
我以 Python 环境为例,演示从零搭建的完整过程。先安装基础依赖,这里用到的都是社区里最常见的库:文档解析用 unstructured 和 PyMuPDF,文本切分用 LangChain 的文本分割器或自己写规则,向量存储用 FAISS,嵌入模型用开源的 BGE 系列,生成模型先用 OpenAI 兼容接口的商用 API 来做验证。
pip install openai langchain langchain-community faiss-cpu pymupdf unstructured开发机上建议先用 CPU 版本的 FAISS 跑通链路,GPU 不是必需的。嵌入模型方面,可以下载本地开源模型,也可以调用托管 API。我比较建议本地加载开源模型,这样离线可跑、数据可控,调试起来也方便。把模型下载到本地目录后,用 sentence-transformers 库加载即可。
from sentence_transformers import SentenceTransformer model = SentenceTransformer("BAAI/bge-large-zh-v1.5") embedding = model.encode("查询超时时间默认是多少") print(embedding.shape)3.2 文档加载与切分策略
文档加载这一步,不同格式对应不同解析方式。PDF 要处理文字层和扫描件两种情况,扫描件必须走 OCR,否则你切出来的“文本”全是乱码。Word 和 Markdown 相对好处理,直接解析正文。实际操作里,PDF 是占坑最多的格式,很多 PDF 看着是文字,实际是图片,必须先转图片再做 OCR。
切分策略这一步我要多说一点。最粗糙的做法是固定长度切分,比如每 512 个字符切一块,这样做实现简单,但效果很差。它最容易把一个完整语义段——比如“某某接口的请求参数”从中间砍断,导致检索时召回的片段信息不完整。
我用的切分策略是“结构感知 + 窗口重叠”。结构感知就是优先按文档本身的层级来切:Markdown 按标题分节,PDF 按章节标题定位再分,HTML 按标题和段落分。切完之后如果某一段还是太长,再递归地按句子边界进一步切。窗口重叠是在切分时让相邻片段保留一小段重叠文字,比如每块末尾带上上一块最后 50 到 100 个字,这样可以避免切断语义句导致的信息丢失。
def split_markdown_by_headings(text, max_chunk_size=800, overlap=100): sections = [] current_section = [] for line in text.split("\n"): if line.startswith("#") and current_section: sections.append("\n".join(current_section)) current_section = [line] else: current_section.append(line) if current_section: sections.append("\n".join(current_section)) chunks = [] for section in sections: if len(section) <= max_chunk_size: chunks.append(section) else: # 按句切割并加入重叠 part = section while len(part) > max_chunk_size: split_point = part.rfind("。", 0, max_chunk_size) if split_point == -1: split_point = max_chunk_size chunks.append(part[:split_point]) part = part[max(split_point - overlap, 0):] chunks.append(part) return chunks这里的关键参数有两个:最大块长度和重叠长度。块太大,检索出来的信息冗余,浪费上下文窗口,也容易引入噪声;块太小,单个片段语义不完整,检索精度下降。根据我的实践经验,中文场景里最大块长度在 500 到 800 字之间比较合适,具体数值一定要对应嵌入模型的输入上限来定。重叠一般取块长度的 10% 到 20% 就行,太长会显著增加索引体积。
提示:如果问答里有大量表格和结构化数据,给每块内容补一行元数据标记(比如来源文件名、章节路径),后面做过滤和引用展示时非常有用。
3.3 向量化与索引构建
文本块确定后,接下来就是对每块做向量化,然后存入向量索引。下面这段代码演示了把文本块转成向量并写入 FAISS 索引的全过程。
import json import faiss import numpy as np def build_index(chunks, embed_model): vectors = [] for chunk in chunks: vec = embed_model.encode(chunk) vectors.append(vec) dimension = len(vectors[0]) index = faiss.IndexFlatIP(dimension) # 内积相似度 index.add(np.array(vectors).astype("float32")) # 文本块与向量顺序一一对应,保存起来 with open("chunks.json", "w", encoding="utf-8") as f: json.dump(chunks, f, ensure_ascii=False) return index注意这里我用的是 IndexFlatIP,也就是内积相似度。如果嵌入模型本身做了归一化,那么内积和余弦相似度是等价的。使用 FAISS 时我习惯将文本内容单独落盘保存,向量索引只保存向量和文档ID,这样后续检索时能方便地拿回原始文本。
更完整的做法是:每一步都把元数据带上,生成检索条目时用字典打包,一条记录包括文本内容、来源文件名、章节标题、页码、时间戳。这样检索出来不但能定位到原文,还能展示“第几章第几页”,用户信任度会大幅提升。
documents = [ { "text": chunk, "source": "产品手册.pdf", "section": "3.2 配置参数", "page": 12, "timestamp": "2025-06-01" } for chunk in chunks ]3.4 检索链路:双路召回与重排序
用户输入一个 query,最基础的做法就是直接把它向量化后在向量库里做相似度检索,取 top_k 个片段。这种做法原型阶段没问题,但生产环境里我强烈建议改成“双路召回 + 重排序”的结构。
双路召回指的是同时用向量检索和关键词检索各跑一路,再把结果融合。为什么要加关键词检索?因为向量检索擅长语义匹配,但它对专有名词、代码变量名、产品型号的处理并不理想。比如用户问“如何配置 SLB-8080”,这里面的“SLB-8080”是典型标识符,向量空间里很难找到语义相近的片段,而关键词检索能精准命中。召回后的融合策略我用 RRF(Reciprocal Rank Fusion),它不依赖分数绝对值,只看排名。实现方式很简单:每个文档在多个召回列表里都算一个倒数排名分数,最后累加排序。
from rank_bm25 import BM25Okapi # 关键词召回 bm25 = BM25Okapi([doc["text"] for doc in documents]) def keyword_search(query, top_k=5): tokenized_query = list(query) # 中文按字切分比较简单 scores = bm25.get_scores(tokenized_query) top_indices = np.argsort(scores)[::-1][:top_k] return [documents[i] for i in top_indices] def vector_search(query, embed_model, index, top_k=5): vec = embed_model.encode(query) scores, indices = index.search(np.array([vec]).astype("float32"), top_k) return [documents[i] for i in indices[0]] # 重排序(RRF融合) def rrf_fusion(doc_lists, k=60): score_map = {} for doc_list in doc_lists: for rank, doc in enumerate(doc_list): doc_id = doc["text"] score_map[doc_id] = score_map.get(doc_id, 0) + 1.0 / (k + rank + 1) sorted_docs = sorted(score_map.items(), key=lambda x: x[1], reverse=True) return [doc for doc, _ in sorted_docs]重排序这一步,如果追求更好的效果,可以再接一个专门的交叉编码器(Cross-Encoder)模型做精排。它把 query 和候选片段拼接起来输入模型,输出一个相关性分数,精度比向量检索用的双塔模型高不少,但速度慢,所以只适合对少量候选做精排。我通常的做法是:向量召回和关键词召回各取前 20 条,RRF 融合后取前 10 条,再让交叉编码器按相关性精排取前 5 条送入大模型。这套链路的效果,实测比单独向量检索能提升明显,尤其在专有名词多的文档库场景。
3.5 生成阶段:提示词模板设计
检索的最终目的是服务生成。生成环节最核心的是提示词模板设计。我常用的模板结构分四段:角色定位、任务说明、背景材料、作答限制。
prompt_template = """ 你是一个企业内部知识库问答助手。请严格根据以下提供的背景资料回答用户的问题。 背景资料: {context} 用户问题:{question} 作答要求: 1. 只基于上述背景资料作答,不要使用你自身的先验知识。 2. 如果背景资料中找不到答案,请直接回答“资料库中暂无相关信息”。 3. 如果背景资料存在矛盾,请分别列出不同出处的内容,并标注来源。 4. 回答采用简洁的中文段落,必要时用列表说明。 请回答: """有几个细节一定要处理好。背景资料的前后顺序很关键,一般把精排分数最高的片段放在最前面,模型会更关注靠前的内容。另外,要在提示词里明确告诉模型“不许编”,否则它忍不住会用训练数据里的常识来补全。还有一个我踩过坑的细节:如果召回的片段里有大量表格和代码,提示词里要预先声明“背景资料包含代码和表格,请按原样理解”,不然模型可能把代码错当成自然语言去做抽象概括。
context = "\n\n".join( f"[来源 {i+1}] {doc['text']}" for i, doc in top_docs )来源编号的作用不只是定位,还能让模型在作答时引用“来源 1”之类的标记,输出结果会更有条理。
4. 生产环境中的常见问题与排查技巧实录
4.1 用户问法太随意,召回结果总是不理想
这是上线后被反馈最多的问题。真实用户不会像测试人员那样规范提问,他们会说“那个接口怎么整的来着”,“超时时间弄大点行不行”。这类口语化、省略式的问题,直接做向量检索,效果往往不好。
我的应对方法是加一层“查询改写”。在检索之前,先让大模型把用户的问题做一次标准化重写,把指代词补全、把口语转成书面表达。比如“那个接口怎么整的来着”改写为“查询 XX 接口的调用方法与参数配置说明”。需要的额外成本只是一次 LLM 调用,但检索精度提升非常明显。注意改写后的 query 只用于检索,不要覆盖用户原始问题,生成阶段的 question 仍用原问法,这样回答语气更自然。
4.2 数据更新后,老答案还在“作怪”
知识库是动态的,文档更新后你重建了索引,但用户提问时依然召回旧数据。这个问题的根源多半是向量库里旧版本文档没有删除,或者更新逻辑没考虑版本覆盖。我的做法是写入元数据版本号,在检索时强制过滤旧版本。
# 检索前过滤版本 def search_with_version(query, version="v2.0", top_k=5): candidates = vector_search(query, top_k=50) filtered = [ doc for doc in candidates if doc.get("version", "v1.0") == version ] return filtered[:top_k]更健壮的做法是在文档加载阶段设计增量更新任务:新增文档做增量向量化,失效文档按文档ID删除对应向量。这个流程建议做成定时任务,每天低峰期自动跑。
4.3 怎么评估系统效果,而不是靠感觉
上线前一定要建一套评估集,否则你根本说不清楚系统好没好。我维护的评估集一般是几百条真实用户问题,每条问题配上期望命中的文档ID和理想答案要点。每次改动切分策略或检索链路,就用这套评估集跑一遍召回率、命中率、忠实度、相关度。
召回率看的是“正确答案有没有被召回”;命中率看的是“top_k 里有没有命中文档”;忠实度看的是“生成的答案是否严格基于材料,有没有幻觉”。这几个指标分别对应不同的优化点:召回率低,多半是切分粒度或嵌入模型有问题;命中率低,多半是重排序没做好;忠实度低,多半是提示词约束不够或上下文里混入了矛盾信息。
我用来做评估的流程很简单:自动跑批,逐条记录召回的文档ID和模型输出,然后人工抽检或调用评测 API 辅助打分。只要评估集固定,改动前后对比跑一下,就能知道这次改动是变好还是变坏。这个习惯救了我很多次,没有这套评估,你会很容易在调参的路上来回折腾而不自知。
4.4 相似度阈值到底设多少
向量检索返回的是相似度分数,设置一个阈值可以有效过滤不相关的片段。但这个阈值绝对不能拍脑袋定死。BGE 系列和 OpenAI Embedding 的分数分布差异巨大,不同领域、不同切分方式也会导致分布不同。我的建议是:上线前用评估集跑一遍,统计命中文档的分数分布和未命中文档的分数分布,找到二者的分界点再设阈值。上线后还要定期观察新的查询分布,动态微调。不要试图用一个万能阈值适配所有场景,这不现实。
5. 落地扩展:从一个Demo到一个可用系统
5.1 检索之外还需要什么
Demo 能跑通只是第一步,生产系统还要考虑权限隔离、并发性能、日志监控。权限隔离这一点在内部知识库场景尤其重要:不是每个员工都能查看所有文档,但 RAG 系统默认是全库召回。解决方案是给文档打权限标签,在检索阶段根据用户身份过滤候选集。注意过滤一定要在召回阶段做,不能放在生成阶段靠模型自觉,否则模型仍然可能把权限外信息带出来(哪怕不带出来,也可能被诱导推断)。
并发性能上,向量检索本身很快,真正吃性能的是重排序的交叉编码器和生成模型。如果并发高,建议把检索和重排序做成独立服务并开启批处理,生成模型可以配合缓存策略,相同的查询直接返回缓存结果。
5.2 从离线索引到在线服务
一个相对完整的架构,我建议拆成三个进程:索引构建进程、检索服务进程、生成服务进程。索引构建进程负责定时从数据源拉取文档、解析切分、向量化、写入向量库,完全离线运行。检索服务进程接收查询请求,完成召回、过滤、重排序,返回结构化候选片段。生成服务进程接收用户问题和候选片段,调用大模型生成最终答案。这样的拆分看起来多了一步,但后续扩展缓存、负载均衡、异步批处理都很方便,而且出错时可以只重启出问题的部分。
# 检索服务的伪代码框架 class RetrieverService: def retrieve(self, query, user_id, top_k=5): rewritten_query = self.rewrite(query) vector_docs = self.vector_search(rewritten_query, top_k=50) keyword_docs = self.keyword_search(rewritten_query, top_k=50) fused = rrf_fusion([vector_docs, keyword_docs], k=60) filtered = [doc for doc in fused if self.is_allowed(doc, user_id)] ranked = self.cross_encoder.rank(rewritten_query, filtered[:10]) return ranked[:top_k]5.3 未来值得关注的方向
在基础流程稳定后,可以逐步引入一些增强手段:多路知识源融合(文档库 + 数据库 + 实时 API)、查询意图分类(不同的查询走不同的检索策略)、Agent 化流程(多轮主动追问澄清需求后再检索)。我一直觉得 RAG 的价值不只是做一个问答机器人,它是把企业沉睡的文档资产重新激活的一种通用范式。等你把检索、评估、迭代这套闭环跑顺之后,会发现它还能延伸到搜索升级、写作辅助、培训陪练等一串场景,围绕一套知识底座能做的事情远比一个问答入口要多。
我在实际项目中感受最深的一点是:RAG 的调优绝不是模型或框架能一步到位的,它是典型的“细节决定成败”。很多人问为什么照着开源 Demo 复制了一份,效果就是不如别人演示的好,差别往往就在数据清洗的程度、切分的粒度、检索链路是否做了双重校验这些“看不见的功夫”上。如果你正准备在业务里引入知识问答,我建议从一套小规模、真实数据的完整闭环开始,先把评估集建起来,再逐步调优迭代,而不是一上来就追最新最强的模型。把每一步为什么这么做想清楚,你搭出来的系统自然比盲目堆组件要稳定得多。