在AI应用开发中,RAG(Retrieval-Augmented Generation,检索增强生成)已经成为构建知识库问答、企业文档助手、智能客服等场景的主流方案之一。很多初学者拿到 RAG 相关资料后,第一反应是“这不就是把文档切成块,存进向量数据库,再让大模型回答吗”。方向没错,但真实项目跑起来会发现,问题往往不出在大模型,而出在检索环节:文档切得不合适、向量召回不精准、TopK 结果里正确答案排不到前面。这篇文章从 0 到 1 梳理 RAG 的原理,重点围绕高级检索实战,讲清楚文本切块、向量检索、混合检索和重排序到底怎么配合,同时给出可运行的代码、参数建议和排查链路。
文章面向对 AI 应用开发有一定基础、希望深入理解 RAG 检索质量的开发者。学完后,你能搭建一个最小可运行的 RAG 系统,知道如何用高级检索手段提升答案准确率,也能在生成效果变差时快速定位问题出在检索、切块还是生成环节。
1. RAG 到底解决什么问题,为什么现在应用这么广
1.1 大模型的知识局限
大语言模型虽然能够生成相当流畅的文本,但它有一个天然限制:知识来自训练数据,并且训练数据有截止时间。当用户问一个企业内部 SOP、一套私有接口文档或者最新产品说明时,模型很可能回答不出来,甚至一本正经地给出错误答案。
要解决这个问题,主流思路有两种:
- 继续微调模型,让模型记住新知识。
- 在问答时临时把相关资料检索出来,放进提示词,让模型基于资料回答。
微调成本高、周期长,而且每次知识更新都要重新训练,不适合需要频繁变更内容的场景。RAG 选择的是第二种思路:不改变模型权重,只改变模型回答问题时“看到”的上下文。
所以 RAG 的核心定位是:给大模型接上外挂知识库,让它能依据检索到的内容生成答案。这也解释了为什么 RAG 和向量数据库、知识库这几个概念经常一起出现。
1.2 RAG 的核心概念
RAG 可以拆成三个字母来理解:
- Retrieval:检索。从知识库中找到和用户问题最相关的若干文本片段。
- Augmented:增强。把检索到的片段作为额外上下文,拼进提示词。
- Generation:生成。大模型基于“原始问题 + 检索片段”生成回答。
一个最小 RAG 流程如下:
用户问题 -> 查询向量化 -> 检索相似片段 -> 组装提示词 -> 大模型生成回答这里的“检索”是 RAG 的灵魂。如果检索回来的片段本身不相关,大模型再强也无法给出正确答案。很多 RAG 项目效果差,恰恰是因为把太多精力放在模型选择上,忽略了检索质量。
1.3 RAG、知识库与向量数据库的关系
项目里经常听到“RAG 知识库”“向量数据库”这些说法,需要理清它们的关系:
| 概念 | 作用 | 举例 |
|---|---|---|
| 知识库 | 原始文档的组织形式,包含切分后的文本片段、元数据、权限信息 | 企业文档库、产品手册、运维工单 |
| 向量数据库 | 存储文本向量并支持相似度检索 | FAISS、Milvus、pgvector、Qdrant |
| RAG | 一套应用框架,把知识库、向量检索和大模型串起来 | LangChain、LlamaIndex、自研流程 |
简单理解:知识库是“存什么”,向量数据库是“怎么存和怎么查”,RAG 是“查完之后怎么让模型用起来”。做高级检索实战时,这三个层面都要考虑。
2. RAG 的完整链路,每个环节都要先想清楚
RAG 不是“把所有文档塞进向量库”这么简单。一个完整的 RAG 系统由离线和在线两部分组成。
2.1 数据接入与准备
离线阶段的核心任务是把原始数据变成可检索的文本片段。原始数据可能是 PDF、Word、Markdown、HTML,也可能来自数据库表格。这一步通常包括:
- 格式解析:把 PDF、Word 等内容抽取成纯文本。
- 清洗:去掉页眉页脚、重复内容、无关水印、乱码字符。
- 结构识别:尽量保留标题、段落、表格、列表等结构信息。
清洗后的文本质量直接决定切块效果。一个常见错误是:解析 PDF 时带了大量换行和页码,导致切出来的块有一半是空白字符,向量化之后语义被稀释。
2.2 索引构建:切分、向量化、存储
索引构建是离线阶段最核心的步骤,包含三个动作:
- 切分:把长文档按策略切成长度合适的块(chunk)。
- 向量化:用 Embedding 模型把每个块转换成向量。
- 存储:把向量和原始文本、元数据一起写入向量数据库。
切分是 RAG 里最容易被忽略但影响最大的环节。后面第 4 章会详细讲不同切块策略的效果。
向量化时,要选择与查询语言和场景匹配的 Embedding 模型。中文场景下,可以优先考虑支持中文的模型,并在出现术语、行业缩写时注意模型是否认识。
索引存储阶段,除了向量本身,还要保存原始文本片段、文档来源、章节路径、更新时间等元数据。这些元数据在后续过滤、展示引用来源、排错时非常有用。
2.3 查询阶段:检索、重排序、生成
在线阶段是用户每次提问都会执行的链路:
- 将用户问题向量化。
- 从向量索引中召回 TopK 个相似片段。
- 可选地,加入关键词检索,合并结果。
- 可选地,用重排序模型对合并结果进行精排。
- 将精排后的片段按顺序拼接到提示词中。
- 调用大模型生成答案。
第 2 到第 4 步属于“高级检索”范畴。基础 RAG 通常只做第 2 步,高级 RAG 则会加入混合检索和重排序。理解这条链路后,我们再从环境搭建开始,先跑通一个最小系统,再逐步优化检索质量。
3. 从0到1搭建一个最小RAG系统
3.1 环境准备与依赖安装
为了减少配置负担,示例采用 Python 3.9 以上环境,核心依赖包括:
- sentence-transformers:负责文本向量化。
- faiss-cpu:负责向量索引和相似度检索。
- rank-bm25:负责关键词检索,用于混合检索对比。
- openai 或其他 OpenAI 兼容客户端:负责调用大模型接口。
- python-dotenv:读取环境变量,保存 API Key。
安装命令如下:
pip install sentence-transformers faiss-cpu rank-bm25 openai python-dotenv如果使用本地 GPU,可以安装faiss-gpu;如果只是学习,CPU 版本足够。注意faiss-gpu和faiss-cpu不要同时安装,否则会出现底层库冲突。
注意:实际项目落地前,需要先确认依赖版本与 Python 版本、操作系统的兼容性。这里给出的命令用于演示思路,具体版本要以自己的环境为准。
3.2 准备测试文档
先用一段模拟产品说明作为测试文档,内容包含可被检索的关键信息。
raw_text = """ 多功能巡检机器人系统说明: 一、设备供电 机器人本体采用 48V 直流电源供电,充电桩输出功率为 500W。 二、通信方式 机器人支持 Wi-Fi 6 和 5G 两种通信方式,在断网情况下可自动切换为本地规划模式。 三、安全机制 当激光雷达检测到前方 30cm 内有障碍物时,机器人会紧急制动。 四、任务调度 机器人可通过管理后台下发巡检任务,任务类型包括日常巡检、指定点位抽查和环境监测。 """这段文本结构简单,包含编号和标题。切分时如果直接按固定长度切,很容易把“48V 供电”和“通信方式”混在一个块里,导致检索精度下降。后面会看到如何通过结构化切分改善这个问题。
3.3 编写文本切分与向量化代码
先实现一个简单的切分函数。为了演示原理,这里使用按字符固定长度切分,并允许重叠。
def split_text(text, chunk_size=100, chunk_overlap=20): chunks = [] start = 0 text_len = len(text) while start < text_len: end = min(start + chunk_size, text_len) chunks.append(text[start:end]) if end == text_len: break start = end - chunk_overlap return chunks chunks = split_text(raw_text, chunk_size=80, chunk_overlap=15) for i, c in enumerate(chunks): print(f"chunk {i}: {c}")这段代码展示的是最基础的字符切分。chunk_size决定每个块的长度,chunk_overlap让相邻块保留重复信息,避免关键句子被切断。实际项目中不建议按字符切分,更好的是按语义或结构切分,后面会讲。
接下来做向量化并建索引。这里使用中文本向量模型BAAI/bge-small-zh-v1.5,它体积小,适合学习环境。
from sentence_transformers import SentenceTransformer import faiss import numpy as np model = SentenceTransformer("BAAI/bge-small-zh-v1.5") def embed_texts(texts): return model.encode(texts, normalize_embeddings=True) embeddings = embed_texts(chunks) dim = embeddings.shape[1] index = faiss.IndexFlatIP(dim) index.add(embeddings.astype("float32"))IndexFlatIP使用内积相似度。因为向量已经做过 L2 归一化,内积结果等价于余弦相似度,范围在 -1 到 1 之间。这里normalize_embeddings=True是关键,如果漏掉,内积的大小会受到向量长度影响,检索结果可能偏向长文本。
3.4 实现检索与生成调用
检索函数如下:
def search(query, k=3): query_vec = embed_texts([query]) scores, indices = index.search(query_vec.astype("float32"), k) results = [] for i in range(len(indices[0])): idx = indices[0][i] score = scores[0][i] results.append((score, chunks[idx], idx)) return results query = "机器人的供电电压是多少?" results = search(query, k=3) for score, chunk, idx in results: print(f"score={score:.4f} idx={idx}: {chunk}")生成环节使用 OpenAI 兼容接口。这样不管是 OpenAI 官方服务、还是本地起的模型服务,只要兼容/v1/chat/completions都能用。
import os from openai import OpenAI from dotenv import load_dotenv load_dotenv() client = OpenAI( base_url=os.getenv("LLM_BASE_URL", "http://localhost:8000/v1"), api_key=os.getenv("LLM_API_KEY", "sk-no-key"), ) def rag_answer(question, search_results): context = "\n\n".join([chunk for score, chunk, idx in search_results]) prompt = f"""请根据以下资料回答问题。 资料: {context} 问题:{question} 要求: 1. 只能基于资料回答。 2. 如果资料中没有相关信息,直接说明“资料中未找到”。 """ resp = client.chat.completions.create( model=os.getenv("LLM_MODEL", "qwen2.5"), messages=[{"role": "user", "content": prompt}], ) return resp.choices[0].message.content这段代码把检索结果拼进提示词,并要求模型不要编造。实际项目里,提示词可以更细致,比如要求模型给出引用片段编号、限定回答长度。
到这里,一个最小 RAG 已经跑通了。接下来要讨论的是:为什么有时候检索出来的片段明明相似,但生成答案还是不对。这就要进入高级检索实战。
4. 高级检索实战:从“能搜到”到“搜得准”
4.1 切块策略如何影响检索质量
切块是 RAG 中最容易被轻视的环节。同一个文档,按不同方式切,检索结果会差别很大。
常见切块方式:
| 切块方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 按固定字符切分 | 实现简单,长度可控 | 容易切断语义,块之间信息割裂 | 学习演示、纯文本无结构 |
| 按 token 切分 | 与模型 token 限制一致 | 会切断词或句子,需要 tokenizer | 需要精确控制 token 长度 |
| 按段落 / 标题切分 | 保留语义边界 | 某些段落过长,仍需二次切分 | PDF 解析后保留结构的情况 |
| 按语义切分 | 语义完整,检索质量高 | 依赖句子 embedding,计算量大 | 高质量知识库 |
| 父子块切分 | 检索小块,生成时用大块 | 存储和索引复杂度增加 | 需要精确片段且上下文完整 |
一个常见问题是:块太小,检索命中的片段只包含表头,没有对应内容;块太大,检索命中的片段包含大量无关内容,向量方向被稀释。
更实用的做法是按文档结构切分,保留标题和段落。代码中可以用简单规则识别“一、”这类章节标记。
def split_by_sections(text): lines = text.split("\n") sections = [] current_title = "" current_content = [] for line in lines: if line.strip() and (line.startswith("一、") or line.startswith("二、") or line.startswith("三、")): if current_title or current_content: sections.append((current_title, "\n".join(current_content))) current_title = line.strip() current_content = [] else: current_content.append(line.strip()) if current_title or current_content: sections.append((current_title, "\n".join(current_content))) return sections切分后,每一段都带上标题元数据。检索时,可以把“标题 + 内容”整体向量化,也可以在返回时把标题展示给用户作为来源。
注意:没有一种切块策略适用于所有场景。最佳做法是准备几十条有代表性的问题,对比不同切块方式下的检索命中率,用数据决定策略。
4.2 关键词检索与向量检索的互补
向量检索擅长找“语义相似”的内容,但也有明显弱点:对专有名词、精确数字、型号代码不敏感。比如用户问“48V 直流电源”,如果训练向量模型时没有充分见过类似表达,向量距离可能并不可靠。
关键词检索刚好相反。它通过精确匹配词项来召回,比如“48V”“500W”“Wi-Fi 6 协议”。缺点是查不到同义词和语义改写。把两者结合,就是混合检索。
先实现一个简单的关键词检索。这里用rank_bm25,需要先对中文做分词。为了演示,可以先用字符 n-gram 方式代替,但效果更好的是使用 jieba 分词。
pip install jiebaimport jieba def tokenize(text): return list(jieba.cut(text)) tokenized_corpus = [tokenize(chunk) for chunk in chunks] bm25 = BM25Okapi(tokenized_corpus) def bm25_search(query, k=3): tokenized_query = tokenize(query) scores = bm25.get_scores(tokenized_query) top_indices = scores.argsort()[-k:][::-1] results = [] for idx in top_indices: results.append((scores[idx], chunks[idx], idx)) return resultsBM25 对精确词命中很有效,尤其适合产品型号、报错码等场景。但单个关键词检索也有问题:如果问题里没有出现知识库里的原词,BM25 会把相关文档丢掉。
4.3 混合检索:结合BM25和向量相似度
混合检索的目标是:两种检索结果取并集,再对并集中的文档做综合打分。
常见的融合方法有两种:
- 加权得分加权:
final_score = alpha * vector_score + beta * bm25_score。 - RRF(Reciprocal Rank Fusion):对每个结果在不同检索结果中的排名取倒数,再求和。
RRF 不依赖分数绝对值,更适合两种分布不一致的分数融合。示例实现如下:
def rrf_fusion(vector_results, bm25_results, k=60, top_n=5): scores = {} for rank, (score, chunk, idx) in enumerate(vector_results): scores[idx] = scores.get(idx, 0) + 1.0 / (k + rank) for rank, (score, chunk, idx) in enumerate(bm25_results): scores[idx] = scores.get(idx, 0) + 1.0 / (k + rank) sorted_idx = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_n] return [(chunks[idx], idx, score) for idx, score in sorted_idx]每次检索时,向量检索召回 TopK,BM25 也召回 TopK,然后用 RRF 合并。这样做的好处是:向量检索负责语义相似,BM25 负责精确词匹配,互补性很强。
4.4 重排序:找回被TopK丢掉的正确答案
混合检索后,候选片段可能增加到 10 到 20 个。如果直接把这么多片段塞给大模型,既浪费 token,又可能引入噪音。更合理的做法是先用一个重排序模型对候选集精排,只取前 3 到 5 个进入提示词。
重排序模型和向量检索模型不同。向量检索的目标是“从全库找相关”,速度快;重排序的目标是“对少量候选精排”,精度高,但速度较慢。
可以使用跨编码器模型做重排序,示例:
from sentence_transformers import CrossEncoder reranker = CrossEncoder("BAAI/bge-reranker-base") def rerank(query, candidates, top_n=3): pairs = [[query, chunk] for chunk, idx, score in candidates] scores = reranker.predict(pairs) sorted_pairs = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True) return [cand for cand, score in sorted_pairs[:top_n]]经过重排序后,最终进入提示词的片段相关性更高。这里要特别注意:不要跳过混合检索直接重排序全库,因为重排序模型计算代价高,不适合处理超大规模候选集。
到这一步,我们已经有了一套相对完整的高级检索链路:
用户问题 -> 向量检索 TopK -> RRF 融合 -> 重排序 -> 精排片段 -> 大模型生成5. 参数调优与效果验证
5.1 关键参数速查表
RAG 项目里没有“万能参数”,但常见参数的含义和影响可以总结成一张表:
| 参数 | 默认值参考 | 调大影响 | 调小影响 | 建议 |
|---|---|---|---|---|
chunk_size | 200-500 字符 | 上下文更完整,但噪音增加 | 语义不完整,召回碎片化 | 按文档结构动态切分 |
chunk_overlap | 10%-20% | 减少断句损失,但增加重复 | 关键内容可能被切断 | 与切块方式配合 |
| TopK(检索) | 10-20 | 召回更全,但噪音增加 | 可能漏掉正确答案 | 用于候选召回阶段 |
| TopN(重排序后) | 3-5 | 上下文更丰富,但 token 消耗大 | 答案可能缺少支撑 | 取决于模型上下文长度 |
rrf_k | 60 | 排名融合更平滑 | 更关注头部排名 | 一般保持 60 |
| Embedding 维度 | 取决于模型 | 更高表达力,计算更慢 | 轻量,但可能损失语义 | 按模型官方说明 |
这里要强调:TopK 和 TopN 是两个不同阶段的参数。先召回一个较大的候选集,再精排到很小数量,是高级 RAG 的标准做法。
5.2 用检索评估指标判断效果
没有指标就谈不上调优。RAG 的评估至少包含两个层面:
- 检索层面:命中的片段是否包含正确答案。
- 生成层面:最终答案是否正确、可读、可溯源。
检索层面最简单的评估方式是“命中率”(Hit Rate):对每道测试题,看看正确答案所在的片段是否出现在检索结果前 K 个中。
def calculate_hit_rate(test_cases, search_func, k=5): hits = 0 for case in test_cases: query = case["query"] expected_chunk_id = case["chunk_id"] results = search_func(query, k=k) result_ids = [idx for score, chunk, idx in results] if expected_chunk_id in result_ids: hits += 1 return hits / len(test_cases)也可以计算 MRR(Mean Reciprocal Rank):正确答案出现在第 1 位得 1 分,第 2 位得 0.5 分,第 3 位得 0.33 分,取平均。MRR 对排序位置更敏感,适合对比不同检索策略。
5.3 一个简单的消融验证流程
想验证高级检索是否有用,可以按下面的流程做对比实验:
- 准备 30 到 50 条覆盖不同类型问题的测试集。
- 在相同切块和相同生成模型下,先只用向量检索跑一遍,记录命中率和答案分。
- 改成“向量 + BM25”混合检索,重新跑。
- 加上重排序,再跑。
- 对比每组实验结果,形成结论。
每次只改一个变量,否则无法定位是哪个步骤带来的提升。这类消融实验也是给团队或领导讲清楚“为什么要做高级检索”的最好材料。
6. 常见问题排查链路与生产环境建议
6.1 检索为空或相关度低
现象:模型回答“资料中未找到”,但人类一眼看出知识库里有答案。
排查顺序:
- 检查原始文档有没有被正确解析。如果 PDF 是扫描件,没有 OCR,抽取出来可能是空内容。
- 检查切块后的片段是否包含关键信息。打印出 chunks,确认“答案所在句子”没有被切散。
- 打印检索分数。如果 Top1 分数很低,说明向量检索本身没召回相关片段。
- 用 BM25 再查一次。如果 BM25 能命中,说明问题出在向量模型或切块策略。
- 检查查询预处理的归一化逻辑。比如问题里包含英文括号、全角半角混用,可能导致向量化后差异增大。
6.2 返回结果重复或内容截断
现象:模型回答里重复出现同一句话,或者明显在“硬凑内容”。
常见原因和处理建议:
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 多个检索片段内容几乎相同 | 切块重叠过高,或重复文本入索引 | 查看返回片段原文和元数据 | 降低重叠率,写入索引前去重 |
| 生成内容截断 | 提示词超出模型上下文长度 | 计算 prompt token 数 | 减少 TopN,或使用更长上下文模型 |
| 回答只有检索片段本身 | 提示词把“总结”写成了“复述” | 检查 prompt 指令 | 明确要求“基于资料,用自己的话概括” |
| 引用来源对不上 | 元数据没有随片段保留 | 检查索引是否记录文档 ID | 检索结果同步返回元数据 |
6.3 生产环境还需要补齐哪些能力
学习环境里,用IndexFlatIP把全部向量加载进内存即可。生产环境会复杂很多,至少还需要关注:
| 能力 | 生产环境要求 |
|---|---|
| 向量库 | 使用分布式向量数据库,支持过滤、权限、更新、备份 |
| 索引更新 | 支持增量写入,不阻塞查询 |
| 权限 | 用户只能检索自己有权限的文档,需要在检索时加元数据过滤 |
| 缓存 | 相同或相似问题可以缓存答案和检索结果 |
| 日志与监控 | 记录检索耗时、片段来源、生成 token 数、错误率 |
| 版本回滚 | 文档或模型升级后,如果效果下降,能快速回退 |
| 模型部署 | 根据成本、延迟、数据隐私决定使用本地模型还是云 API |
6.4 可复用的上线前检查清单
RAG 系统上线前,可以按下面清单逐项检查:
- 是否对原始文档做了格式解析和清洗,并验证了文本可读性。
- 是否按文档结构切块,并保留标题、来源、更新时间等元数据。
- 是否准备了覆盖常见问题、疑难问题、越权问题的测试集。
- 是否对比过至少两种检索策略,并用命中率、MRR 等指标验证。
- 是否配置了重排序,并调好 TopK、TopN 参数。
- 提示词是否限制了模型只能基于资料回答。
- 是否处理了“资料中未找到”的回答。
- 是否接入日志和监控,能追溯每次回答引用了哪些片段。
- 是否评估过大模型的上下文 token 开销和响应延迟。
- 是否做好文档更新、删除、权限隔离和版本回滚方案。
7. 从基础 RAG 到 Agentic RAG,下一步怎么走
这一步是自然的扩展方向。
基础 RAG 是“单轮检索 + 生成”。遇到复杂问题时,一次检索经常不够,比如用户连续追问“那电池容量呢”“如果断网怎么处理”,如果每次都把全部历史问题向量化检索,容易丢失上下文。
Agentic RAG 是近期工程实践里讨论较多的方向。它的核心思想是:让大模型自己决定“要不要检索”“检索什么”“是否需要多轮检索”“如何评估检索结果是否足够”。这不只是加一层循环,而是把检索、判断、再检索变成智能体行为。
对此,本文先不做展开,但建议把上面这些检索质量和评估方法掌握扎实。因为不管是基础 RAG 还是 Agentic RAG,最终效果依然依赖检索质量。
对刚接触 RAG 的开发者来说,最有价值的练习是:拿一份真实业务文档,准备 30 个问题,先用纯向量检索跑一版,再逐步加入结构化切块、混合检索和重排序,观察每个改动带来的命中率变化。这个过程比直接套框架更能理解 RAG 的本质。