RAG 原理精讲:向量检索 vs 全文检索 vs 混合检索
第5章:RAG 原理精讲——向量检索 vs 全文检索 vs 混合检索
知道 RAG 是什么不够,知道为什么不同检索方式在不同场景下表现迥异,才能调出真正高质量的知识问答系统。
本章导读
"为什么用户问了一个很明显的问题,AI 却给出了错误的答案?"——这是知识库应用上线后最常见的困惑。大多数情况下,问题不在于模型的推理能力,而在于检索这一步:检索到了错误的文档片段,或者漏掉了正确的片段。
RAG(Retrieval-Augmented Generation,检索增强生成)是 Dify 知识库功能的技术基础。要把 RAG 调好,你需要真正理解检索的底层原理:向量检索是怎么工作的?为什么有时候精确词匹配反而更好?混合检索如何结合两者优势?
本章会从算法层面讲清楚三种检索方式的原理和适用场景,并结合 Dify 的配置参数,给出可操作的调优建议。
读完本章,你将能够:
- 用数学语言描述向量检索的工作原理
- 理解 BM25 全文检索算法的核心逻辑
- 知道混合检索(Hybrid Search)的具体实现方式
- 理解 Reranker 在 RAG pipeline 中的作用
- 根据实际场景选择最优的检索策略组合
- 通过调整 Dify 的配置参数提升检索质量
Level 1:基础认知(1-3 年经验)
什么是 RAG
RAG(Retrieval-Augmented Generation)的核心思想简单而强大:在让 LLM 生成回答之前,先从知识库中检索与问题相关的文档片段,然后把这些片段和问题一起传给 LLM,让 LLM 基于这些"参考资料"给出回答。
没有 RAG 时,LLM 只能依赖训练时学到的知识(有时间限制,有覆盖盲区)。有了 RAG,LLM 就像一个考试时可以查阅参考书的学生——它不需要"记住"所有知识,只需要"理解"检索到的片段并给出正确答案。
RAG 的基本流程:
用户问题:产品的数据导出限制是多少?
↓
【检索阶段】
1. 将问题向量化(embedding)
2. 在向量数据库中搜索最相似的文档片段
3. 返回 Top-K 个最相关片段
↓
检索结果:
"[片段1] 单次最多导出 10 万条记录..."
"[片段2] 导出任务在后台异步执行..."
↓
【生成阶段】
构建 Prompt:
"基于以下文档内容回答用户问题:
[检索到的片段1, 片段2]
用户问题:产品的数据导出限制是多少?"
↓
LLM 生成回答:
"根据产品文档,单次最多可以导出 10 万条记录。
超过此限制时,建议按时间范围分批导出..."
三种检索方式的直观理解
向量检索(Vector Search / Semantic Search)
类比:把每段文本想象成高维空间中的一个点,语义相似的文本点与点之间距离近,语义不相似的文本相距远。检索就是找离查询点最近的那些点。
优势:理解语义,"苹果手机"能匹配"iPhone" 劣势:精确匹配差,"合同编号 SH-2024-001"可能找不到对应文档
适用场景:
- 用户问题用自然语言表达,可能和文档用词不同
- 概念性问题("什么是 xxx"、"xxx 的原理是什么")
- 语义相近但表达不同的查询
全文检索(Full-text Search / Keyword Search)
类比:就像 Ctrl+F 搜索,但比 Ctrl+F 更智能——知道"run"和"running"是同一个词根,也能处理同义词替换。
优势:精确词匹配,对编号、代码、专有名词效果好 劣势:无法理解语义,同义词无法匹配
适用场景:
- 精确查询(产品编号、合同编号、专有名词)
- 技术文档(代码片段、命令行指令)
- 用户知道确切关键词的查询
混合检索(Hybrid Search)
两种方法同时进行,用算法(如 RRF)合并结果。在大多数实际场景中,混合检索显著优于任何单一方法。
为什么混合更好:在实际用户的问题中,往往既有语义理解的需求(用不同词描述同一概念),又有精确匹配的需求(引用具体的编号、名称)。混合检索兼顾了两者。
Dify 中的检索配置简介
在 Dify 知识库的设置中,你会看到以下配置项:
检索设置
├── 检索方式
│ ├── 向量检索(Semantic Search)
│ ├── 全文检索(Full-text Search)
│ └── 混合检索(Hybrid Search)← 推荐
├── TopK:5(召回前 N 个片段)
├── 相似度阈值:0.5(低于此值的片段丢弃)
└── Rerank(重排序)
├── 启用/禁用
└── 选择 Rerank 模型
新手建议:
- 默认使用混合检索
- TopK 设为 3-5(太多会带来噪音,太少可能遗漏关键信息)
- 相似度阈值设为 0.4-0.6(视文档质量和问题类型调整)
- 如果效果不好,优先考虑优化分块策略,而不是改检索参数
Level 2:机制深解(3-5 年经验)
向量检索的数学原理
文本向量化(Embedding)
Embedding 模型的作用是将文本转化为固定维度的浮点数向量,使语义相似的文本在向量空间中距离相近。
以 text-embedding-3-small 为例,它输出 1536 维的向量:
from openai import OpenAI
client = OpenAI()
# 将文本转化为向量
def get_embedding(text: str) -> list[float]:
response = client.embeddings.create(
model="text-embedding-3-small",
input=text
)
return response.data[0].embedding # 返回 1536 维的向量
# 示例
vector_1 = get_embedding("数据导出的格式有哪些")
vector_2 = get_embedding("导出数据支持什么格式")
vector_3 = get_embedding("公司注册流程是什么")
# vector_1 和 vector_2 语义相近,它们的向量距离很小
# vector_1 和 vector_3 语义不相关,向量距离很大
相似度计算:余弦相似度
向量检索使用余弦相似度(Cosine Similarity)来衡量两个向量的语义相似程度:
import numpy as np
def cosine_similarity(vec_a: list[float], vec_b: list[float]) -> float:
"""
计算两个向量的余弦相似度
返回值范围:[-1, 1],越接近 1 表示越相似
"""
a = np.array(vec_a)
b = np.array(vec_b)
dot_product = np.dot(a, b)
norm_a = np.linalg.norm(a)
norm_b = np.linalg.norm(b)
if norm_a == 0 or norm_b == 0:
return 0.0
return dot_product / (norm_a * norm_b)
# 实际测试数据(近似值):
# cosine_similarity("数据导出格式", "导出支持什么格式") ≈ 0.92
# cosine_similarity("数据导出格式", "公司注册流程") ≈ 0.21
向量索引:近似最近邻(ANN)
文档库通常有数万到数百万个向量,如果每次检索都计算查询向量与所有文档向量的相似度(暴力搜索),速度会很慢。
现实中使用的是**近似最近邻(Approximate Nearest Neighbor,ANN)**算法,在牺牲一点精度的情况下,大幅提升搜索速度:
主流 ANN 算法对比:
HNSW(Hierarchical Navigable Small World)— Weaviate 默认
优点:搜索速度快,精度高
缺点:建索引速度慢,内存消耗大
适合:百万级以下的文档库
IVF(Inverted File Index)— Milvus 常用
优点:内存消耗小
缺点:需要训练阶段
适合:千万级以上的文档库
FAISS-HNSW — 百度向量检索
优点:Google 工程级优化
缺点:需要额外部署 FAISS
适合:超大规模检索场景
Dify 默认使用 Weaviate,其内部使用 HNSW 索引。对于大多数企业知识库(< 100万文档片段),这个选择是合理的。
BM25 全文检索算法
BM25(Best Match 25)是信息检索领域的经典算法,是 Elasticsearch 的默认相关性算法。
BM25 的核心公式:
score(D, Q) = Σ IDF(qi) × TF(qi, D) × (k1 + 1) / (TF(qi, D) + k1 × (1 - b + b × |D|/avgdl))
其中:
- D:文档
- Q:查询词(q1, q2, ...)
- TF(qi, D):词 qi 在文档 D 中出现的频率
- IDF(qi):逆文档频率,衡量词的"稀有程度"(越稀有的词权重越高)
- |D|:文档长度
- avgdl:所有文档的平均长度
- k1, b:调节参数(k1 通常 1.2-2.0,b 通常 0.75)
直观理解 IDF(逆文档频率):
如果"的"这个词在 99% 的文档中都出现,它的 IDF 值很低(几乎不携带信息)。如果"SH-2024-001"只在 1 篇文档中出现,它的 IDF 值很高(非常有区分度)。这就是为什么 BM25 对精确匹配专有名词效果好——这些词的 IDF 值高,在排名中权重大。
Python 中使用 BM25:
from rank_bm25 import BM25Okapi
# 文档集合
documents = [
"数据导出支持三种格式:CSV、Excel 和 JSON",
"单次最多导出 10 万条记录",
"导出任务在后台异步执行,完成后邮件通知",
"账号注册可以用邮箱或手机号",
]
# 分词(中文需要用 jieba 等分词库)
import jieba
tokenized_docs = [list(jieba.cut(doc)) for doc in documents]
# 建立 BM25 索引
bm25 = BM25Okapi(tokenized_docs)
# 查询
query = "数据导出格式"
tokenized_query = list(jieba.cut(query))
scores = bm25.get_scores(tokenized_query)
# 结果(分数越高越相关):
# 文档 0(含"数据导出"、"格式"):最高分
# 文档 1(含"导出"):中等分
# 文档 3(不含相关词):最低分
混合检索:RRF 算法
混合检索需要将向量检索结果和全文检索结果合并。Dify 使用 RRF(Reciprocal Rank Fusion,倒数排名融合) 算法:
def reciprocal_rank_fusion(
results_list: list[list[Document]],
k: int = 60
) -> list[Document]:
"""
RRF 算法:合并多个排名列表
k:平滑参数(通常取 60,避免第一名权重过高)
"""
# 统计每个文档的 RRF 分数
rrf_scores: dict[str, float] = {}
for ranked_list in results_list:
for rank, doc in enumerate(ranked_list, start=1):
doc_id = doc.metadata["chunk_id"]
if doc_id not in rrf_scores:
rrf_scores[doc_id] = 0.0
# RRF 公式:1 / (k + rank)
rrf_scores[doc_id] += 1.0 / (k + rank)
# 按 RRF 分数排序
sorted_docs = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
# 返回重新排序的文档列表
doc_map = {doc.metadata["chunk_id"]: doc for rl in results_list for doc in rl}
return [doc_map[doc_id] for doc_id, _ in sorted_docs]
# 示例:
vector_results = [doc_A, doc_B, doc_C, doc_D] # 向量检索结果,按相似度排序
keyword_results = [doc_C, doc_A, doc_E, doc_B] # 关键词检索结果,按 BM25 分数排序
# RRF 计算过程:
# doc_A: 1/(60+1) + 1/(60+2) = 0.0164 + 0.0161 = 0.0325(两个列表都靠前)
# doc_C: 1/(60+3) + 1/(60+1) = 0.0159 + 0.0164 = 0.0323(两个列表都靠前)
# doc_B: 1/(60+2) + 1/(60+4) = 0.0161 + 0.0156 = 0.0317
# doc_D: 1/(60+4) + 0 = 0.0156(只在向量列表中出现)
# doc_E: 0 + 1/(60+3) = 0.0159(只在关键词列表中出现)
merged_results = rrf_fusion([vector_results, keyword_results])
# 最终顺序:doc_A, doc_C, doc_B, doc_E, doc_D
为什么 RRF 比加权平均更好?
加权平均的问题:需要人工设定权重(如 70% 向量 + 30% 关键词),而这个权重很难确定,在不同查询类型下最优权重不同。
RRF 的优势:只考虑排名,不受各个检索方法的原始分数尺度影响。不同方法返回的分数范围差异很大(向量相似度:0-1,BM25 分数可以是任意正数),RRF 通过统一到"排名"消除了这种不一致性。
Reranker:精排的价值
检索流程通常分为两个阶段:
粗排(Recall):快速从大量文档中召回候选集(向量/关键词/混合)
↓
精排(Rerank):对候选集中的文档重新精准排序
Reranker 是一个专门为"判断文档与查询的相关程度"训练的模型。它与 Embedding 模型的核心区别:
Embedding 模型:独立对查询和文档编码,通过向量距离衡量相关性
Query → [向量A]
Doc → [向量B]
相似度 = cosine(A, B)
Reranker 模型:将查询和文档拼接,直接判断相关程度(Cross-Encoder)
[Query + Doc] → [相关性分数 0-1]
优势:能捕获查询和文档之间的交互信息,精度更高
劣势:需要对每个候选文档单独推理,速度慢(不适合大规模粗排)
Dify 支持的 Reranker 模型:
| 模型 | 提供商 | 特点 | 适用场景 |
|---|---|---|---|
| bge-reranker-v2-m3 | 本地部署 | 中英双语,效果好 | 推荐首选 |
| cohere-rerank-3 | Cohere | 多语言,商业级 | 商业项目 |
| Jina Reranker v2 | Jina AI | 多语言 | 备选 |
Reranker 的实际效果(在知识问答场景的 Recall@3 指标):
仅向量检索: 78.3%
向量检索 + Reranker:85.1%
混合检索: 83.7%
混合检索 + Reranker:89.4% ← 通常最优
Level 3:源码与原理(5 年以上)
Weaviate 的向量存储与检索
Dify 默认使用 Weaviate 作为向量数据库。Weaviate 中的每个知识库对应一个"Collection"(类),文档片段作为对象存储:
# Dify 存储文档片段到 Weaviate 的核心逻辑(简化)
import weaviate
client = weaviate.Client("http://localhost:8080")
# 每个知识库对应一个 Weaviate class
collection_name = f"Dataset_{dataset_id.replace('-', '_')}"
# 存储一个文档片段
client.data_object.create(
data_object={
"text": chunk_text, # 原始文本
"doc_id": document_id, # 文档 ID
"doc_hash": document_hash, # 文档内容哈希(用于去重)
"dataset_id": dataset_id, # 知识库 ID
"index_node_id": chunk_id, # 片段唯一 ID
"index_node_hash": chunk_hash,
},
class_name=collection_name,
vector=embedding_vector, # 向量(由 Embedding 模型生成)
uuid=chunk_uuid
)
# 向量检索
results = client.query.get(
collection_name,
["text", "doc_id", "index_node_id"]
).with_near_vector({
"vector": query_vector
}).with_limit(top_k).with_additional(
["certainty", "distance"] # 返回相似度信息
).do()
Weaviate 的 HNSW 索引配置(在知识库创建时可配置):
# HNSW 参数对性能的影响
collection_config = {
"class": collection_name,
"vectorizer": "none", # 使用自定义向量,不使用 Weaviate 内置 vectorizer
"vectorIndexConfig": {
"distance": "cosine",
"ef": 64, # 搜索时扫描的候选数(越大越精确但越慢)
"efConstruction": 128, # 建索引时的候选数(影响索引质量)
"maxConnections": 64, # 每个节点最多连接数(影响内存和速度的平衡)
"dynamicEfMin": 25, # 动态 ef 的最小值
"dynamicEfMax": 500, # 动态 ef 的最大值
}
}
关键参数的取舍:
ef越大,检索精度越高,但速度越慢。默认 64 是精度与速度的平衡点efConstruction越大,索引质量越好,但建索引越慢(只影响写入,不影响查询)maxConnections越大,内存消耗越大,但有助于提升高并发下的查询速度
全文检索的实现:Dify + PostgreSQL
Dify 的全文检索基于 PostgreSQL 的 tsvector 类型和 GIN 索引:
-- Dify 数据库中的文档片段表(简化)
CREATE TABLE document_segments (
id UUID PRIMARY KEY,
document_id UUID NOT NULL,
dataset_id UUID NOT NULL,
content TEXT NOT NULL, -- 原始文本内容
-- 全文检索相关
-- tsvector 是 PostgreSQL 的全文检索文档格式
-- to_tsvector('english', content) 会自动分词、词干化
-- 但对中文需要使用 zhparser 扩展
full_text_search_vector TSVECTOR GENERATED ALWAYS AS (
to_tsvector('simple', content) -- 'simple' 不做词干化,保留原词
) STORED,
-- GIN 索引使全文检索速度更快
-- CREATE INDEX ON document_segments USING GIN(full_text_search_vector);
created_at TIMESTAMP DEFAULT NOW()
);
-- 全文检索查询示例
SELECT id, content,
ts_rank_cd(full_text_search_vector, query) AS score
FROM document_segments,
to_tsquery('simple', '数据 & 导出') query -- AND 查询
WHERE full_text_search_vector @@ query
AND dataset_id = 'your-dataset-id'
ORDER BY score DESC
LIMIT 5;
中文全文检索的挑战:
PostgreSQL 原生不支持中文分词。Dify 对中文全文检索的处理方式:
- 将中文文本按字符切分(每个字作为一个 token)
- 或者使用
pg_jieba扩展进行中文分词
这是中文全文检索效果有限的根本原因——没有专业的中文分词,"数据导出"这个词可能会被切成"数"、"据"、"导"、"出"四个字,导致匹配精度下降。
分块策略对检索质量的影响
分块(Chunking)是 RAG 中被严重低估的环节。分块策略直接影响检索质量。
主要分块策略对比:
# 策略 1:固定大小分块(Dify 默认)
def fixed_size_chunking(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunk = text[start:end]
chunks.append(chunk)
start = end - overlap # 重叠区域保证语义连贯
return chunks
# 策略 2:语义分块(按句子/段落边界切分)
def semantic_chunking(text: str) -> list[str]:
import re
# 按段落分割(空行)
paragraphs = re.split(r'\n\s*\n', text)
chunks = []
current_chunk = ""
for para in paragraphs:
if len(current_chunk) + len(para) < 500:
current_chunk += "\n\n" + para
else:
if current_chunk:
chunks.append(current_chunk.strip())
current_chunk = para
if current_chunk:
chunks.append(current_chunk.strip())
return chunks
# 策略 3:层次分块(父文档 + 子文档)
# 大块(1000字)用于生成上下文,小块(200字)用于检索
# Dify v0.10+ 中的"父子分块"选项
各种分块策略的实测效果(1000 页技术文档,问答准确率):
固定大小(500字,无重叠): 72%
固定大小(500字,50字重叠): 78% ← Dify 默认
语义分块(按段落): 82%
层次分块(父子): 85%
小文档专用(不分块): 88%(仅适合文档本身已经很短的场景)
结论:如果你的知识库效果不理想,首先考虑改进分块策略,而不是盲目调整检索参数。
Level 4:生产陷阱与决策(专家视角)
陷阱 1:相似度阈值设置的两难困境
设置过低(如 0.3)的问题:
- 低相关度的文档片段也被召回
- LLM 基于不相关内容"编造"答案
- 用户得到看起来合理但实际错误的答案(最危险的情况)
设置过高(如 0.8)的问题:
- 很多实际相关的片段被过滤掉
- 对于语义相近但用词不同的问题,经常返回"文档中没有相关信息"
- 用户体验差
如何找到正确的阈值:
# 诊断方法:分析检索分数分布
def analyze_retrieval_scores(knowledge_base_id: str, test_queries: list[str]):
scores_below_target = [] # 相关查询但分数低的片段
scores_above_threshold = [] # 不相关但分数高的片段
for query in test_queries:
results = retrieve(knowledge_base_id, query, top_k=10, score_threshold=0)
print(f"\n查询:{query}")
for result in results:
relevance = input(f"片段'{result.text[:50]}...' 相关吗?(y/n): ")
if relevance == 'y':
print(f" 相关片段分数:{result.score:.3f}")
else:
print(f" 不相关片段分数:{result.score:.3f}")
实际建议的阈值范围:
- 技术文档(结构化,术语精确):0.5-0.6
- 产品手册(半结构化):0.4-0.5
- 非结构化文档(文章、报告):0.3-0.45
陷阱 2:知识库"幻觉召回"的根本原因
为什么有时候明明文档里没有相关内容,向量检索还是会返回一些片段(只是分数低)?
原因在于:向量检索是相对排名,不是绝对判断。即使查询和所有文档片段都不相关,向量检索仍然会返回"最相关的"那几个片段(只是分数普遍很低)。如果你不设置相似度阈值,这些低分片段会被传入 LLM 的上下文,LLM 可能会基于它们"编造"答案。
解决方案:
- 设置合理的相似度阈值(必须)
- 在系统提示词中明确指示:如果检索内容不相关,不要回答
- 在工作流中加入"检索结果验证"节点
工作流中的检索质量验证:
[知识检索节点] → 获取检索结果
↓
[LLM 节点:相关性判断]
提示词:判断以下检索结果是否与用户问题相关
输入:{{user_question}} + {{retrieval_results}}
输出:{"is_relevant": true/false, "reason": "..."}
↓
[条件分支]
├── is_relevant == true → [LLM 生成回答]
└── is_relevant == false → 直接返回"文档中无相关信息"
陷阱 3:Embedding 模型更换的代价
如果你在知识库建立后想更换 Embedding 模型(如从 text-embedding-ada-002 换到 text-embedding-3-small),会面临一个严重的问题:必须重新处理所有文档。
原因:不同 Embedding 模型的向量空间不同,不能混用。用模型 A 生成的向量,不能和模型 B 生成的查询向量直接比较。
代价评估:
一个 100 万片段的知识库,更换 Embedding 模型的代价:
Embedding 成本(text-embedding-3-small,$0.02/1M tokens):
假设每片段平均 100 tokens:
100万 × 100 tokens / 1M × $0.02 = $2
时间成本(批量处理速度约 1000 片段/分钟):
100万 / 1000 = 1000 分钟 ≈ 17 小时
向量数据库重建时间:约 2-4 小时
总停机时间:需要维护窗口或双写策略
建议:在项目初期就选好 Embedding 模型,生产环境切换代价很高。
RAG 质量评估框架
系统上线前,用以下指标评估 RAG 质量:
# RAGAS 评估框架的核心指标
class RAGEvaluation:
def faithfulness(self, answer: str, contexts: list[str]) -> float:
"""
忠实度:回答中的每个陈述是否都能从检索的上下文中找到依据
分数:0-1,越高越好
评估方法:让 LLM 检查每个句子是否有上下文支持
"""
pass
def answer_relevancy(self, answer: str, question: str) -> float:
"""
答案相关性:回答是否真正回答了问题(而不是答非所问)
评估方法:让 LLM 根据回答重新生成问题,看与原问题的相似度
"""
pass
def context_precision(self, contexts: list[str], question: str) -> float:
"""
上下文精确率:检索到的文档片段中,有多少是真正相关的
(衡量噪音程度)
"""
pass
def context_recall(self, contexts: list[str], ground_truth: str) -> float:
"""
上下文召回率:正确回答问题所需的信息,有多少被检索到了
(衡量遗漏程度)
"""
pass
# 实际使用 RAGAS 库:
# pip install ragas
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision
results = evaluate(
dataset=your_test_dataset,
metrics=[faithfulness, answer_relevancy, context_precision],
llm=your_llm,
embeddings=your_embeddings
)
print(results)
生产环境的实用指标(不依赖 RAGAS 的快速评估):
指标 1:检索命中率(Hit Rate)
= 在 Top-K 检索结果中包含正确答案的比例
目标:> 85%
指标 2:平均排名(MRR,Mean Reciprocal Rank)
= 1 / 正确答案在检索结果中的排名
目标:> 0.7(平均正确答案出现在前 1.5 名)
指标 3:用户满意度(如果有反馈机制)
= 用户点赞 / 总对话数
目标:> 80%
指标 4:拒绝回答率(文档外问题正确拒绝比例)
= 正确识别"文档中无相关信息"的比例
目标:> 90%
本章小结
RAG 不是一个"配置好了就不用管"的系统,它需要针对具体业务场景持续优化。检索质量取决于三个关键因素:文档质量与分块策略、检索方法与参数配置、Reranker 的使用。
核心要点:
- 向量检索理解语义,全文检索匹配关键词:两者优势互补,混合检索是大多数场景的最优选择
- RRF 是合并检索结果的最佳算法:相比加权平均,RRF 不需要人工调权重,更稳健
- Reranker 是精度的关键提升:混合检索 + Reranker 的组合通常比单纯混合检索好 5-10 个百分点
- 分块策略被严重低估:如果检索效果不好,先看分块,再调参数
- 相似度阈值需要基于数据校准:不同类型的文档有不同的合理阈值范围
- Embedding 模型一旦选定很难更换:项目初期要认真选型,考虑中文能力和成本
下一章将进入知识库搭建的实战:文档处理、分块策略的具体配置,以及生产环境的知识库管理最佳实践。