本文基于一套完整的工程实现,涵盖 Markdown 语义切块、FAISS 向量检索、BGE Reranker 重排,以及基于 Hit@K / Recall@K / MRR@K / nDCG@K 四指标的离线评测体系,适合希望将 RAG 从 Demo 推向生产的工程师参考。


一、整体架构概览

一个完整的 RAG(Retrieval-Augmented Generation)检索链路,可以分为以下几个阶段:

原始 Markdown 文档
       │
       ▼
  语义切块(Chunking)
       │
       ▼
  向量化(BGE Embedding)
       │
       ▼
  写入本地向量库(FAISS)
       │
       ▼
  查询时:粗召回(FAISS Top-K)
       │
       ▼
  精排(BGE Reranker Cross-Encoder)
       │
       ▼
  最终结果 → 喂给大模型(LLM)生成答案

涉及的三个核心模型角色要区分清楚,不能混淆:

模型 名称 作用
Embedding 模型 BAAI/bge-small-zh-v1.5 将文本编码为向量,用于语义切块与向量检索
向量数据库/索引 FAISS (IndexFlatIP) 存储向量,高效做内积近邻搜索(粗召回)
Reranker 模型 BAAI/bge-reranker-base Cross-Encoder,对 (query, chunk) 对直接打相关性分,用于重排

二、Embedding 方案

系统提供两套 Embedding 接入方案,可按需切换。

方案一:本地模型(离线)

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("BAAI/bge-small-zh-v1.5")

def embed(texts: List[str]) -> np.ndarray:
    vectors = model.encode(texts, normalize_embeddings=True)
    return np.array(vectors, dtype=np.float32)

normalize_embeddings=True 使输出向量 L2 归一化,这样内积(IP)等价于余弦相似度,与 FAISS 的 IndexFlatIP 配合使用时无需额外处理。

方案二:云端 API(DashScope OpenAI 兼容接口)

通过 OpenAI 客户端指向 DashScope 的 OpenAI 兼容地址,调用 text-embedding-v3,适合不想本地部署模型的场景。


三、文本切块(Chunking)

3.1 教学级:字符滑动窗口(Fixed-size Chunking)

最简单的实现是固定字符数的滑动窗口:

  • chunk_size 限定每块最大字符数
  • chunk_overlap 让相邻块保留重叠,避免边界信息丢失
  • 窗口起点每次前进 chunk_size - chunk_overlap

这属于 fixed-size chunking + overlap 的经典实现,切分不感知语义边界,适合入门理解,但在信息密度不均的文档上表现一般。

3.2 工程级:BGE 语义切块

本系统采用 BGE 驱动的语义切块,全程只用 Embedding 模型,不依赖任何生成式 LLM

第一步:将 Markdown 解析为段落单元
def split_markdown_to_units(text: str) -> List[str]:
    units = []
    buffer = []

    for line in text.replace("\r\n", "\n").split("\n"):
        stripped = line.strip()
        if not stripped:
            flush_buffer()      # 空行 → 段落边界
            continue
        if stripped.startswith("#"):
            flush_buffer()
            units.append(stripped)  # 标题单独成单元
            continue
        buffer.append(line.rstrip())

    flush_buffer()
    return units

逻辑:遇到空行或标题行就切断,将整段文字作为一个"段落单元"。

第二步:用 BGE 相邻语义相似度确定断点
def semantic_chunk_units(units, embedder,
                          min_chunk_chars=120,
                          max_chunk_chars=420,
                          break_percentile=30.0):
    vectors = embedder(units)
    # 计算相邻单元的内积相似度
    sims = [float(np.dot(vectors[i], vectors[i+1]))
            for i in range(len(units) - 1)]
    # 取第 30 百分位作为断点阈值
    threshold = float(np.percentile(sims, break_percentile))

    chunks, current_units, current_len = [], [units[0]], len(units[0])
    for i in range(1, len(units)):
        candidate_len = current_len + 2 + len(units[i])
        semantic_break = sims[i-1] < threshold and current_len >= min_chunk_chars
        length_break   = candidate_len > max_chunk_chars

        if semantic_break or length_break:
            chunks.append("\n\n".join(current_units).strip())
            current_units, current_len = [units[i]], len(units[i])
        else:
            current_units.append(units[i])
            current_len = candidate_len

    if current_units:
        chunks.append("\n\n".join(current_units).strip())
    return chunks

断点触发逻辑有两种情形:

  1. 语义断点:相邻单元相似度低于阈值(第 30 百分位),且当前块已达到最小字符数
  2. 长度断点:合并后超出最大字符数上限

关键超参解读:

参数 当前值 说明
min_chunk_chars 120 避免切得太碎,中文文档建议 100~180
max_chunk_chars 420 避免块太大语义混杂,中文文档建议 350~700
break_percentile 30.0 相邻相似度分布的第 30 百分位作为断点阈值

当前 120/420细粒度,是合理的起点。如何调整:

  • 命中但答案不完整 → 增大 max_chunk_chars 或降低 break_percentile
  • 命中很多但不准确 → 减小 max_chunk_chars 或增大 min_chunk_chars

注意:语义切块不做固定重叠(chunk_overlap 已移除),因为断点本身由语义决定,强制重叠反而引入噪声。


四、向量库:FAISS

4.1 构建索引

def create_faiss_index(chunk_records, embedder):
    import faiss
    texts = [item["text"] for item in chunk_records]
    vectors = embedder(texts).astype(np.float32)
    dimension = vectors.shape[1]
    index = faiss.IndexFlatIP(dimension)  # 内积相似度
    index.add(vectors)
    return index

使用 IndexFlatIP(Inner Product,内积)而非 L2 距离索引,配合 L2 归一化的 BGE 向量,等价于余弦相似度搜索。

4.2 智能加载策略

系统启动时会自动判断是否需要重建索引,避免重复切分:

┌─────────────────────────────┐
│       启动时检测             │
├─────────────────────────────┤
│ FAISS 文件不存在?           │──→ 重建
│ 检测到新的 .md 文件?        │──→ 重建
│ 以上均否?                   │──→ 直接加载本地 FAISS
└─────────────────────────────┘

判断"新文件"的逻辑是比对当前文档路径集合与上次构建时保存的 doc_paths 元数据,仅新增文件触发重建,已有文件变更内容不会触发(如需更严格,可引入 md5/mtime 比对)。

4.3 向量检索

def search_with_faiss(query, index, chunk_records, embedder, top_k=3):
    query_vector = embedder([query]).astype(np.float32)
    scores, ids = index.search(query_vector, min(top_k, len(chunk_records)))
    return [
        {"id": int(idx), "source": item["source"],
         "chunk_no": item["chunk_no"], "score": float(score),
         "text": item["text"]}
        for score, idx in zip(scores[0], ids[0]) if idx >= 0
    ]

FAISS 返回的 ids 对应 chunk_records 的下标索引,scores 为内积相似度分数(归一化后范围在 0~1 之间)。


五、两阶段检索:粗召回 + Reranker 重排

向量召回速度快但精度有限,Reranker 精度高但计算开销大。两者结合是工程实践中的标准范式。

5.1 为什么需要 Reranker?

Embedding 模型将 query 和 chunk 分别独立编码,两者的交互信息有限,容易出现"向量相似但语义不够精准"的问题。Cross-Encoder(Reranker)将 (query, chunk) 拼接输入,能捕捉细粒度的语义交互,相关性判断更准确。

5.2 两阶段流程

第一阶段:粗召回(快)
  FAISS + BGE Embedding
  → 取 recall_k = 20 个候选

第二阶段:精排(准)
  BGE Reranker (Cross-Encoder)
  → 对 20 个 (query, chunk) 对逐一打分
  → 按重排分数降序,取 final_k = 3 返回

5.3 Reranker 实现

def rerank_with_cross_encoder(query, candidates, reranker, final_top_k):
    pairs = [[query, item["text"]] for item in candidates]
    rerank_scores = reranker.predict(pairs)      # 批量打分

    enriched = []
    for rank, (item, score) in enumerate(zip(candidates, rerank_scores), start=1):
        new_item = dict(item)
        new_item["faiss_rank"] = rank            # 原始 FAISS 排名
        new_item["rerank_score"] = float(score)  # Reranker 打分
        enriched.append(new_item)

    enriched.sort(key=lambda x: x["rerank_score"], reverse=True)

    for idx, item in enumerate(enriched, start=1):
        item["rerank_rank"] = idx               # 重排后排名

    return enriched[:final_top_k]

重排后,final_k 条结果连同 query 一起送入 LLM 生成最终答案。


六、离线评测体系

评测系统独立于主检索流程,通过人工标注的 JSONL 文件进行量化对比。

6.1 评测集格式

每行是一条样本,包含查询和对应的正确 chunk(gold):

{"query": "个金客户经理的基本职责包括什么?", "gold": [{"source": "simple.md", "chunk_no": 2}, {"source": "simple.md", "chunk_no": 3}]}
{"query": "工作质量考核最多扣多少分?", "gold": [{"source": "simple.md", "chunk_no": 7}]}

gold 支持多个正确 chunk(多跳问题),评测指标会综合计算覆盖率。

6.2 四大核心指标

这四个指标本质上回答同一个问题的四个维度:检索结果有没有找对、找全、排得靠前?

Hit@K(命中率)

Top-K 内是否至少出现 1 个 gold chunk,是最粗粒度的成功率:

$$\text{Hit@K} = \begin{cases} 1 & \text{若 Top-K 内有任一 gold} \ 0 & \text{否则} \end{cases}$$

对所有 query 取平均,衡量"系统能不能把对的内容找出来"。

Recall@K(召回率)

Top-K 内命中的 gold 数量除以该 query 的 gold 总数:

$$\text{Recall@K} = \frac{|\text{命中的 gold}|}{|\text{gold 总数}|}$$

例如 gold 有 3 条,Top-K 命中 2 条,则 Recall@K = 0.667。衡量证据是否找全,对多跳问题尤为重要。

MRR@K(平均倒数排名)

第一个 gold chunk 出现在第几位,取其排名的倒数:

$$\text{MRR@K} = \frac{1}{\text{rank}_{\text{first gold}}}$$

首个 gold 位置 MRR
第 1 位 1.000
第 2 位 0.500
第 3 位 0.333
Top-K 内未命中 0.000

衡量首条正确结果是否靠前,对"用户只看前几条"的使用场景最关键。

nDCG@K(归一化折损累计增益)

考虑全部命中位置的排序质量:位置越靠后贡献越小(对数折损),并用理想排序归一化到 [0, 1]:

$$\text{DCG@K} = \sum_{i=1}^{K} \frac{\text{rel}_i}{\log_2(i+1)}, \quad \text{nDCG@K} = \frac{\text{DCG@K}}{\text{IDCG@K}}$$

其中 rel_i = 1 表示第 i 位命中 gold,IDCG 为理想最优排序的 DCG。nDCG 综合衡量整体排序质量,比 Hit/Recall 更敏感。

四指标分工速查:

指标 核心问题 关注场景
Hit@K 有没有找到 兜底成功率
Recall@K 找了多少 多 gold 覆盖
MRR@K 第一个来得快不快 用户只看前几条
nDCG@K 整体排得好不好 综合排序质量

实践中四个指标一起看,避免单一指标误判。

6.3 综合评分与等级

系统将四个指标等权加权,折算为百分制分数:

SCORE_WEIGHTS = {"hit": 0.25, "recall": 0.25, "mrr": 0.25, "ndcg": 0.25}

Score = 100 × (0.25×Hit@K + 0.25×Recall@K + 0.25×MRR@K + 0.25×nDCG@K)
分数 等级
≥ 90 A
≥ 80 B
≥ 70 C
≥ 60 D
< 60 E

6.4 评测输出示例

========== 离线检索评测 ==========
评测文件: eval_data.jsonl
评测样本总数: 5 / 有效样本数: 5 / 跳过样本数: 0
评测 K: 3  |  召回池 recall_k: 20
Top1 变化率(重排后Top1与召回Top1不同): 0.4000

指标对比表(Faiss@3 vs Reranker@3)
| Metric   | Faiss  | Reranker | Delta   |
|----------|--------|----------|---------|
| Hit@K    | 0.8000 | 0.8000   | +0.0000 |
| Recall@K | 0.7333 | 0.7333   | +0.0000 |
| MRR@K    | 0.7333 | 0.8167   | +0.0833 |
| nDCG@K   | 0.7556 | 0.8222   | +0.0667 |

综合评分(百分制)
| System    | Score/100 | Grade |
|-----------|-----------|-------|
| Faiss     | 94.57     | A     |
| Reranker  | 95.56     | A     |
| Delta(R-F)| +0.99     | -     |

Top1 变化率是一个辅助指标:重排后 Top-1 与 FAISS Top-1 不同的比例。变化率高,说明 Reranker 对排序影响显著;结合 MRR/nDCG 提升,可以判断这种影响是正向的。

6.5 时延统计

评测同时记录 FAISS 阶段和 Reranker 阶段的耗时均值与 P95,用于衡量上线后的延迟成本:

时延统计(单位: ms)
| Stage      | Mean   | P95    |
|------------|--------|--------|
| Faiss(ms)  | 12.34  | 18.50  |
| Rerank(ms) | 85.67  | 102.10 |
| Total(ms)  | 98.01  | 120.60 |

Reranker 的主要开销在于 Cross-Encoder 对 recall_k 个候选逐一前向推理,recall_k 越大时延越高,需要在召回质量和响应速度之间权衡。


七、top_k 参数体系

系统中有两个 top_k 参数,含义不同,容易混淆:

参数 变量名 默认值 作用
FAISS 召回数 RECALL_TOP_K 20 粗召回候选池大小,越大召回率越高,Reranker 开销也越大
最终返回数 FINAL_TOP_K 3 Reranker 精排后返回给 LLM 的 chunk 数

top_k 影响召回效果,但不等于召回效果本身:k 增大,召回率提升,但噪声也增多,LLM 上下文质量可能下降。


八、参数调优参考

现象 建议动作
Hit@K 低,说明根本没找到 检查 Embedding 模型是否与文档语言匹配;增大 RECALL_TOP_K
Recall@K 低,找到但不全 增大 FINAL_TOP_KRECALL_TOP_K
MRR@K 低,正确结果排名靠后 调整 Reranker 或检查 query-chunk 语义匹配质量
nDCG@K 与 MRR@K 差距大 说明首条准但后续排序乱,Reranker 重排对整体有提升空间
命中但答案不完整 增大 max_chunk_chars 或调小 break_percentile
命中很多但答案不准确 减小 max_chunk_chars 或增大 min_chunk_chars

九、小结

本文从源码出发,系统梳理了一套完整的中文 RAG 检索工程实现:

  1. Markdown 语义切块:以 BGE Embedding 计算相邻段落相似度,用百分位阈值动态确定语义断点,替代传统固定滑窗方案
  2. FAISS 向量库:归一化向量 + 内积索引(等价余弦相似度),支持智能增量加载
  3. 两阶段检索:FAISS 粗召回(speed)+ BGE Reranker 精排(accuracy),各司其职
  4. 四指标评测体系:Hit / Recall / MRR / nDCG 全面衡量"找到、找全、找快、排好"
  5. 百分制评分:量化对比 FAISS 与 Reranker 的整体提升,辅以时延统计支持上线决策

整套系统的核心设计哲学是:评测驱动优化。先搭好离线评测,再通过指标反馈调整切块参数、召回规模和重排策略,避免凭感觉调参。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐