第 5 章

RAG 原理精讲:向量检索 vs 全文检索 vs 混合检索

第5章:RAG 原理精讲——向量检索 vs 全文检索 vs 混合检索

知道 RAG 是什么不够,知道为什么不同检索方式在不同场景下表现迥异,才能调出真正高质量的知识问答系统。

本章导读

"为什么用户问了一个很明显的问题,AI 却给出了错误的答案?"——这是知识库应用上线后最常见的困惑。大多数情况下,问题不在于模型的推理能力,而在于检索这一步:检索到了错误的文档片段,或者漏掉了正确的片段。

RAG(Retrieval-Augmented Generation,检索增强生成)是 Dify 知识库功能的技术基础。要把 RAG 调好,你需要真正理解检索的底层原理:向量检索是怎么工作的?为什么有时候精确词匹配反而更好?混合检索如何结合两者优势?

本章会从算法层面讲清楚三种检索方式的原理和适用场景,并结合 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 万条记录。
    超过此限制时,建议按时间范围分批导出..."

三种检索方式的直观理解

类比:把每段文本想象成高维空间中的一个点,语义相似的文本点与点之间距离近,语义不相似的文本相距远。检索就是找离查询点最近的那些点。

优势:理解语义,"苹果手机"能匹配"iPhone" 劣势:精确匹配差,"合同编号 SH-2024-001"可能找不到对应文档

适用场景

类比:就像 Ctrl+F 搜索,但比 Ctrl+F 更智能——知道"run"和"running"是同一个词根,也能处理同义词替换。

优势:精确词匹配,对编号、代码、专有名词效果好 劣势:无法理解语义,同义词无法匹配

适用场景

两种方法同时进行,用算法(如 RRF)合并结果。在大多数实际场景中,混合检索显著优于任何单一方法。

为什么混合更好:在实际用户的问题中,往往既有语义理解的需求(用不同词描述同一概念),又有精确匹配的需求(引用具体的编号、名称)。混合检索兼顾了两者。

Dify 中的检索配置简介

在 Dify 知识库的设置中,你会看到以下配置项:

检索设置
├── 检索方式
│   ├── 向量检索(Semantic Search)
│   ├── 全文检索(Full-text Search)
│   └── 混合检索(Hybrid Search)← 推荐
├── TopK:5(召回前 N 个片段)
├── 相似度阈值:0.5(低于此值的片段丢弃)
└── Rerank(重排序)
    ├── 启用/禁用
    └── 选择 Rerank 模型

新手建议


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 的最大值
    }
}

关键参数的取舍

全文检索的实现: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 对中文全文检索的处理方式:

  1. 将中文文本按字符切分(每个字作为一个 token)
  2. 或者使用 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)的问题

设置过高(如 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}")

实际建议的阈值范围

陷阱 2:知识库"幻觉召回"的根本原因

为什么有时候明明文档里没有相关内容,向量检索还是会返回一些片段(只是分数低)?

原因在于:向量检索是相对排名,不是绝对判断。即使查询和所有文档片段都不相关,向量检索仍然会返回"最相关的"那几个片段(只是分数普遍很低)。如果你不设置相似度阈值,这些低分片段会被传入 LLM 的上下文,LLM 可能会基于它们"编造"答案。

解决方案

  1. 设置合理的相似度阈值(必须)
  2. 在系统提示词中明确指示:如果检索内容不相关,不要回答
  3. 在工作流中加入"检索结果验证"节点
工作流中的检索质量验证:

[知识检索节点] → 获取检索结果
      ↓
[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 的使用

核心要点

  1. 向量检索理解语义,全文检索匹配关键词:两者优势互补,混合检索是大多数场景的最优选择
  2. RRF 是合并检索结果的最佳算法:相比加权平均,RRF 不需要人工调权重,更稳健
  3. Reranker 是精度的关键提升:混合检索 + Reranker 的组合通常比单纯混合检索好 5-10 个百分点
  4. 分块策略被严重低估:如果检索效果不好,先看分块,再调参数
  5. 相似度阈值需要基于数据校准:不同类型的文档有不同的合理阈值范围
  6. Embedding 模型一旦选定很难更换:项目初期要认真选型,考虑中文能力和成本

下一章将进入知识库搭建的实战:文档处理、分块策略的具体配置,以及生产环境的知识库管理最佳实践。

本章评分
4.9  / 5  (62 评分)

💬 留言讨论