检索增强生成与向量检索实战:Embedding、HNSW 与重排

从零搭建一条可用的 RAG 链路:embedding 模型选型与中文适配、向量索引原理(HNSW 与 IVF-PQ 的取舍)、文本分块与混合召回、交叉编码器重排、端到端评测指标(命中率与忠实度),以及生产环境的常见坑与优化清单。

引言

大模型的幻觉与知识时效性问题,靠微调很难根治:知识一变就得重训,成本高且容易遗忘。**检索增强生成(RAG)**换了个思路——把外部知识库当作「外挂记忆」,每次回答前先检索出相关片段,拼进上下文让模型基于证据作答。

RAG 的成败几乎全在检索质量上:检索不到,生成再强也是编。本文从 embedding 选型讲到向量索引原理,再讲到混合召回与重排,最后给出完整的链路搭建代码与评测方法。

前置:文本向量化基础见 https://plumephp.com/ml-nlp-basics/;召回排序的分层思想与 https://plumephp.com/ml-recommender-systems/ 高度相通;文本特征的清洗与构造见 https://plumephp.com/ml-feature-engineering/。


目录


1. RAG 要解决什么问题

1.1 三种知识注入方式对比

方式知识更新可溯源成本适用
提示里塞每次手改差低极少量固定知识
微调需重训差高风格、格式、术语
RAG改库即可好(带引用)中事实性知识、频繁更新

1.2 RAG 的两段式结构

离线索引:文档 → 分块 → embedding → 向量库
在线查询:问题 → embedding → 检索 Top-K → 重排 → 拼上下文 → LLM 生成

几乎所有 RAG 问题都能定位到这两段中的某一步:索引阶段是数据工程,查询阶段是检索工程。


2. Embedding 模型选型

2.1 好的 embedding 要看什么

  • 维度:384 / 768 / 1024 / 1536。维度越高表达能力越强,但存储与检索成本线性上升。
  • 最大长度:常见 512 token,长文模型可到 8192。超长会截断。
  • 语言覆盖:中文场景必须选中文或多语模型,纯英文模型在中文上会明显退化。
  • 对称性:有些模型要求查询和文档用不同前缀(如 query: 与 passage:),用错会掉点。

2.2 常用模型速查

模型维度特点
BGE-M31024多语、支持稠密+稀疏+多向量
BGE-large-zh1024中文检索强,社区常用
text-embedding-3-small1536通用、便宜
m3e-base768中文轻量,本地部署友好

2.3 归一化与相似度

绝大多数模型在归一化后用余弦相似度,等价于内积。检索前务必确认:

import numpy as np
def normalize(x):
    return x / np.linalg.norm(x, axis=-1, keepdims=True)

# 归一化后,内积 == 余弦相似度,可以用更快的点积索引
emb = normalize(emb)

2.4 一段最简向量化

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
docs = ["退换货政策:签收后 7 天内可无理由退货。",
        "发票申请:订单完成后可在个人中心申请电子发票。"]
doc_vecs = model.encode(docs, normalize_embeddings=True)
q_vec = model.encode(["怎么开发票"], normalize_embeddings=True)
print(q_vec @ doc_vecs.T)     # 哪个文档更相关,一眼可见

3. 向量索引:HNSW 与 IVF

3.1 为什么需要索引

10 万条 1024 维向量做暴力检索(Flat)每次要算 1 亿次乘法。数据到百万级就撑不住了。索引的本质是用一点召回损失换取数量级的加速。

3.2 三类索引对比

索引原理召回速度内存适用
Flat暴力精确100%慢高< 10 万条
IVF聚类分桶,只搜近桶高(可调)快中百万级
HNSW多层图近邻游走很高很快高千万级、低延迟

3.3 HNSW 的直觉

HNSW 建了一张分层图:上层稀疏、跨度大,下层稠密、精度高。检索从最上层入口点出发,贪心地朝目标靠近,逐层下降,最后在底层做精细搜索。

两个关键参数:

M              每个节点的邻居数,越大越准也越占内存(常用 16~64)
ef_construction 建图时的候选池大小,越大图质量越好(常用 100~500)
ef_search      查询时的候选池大小,越大召回越高、延迟越大(常用 64~256)

ef_search 是运行时参数,可以按业务动态调整:低延迟场景调小,高精度场景调大。

3.4 IVF 的直觉

IVF 先用 K-Means 把向量聚成 nlist 个桶,查询时只访问最近的 nprobe 个桶:

import faiss
d, nlist = 1024, 256
quantizer = faiss.IndexFlatIP(d)
index = faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_INNER_PRODUCT)
index.train(doc_vecs)          # 必须先训练聚类中心
index.add(doc_vecs)
index.nprobe = 16              # 只搜最近的 16 个桶
D, I = index.search(q_vec, k=5)

nprobe 越大召回越高。IVF-PQ 再加乘积量化压缩向量,内存可降 10 倍以上,代价是精度损失。

3.5 HNSW 实战

index = faiss.IndexHNSWFlat(d, 32)          # M=32
index.hnsw.efConstruction = 200
index.add(doc_vecs)
index.hnsw.efSearch = 128                   # 查询时调
D, I = index.search(q_vec, k=5)

3.6 选型决策

数据 < 10 万且要精确     → Flat
内存充足、要低延迟       → HNSW
数据上亿、内存吃紧       → IVF-PQ
需要按条件过滤           → 带 filter 的向量库(Milvus/Qdrant 等)

4. 分块与召回策略

4.1 分块是 RAG 的第一生产力

块太大,噪声多、稀释相关性;块太小,语义不完整。常用策略:

策略说明适用
固定长度按 token 数切,带重叠通用基线
按结构切按标题/段落/表格边界文档类语料
递归切分先大后小,逐级尝试分隔符混合结构
语义切分按相邻句向量相似度断层高质量要求
def chunk_by_chars(text, size=500, overlap=80):
    chunks, i = [], 0
    while i < len(text):
        chunks.append(text[i:i + size])
        i += size - overlap          # 重叠避免语义被切断
    return chunks

4.2 混合检索:稠密 + 稀疏

纯向量检索对专有名词、编号、代码标识符不敏感——这些恰恰是 BM25 的强项。工业界主流是两者并行再融合:

def rrf_fuse(dense_ids, sparse_ids, k=60):
    """Reciprocal Rank Fusion:按排名倒数加权融合"""
    score = {}
    for ids in (dense_ids, sparse_ids):
        for rank, doc_id in enumerate(ids):
            score[doc_id] = score.get(doc_id, 0) + 1 / (k + rank + 1)
    return [d for d, _ in sorted(score.items(), key=lambda x: -x[1])]

RRF 的好处是不需要归一化两路分数,只看排名,鲁棒且零调参。

4.3 查询改写

用户问题往往太短。常见增强:

  • HyDE:先让 LLM 编一段假想答案,用它去检索;
  • 多查询:让 LLM 生成 3~5 个改写版本,各检索一次后合并;
  • 子问题分解:复杂问题拆成多个子问题分别检索。

5. 重排:从召回走向精排

5.1 为什么需要重排

双编码器(bi-encoder)为了能预先建索引,查询与文档分别编码,无法建模细粒度交互。交叉编码器(cross-encoder)把「查询+文档」拼在一起送进模型,精度高得多,但没法预建索引,只能对少量候选做。

5.2 两阶段流水线

向量检索 Top-100  →  Reranker 精排  →  取 Top-5 送 LLM
   (快、粗)            (慢、准)
from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-large")
pairs = [(q, docs[i]) for i in candidate_ids]
scores = reranker.predict(pairs)
top5 = [candidate_ids[i] for i in np.argsort(-scores)[:5]]

5.3 收益与代价

指标只召回加重排
精度基线明显提升
延迟几十 ms增加 100~500 ms
成本低需部署重排模型

经验值:召回 100 → 重排取 5,端到端命中率通常能提升 10~25 个百分点,是性价比最高的一步优化。


6. 搭建一条完整 RAG 链路

6.1 索引阶段

import faiss, numpy as np
from sentence_transformers import SentenceTransformer
embedder = SentenceTransformer("BAAI/bge-large-zh-v1.5")

def build_index(texts):
    chunks = [c for t in texts for c in chunk_by_chars(t)]
    vecs = embedder.encode(chunks, normalize_embeddings=True,
                           batch_size=64, show_progress_bar=True)
    vecs = np.asarray(vecs, dtype="float32")
    index = faiss.IndexHNSWFlat(vecs.shape[1], 32)
    index.hnsw.efConstruction = 200
    index.add(vecs)
    return index, chunks

6.2 查询阶段

def retrieve(index, chunks, query, top_k=100, final_k=5, reranker=None):
    qv = np.asarray(embedder.encode([query], normalize_embeddings=True),
                    dtype="float32")
    _, ids = index.search(qv, top_k)
    cands = [chunks[i] for i in ids[0] if i != -1]
    if reranker is None:
        return cands[:final_k]
    scores = reranker.predict([(query, c) for c in cands])
    order = np.argsort(-scores)[:final_k]
    return [cands[i] for i in order]

6.3 拼装提示

PROMPT = """请仅依据以下资料回答问题;资料中没有的信息,请回答「资料未提及」。
资料:
{context}
问题:{question}
"""

def answer(llm, question, contexts):
    ctx = "\n\n".join(f"[{i+1}] {c}" for i, c in enumerate(contexts))
    return llm(PROMPT.format(context=ctx, question=question))

编号 [1] [2] 是为了让模型能在回答里带引用,方便溯源与人工核验。


7. RAG 评测:命中率、忠实度与端到端

7.1 三个层次的指标

层次指标含义
检索Recall@K / Hit Rate正确文档是否进了 Top-K
检索MRR / NDCG正确文档排得够不够前
生成忠实度回答是否全部有资料支撑
生成答案相关性是否切题
端到端正确率 / 人工胜率最终用户是否满意

7.2 检索指标实现

def recall_at_k(retrieved_ids, gold_ids, k):
    hit = set(retrieved_ids[:k]) & set(gold_ids)
    return len(hit) / len(gold_ids)

def mrr(retrieved_ids, gold_ids):
    for rank, doc_id in enumerate(retrieved_ids, 1):
        if doc_id in gold_ids:
            return 1.0 / rank
    return 0.0

7.3 忠实度:用 LLM 当裁判

JUDGE = """判断回答是否完全由资料支撑,只输出 支持 或 不支持。
资料:{context}
回答:{answer}
"""

def faithfulness(llm, answer, contexts):
    verdict = llm(JUDGE.format(context="\n".join(contexts), answer=answer))
    return 1.0 if "支持" in verdict and "不支持" not in verdict else 0.0

7.4 建评测集的正确姿势

准备 50~200 条「问题 → 标准答案 → 应命中文档 ID」三元组,覆盖简单/多跳/无答案三类。必须包含无答案样本——否则模型学不会说「资料未提及」,会强行编造。


8. 常见坑与优化清单

现象根因处理
检索总是不相关分块过大或 embedding 不匹配语言缩小块、换中文模型、加查询改写
专有名词查不到纯稠密检索的短板上混合检索(BM25 + 向量)
答案编造提示未约束、无答案样本缺失加「仅依据资料」约束与拒答样本
延迟高重排候选太多召回降到 50,重排模型换小号
更新后仍答旧内容索引未增量更新建立文档版本与重建流水线
相似问题答案矛盾块重叠导致重复片段检索后做去重与多样性(MMR)

MMR 去重能显著缓解「Top-5 全是同一段话」的尴尬:

def mmr(query_vec, doc_vecs, doc_ids, lam=0.7, k=5):
    selected, cands = [], list(range(len(doc_ids)))
    while cands and len(selected) < k:
        def score(i):
            rel = float(query_vec @ doc_vecs[i])
            div = max([float(doc_vecs[i] @ doc_vecs[j]) for j in selected],
                      default=0.0)
            return lam * rel - (1 - lam) * div
        best = max(cands, key=score)
        selected.append(best)
        cands.remove(best)
    return [doc_ids[i] for i in selected]

9. 总结

9.1 优化优先级

1. 分块策略      ← 收益最大、成本最低
2. 混合召回      ← 解决专有名词与编号
3. 加重排        ← 精度提升 10~25 个点
4. 查询改写      ← 解决短问题与多跳
5. 换更大模型    ← 最后再考虑

9.2 关键决策点

问题选择
数据量 < 10 万Flat 索引,先跑通再优化
要低延迟HNSW + 小重排模型
内存吃紧IVF-PQ 压缩
中文语料BGE 系列 + 混合检索
上线前必须做建带无答案样本的评测集

9.3 一句话心法

RAG 的天花板由检索决定,检索的天花板由分块与数据质量决定——先把「能不能检索到」做扎实,再谈生成端的花活。


延伸阅读

  • https://plumephp.com/ml-nlp-basics/ — 文本向量化、TF-IDF 与词嵌入基础
  • https://plumephp.com/ml-recommender-systems/ — 召回-精排-重排的分层架构
  • https://plumephp.com/ml-feature-engineering/ — 文本特征构造与清洗
  • https://plumephp.com/ml-model-evaluation/ — 评估方法论与指标选择
  • Faiss 官方 Wiki

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「ml」更多文章

  1. 特征平台与训练服务一致性:时间点正确性与特征回填
  2. 实验管理与可复现:MLflow、版本化与模型注册表
  3. 大模型微调实战:LoRA、QLoRA 与指令数据构造全流程