RAG 检索增强实战:切块、混合检索与重排序,把“答非所问“治住

本文聚焦RAG系统中导致'答非所问'的核心工程问题,系统阐述切块(结构感知分块、父子块策略)、混合检索(向量+BM25+RRF融合)、重排序(bi-encoder召回与cross-encoder精排)、prompt引用约束及离线评估方法。强调检索质量决定RAG上限,提供可落地的代码级优化链路与成本控制实践。

很多团队做 RAG(检索增强生成)的第一版都长一个样:把文档灌进向量库,用户提问就 top-3 检索,拼进 prompt 让模型回答。上线第一天效果惊艳,第二周开始有人反馈"问东答西""明明文档里有却说不知道"。这时候往往不是模型不行,而是检索环节的工程细节没打磨。本文按一条真实的优化链路,把切块、混合检索、重排序、引用约束和离线评估逐段拆开讲,代码可直接改。

一、切块:别用固定字符数硬切

多数人第一版是 text[i:i+500] 这种固定窗口。它的问题很直接:一段"步骤 3"的说明被切到两块里,两块单独看都不完整,检索命中哪一块都可能答错。

基线做法是固定窗口加重叠(overlap),减少跨块语义断裂;更稳妥的是结构感知切分——Markdown 按标题层级切,PDF 按段落和页码切,代码文档按函数/类切。切块粒度建议按内容类型调整:FAQ 类 200–300 字一块,技术文档 500–800 字一块,合同这类依赖前后文连贯的,单块可以放到 1000 字以上。

下面的切块器先按二、三级标题切,超长块再按句子切并保留重叠窗口:

import re

def split_markdown(text, max_chars=800, overlap=120):
    """结构感知切块:先按 Markdown 标题切,超长块按句切并保留上下文重叠。"""
    blocks = re.split(r"\n(?=#{2,3} )", text)   # 以二/三级标题为界
    chunks = []

    for block in blocks:
        if len(block) <= max_chars:
            chunks.append(block.strip())
            continue

        sentences = re.split(r"(?<=[。!?;])", block)
        buf = ""
        for sent in sentences:
            if len(buf) + len(sent) > max_chars and buf:
                chunks.append(buf.strip())
                buf = buf[-overlap:] + sent      # 用尾部 overlap 字做衔接
            else:
                buf += sent
        if buf.strip():
            chunks.append(buf.strip())

    # 丢掉过短碎片(多为目录、页眉、孤立标题)
    return [c for c in chunks if len(c) >= 30]

常见切块策略的取舍:

切块策略

适用场景

主要风险

固定窗口 + 重叠

无结构纯文本、快速验证

语义被截断,重叠比例难调

结构感知(标题/段落)

技术文档、Markdown 知识库

依赖原始排版质量

语义切分(相似度断点)

长篇散文、会议记录

需额外算力,偶有过度切碎

父子块(小块召回、大块喂模型)

长文档问答、精度要求高

存储与组装逻辑更复杂

其中"父子块"值得单独提一句:用小块做召回,命中后把所属的大块(或整节)交给模型。这样召回更准(小块向量更聚焦),回答时上下文又更完整,是目前长文档问答里性价比很高的方案。

二、检索:纯向量会漏关键词,加一路 BM25 再融合

向量检索擅长语义近似,但对精确字符串很钝。用户问"428XL 报 E1042 怎么处理",向量可能召回一堆讲"设备报错处理流程"的段落,却没命中写着"E1042"的那一节。反过来,BM25 这类关键词检索对专有名词、型号、错误码、函数名非常敏感,却完全不懂同义改写。

所以别二选一。两路各召回一批,再用 RRF(Reciprocal Rank Fusion)融合。RRF 的好处是不依赖两路分数的量纲,省掉归一化,公式也简单——只用到排名:

def rrf_fuse(rankings, k=60):
    """RRF 融合多路召回结果。rankings 为多个有序的 id 列表。"""
    scores = {}
    for ranking in rankings:
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda x: -x[1])

# 向量召回 top30 + BM25 top30 → 融合后取前 30 条交给重排
dense_ids = [d["id"] for d in vector_search(query, top_k=30)]
sparse_ids = [d["id"] for d in bm25_search(query, top_k=30)]
fused_ids = [doc_id for doc_id, _ in rrf_fuse([dense_ids, sparse_ids])][:30]

k 取 60 是常见默认值,它决定"高排名"能带来多大优势:k 越小,头部名次的权重越突出。如果你的向量路明显比关键词路可靠,调小 k 会更激进地偏向前者。

三、重排序:召回 30 条,精排留 4 条

向量检索用的是双塔模型(bi-encoder),query 和文档各自编码后算相似度,快但精度有上限——两个向量在编码阶段从没"见过"对方。重排序用交叉编码器(cross-encoder),把 query 和候选文档拼在一起送进模型,能捕捉细粒度的词语对应关系,精度明显更高,代价是慢,所以只适合在小候选集上做。

def rerank(query, candidates, reranker, top_k=4, min_score=0.3):
    """candidates: [{'id': ..., 'text': ...}];reranker.predict 返回相关性分数。"""
    pairs = [(query, c["text"]) for c in candidates]
    scores = reranker.predict(pairs)
    ranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
    # 分数过低的干脆不喂给模型,宁缺毋滥
    return [c for c, s in ranked[:top_k] if s >= min_score]

经验值:混合检索召回 30–50 条,重排后留 3–6 条。留多了反而稀释模型注意力,还白抬 token 成本。min_score 别拍脑袋定,在离线评估集上扫一遍再选。

四、把"依据"和"不知道"写进 prompt

检索再准,prompt 不约束也白搭。三个约束最关键:只依据资料回答、必须标引用编号、没依据就直说。

PROMPT = """你是技术文档助手,只能依据下面的资料回答问题。

规则:
1. 每处结论后用 [编号] 标注来源,如 [2]。
2. 资料中没有的信息,回答"资料中未提及",禁止自行推测或补充。
3. 若多段资料互相冲突,指出冲突并分别标注来源。

资料:
{context}

问题:{question}
"""

context = "\n\n".join(
    f"[{i}] {c['text']}" for i, c in enumerate(reranked, start=1)
)

另外建议把"引用编号 → 原文链接/片段"一并返回给前端,让用户能点开核对。这既是体验问题,也是排查幻觉最快的路径:用户投诉答错时,先看命中的是哪几块,再决定改检索还是改 prompt。

五、离线评估:先凑 30 条黄金问答集

没有评估集,所有优化都只是"感觉变好了"。最低成本的做法:从真实用户问题里挑 30 条,人工标好"应该命中哪几个块",然后分层看指标。

  • 检索阶段:recall@k(正确块是否进了 top-k)、MRR(正确块排在第几位);
  • 生成阶段:答案是否与资料一致、是否出现无来源的断言。

分层的好处是能定位问题归属。如果 recall@5 只有 0.6,那是检索的问题,反复改 prompt 没用;如果 recall@5 有 0.95 而答案仍然错,才轮到 prompt 和模型背锅。

六、几个省成本的工程取舍

  • embedding 结果按"文档 hash + 切块参数"做缓存,切块参数没变就不重算;
  • 增量更新只重算变更文件,别每次全量重建索引;
  • 高频问题加一层语义缓存,命中后直接返回,整条检索链路都可以跳过;
  • 重排序模型按需选型,候选集小、延迟敏感的用轻量 cross-encoder 就够;
  • 把每次请求的召回 / 重排 id 列表落日志,线上出问题能回放复现。

RAG 的效果上限由检索决定,模型只负责把已有资料讲清楚。与其反复换更大的模型,不如先把切块、混合检索、重排序这三步做扎实——通常这三步做完,用户的"答非所问"就能少掉一大半。

深度学习工具包Deprecation notice.-----This toolbox is outdated and no longer maintained.There are much better tools available for deep learning than this toolbox, e.g. [Theano](http://deeplearning.net/software/theano/), [torch](http://torch.ch/) or [tensorflow](http://www.tensorflow.org/)I would suggest you use one of the tools mentioned above rather than use this toolbox.Best, Rasmus.DeepLearnToolbox================A Matlab toolbox for Deep Learning.Deep Learning is a new subfield of machine learning that focuses on learning deep hierarchical models of data.It is inspired by the human brain's apparent deep (layered, hierarchical) architecture.A good overview of the theory of Deep Learning theory is[Learning Deep Architectures for AI](http://www.iro.umontreal.ca/~bengioy/papers/ftml_book.pdf)For a more informal introduction, see the following videos by Geoffrey Hinton and Andrew Ng.* [The Next Generation of Neural Networks](http://www.youtube.com/watch?v=AyzOUbkUf3M) (Hinton, 2007)* [Recent Developments in Deep Learning](http://www.youtube.com/watch?v=VdIURAu1-aU) (Hinton, 2010)* [Unsupervised Feature Learning and Deep Learning](http://www.youtube.com/watch?v=ZmNOAtZIgIk) (Ng, 2011)If you use this toolbox in your research please cite [Prediction as a candidate for learning deep hierarchical models of data](http://www2.imm.dtu.dk/pubdb/views/publication_details.php?id=6284)```@MASTERSTHESIS\{IMM2012-06284, author = "R. B. Palm", title = "Prediction as a candidate for learning deep hierarchical models of data", year = "2012",}```Contact: rasmusbergpalm at gmail dot comDirectories included in the toolbox-----------------------------------`NN/` - A library for Feedforward Backpropagation Neural Networks`CNN/` - A library for Convolutional Neural Networks`DBN/` - A library for Deep Belief Networks`SAE/` - A library for Stacked Auto-Encoders`CAE/` - A library for Convolutional Auto-Encoders`util/` - Utility functions used by the libraries`data/` - Data used by the examples`tests/` - unit tests to verify toolbox is workingFor references on each library check REFS.mdSetup-----1. Download.2. addpath(genpath('DeepLearnToolbox'));Example: Deep Belief Network---------------------```matlabfunction test_example_DBNload mnist_uint8;train_x = double(train_x) / 255;test_x = double(test_x) / 255;train_y = double(train_y);test_y = double(test_y);%% ex1 train a 100 hidden unit RBM and visualize its weightsrand('state',0)dbn.sizes = [100];opts.numepochs = 1;opts.batchsize = 100;opts.momentum = 0;opts.alpha = 1;dbn = dbnsetup(dbn, train_x, opts);dbn = dbntrain(dbn, train_x, opts);figure; visualize(dbn.rbm{1}.W'); % Visualize the RBM weights%% ex2 train a 100-100 hidden unit DBN and use its weights to initialize a NNrand('state',0)%train dbndbn.sizes = [100 100];opts.numepochs = 1;opts.batchsize = 100;opts.momentum = 0;opts.alpha = 1;dbn = dbnsetup(dbn, train_x, opts);dbn = dbntrain(dbn, train_x, opts);%unfold dbn to nnnn = dbnunfoldtonn(dbn, 10);nn.activation_function = 'sigm';%train nnopts.numepochs = 1;opts.batchsize = 100;nn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.10, 'Too big error');```Example: Stacked Auto-Encoders---------------------```matlabfunction test_example_SAEload mnist_uint8;train_x = double(train_x)/255;test_x = double(test_x)/255;train_y = double(train_y);test_y = double(test_y);%% ex1 train a 100 hidden unit SDAE and use it to initialize a FFNN% Setup and train a stacked denoising autoencoder (SDAE)rand('state',0)sae = saesetup([784 100]);sae.ae{1}.activation_function = 'sigm';sae.ae{1}.learningRate = 1;sae.ae{1}.inputZeroMaskedFraction = 0.5;opts.numepochs = 1;opts.batchsize = 100;sae = saetrain(sae, train_x, opts);visualize(sae.ae{1}.W{1}(:,2:end)')% Use the SDAE to initialize a FFNNnn = nnsetup([784 100 10]);nn.activation_function = 'sigm';nn.learningRate = 1;nn.W{1} = sae.ae{1}.W{1};% Train the FFNNopts.numepochs = 1;opts.batchsize = 100;nn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.16, 'Too big error');```Example: Convolutional Neural Nets---------------------```matlabfunction test_example_CNNload mnist_uint8;train_x = double(reshape(train_x',28,28,60000))/255;test_x = double(reshape(test_x',28,28,10000))/255;train_y = double(train_y');test_y = double(test_y');%% ex1 Train a 6c-2s-12c-2s Convolutional neural network %will run 1 epoch in about 200 second and get around 11% error. %With 100 epochs you'll get around 1.2% errorrand('state',0)cnn.layers = { struct('type', 'i') %input layer struct('type', 'c', 'outputmaps', 6, 'kernelsize', 5) %convolution layer struct('type', 's', 'scale', 2) %sub sampling layer struct('type', 'c', 'outputmaps', 12, 'kernelsize', 5) %convolution layer struct('type', 's', 'scale', 2) %subsampling layer};cnn = cnnsetup(cnn, train_x, train_y);opts.alpha = 1;opts.batchsize = 50;opts.numepochs = 1;cnn = cnntrain(cnn, train_x, train_y, opts);[er, bad] = cnntest(cnn, test_x, test_y);%plot mean squared errorfigure; plot(cnn.rL);assert(er<0.12, 'Too big error');```Example: Neural Networks---------------------```matlabfunction test_example_NNload mnist_uint8;train_x = double(train_x) / 255;test_x = double(test_x) / 255;train_y = double(train_y);test_y = double(test_y);% normalize[train_x, mu, sigma] = zscore(train_x);test_x = normalize(test_x, mu, sigma);%% ex1 vanilla neural netrand('state',0)nn = nnsetup([784 100 10]);opts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samples[nn, L] = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.08, 'Too big error');%% ex2 neural net with L2 weight decayrand('state',0)nn = nnsetup([784 100 10]);nn.weightPenaltyL2 = 1e-4; % L2 weight decayopts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex3 neural net with dropoutrand('state',0)nn = nnsetup([784 100 10]);nn.dropoutFraction = 0.5; % Dropout fraction opts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex4 neural net with sigmoid activation functionrand('state',0)nn = nnsetup([784 100 10]);nn.activation_function = 'sigm'; % Sigmoid activation functionnn.learningRate = 1; % Sigm require a lower learning rateopts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex5 plotting functionalityrand('state',0)nn = nnsetup([784 20 10]);opts.numepochs = 5; % Number of full sweeps through datann.output = 'softmax'; % use softmax outputopts.batchsize = 1000; % Take a mean gradient step over this many samplesopts.plot = 1; % enable plottingnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex6 neural net with sigmoid activation and plotting of validation and training error% split training data into training and validation datavx = train_x(1:10000,:);tx = train_x(10001:end,:);vy = train_y(1:10000,:);ty = train_y(10001:end,:);rand('state',0)nn = nnsetup([784 20 10]); nn.output = 'softmax'; % use softmax outputopts.numepochs = 5; % Number of full sweeps through dataopts.batchsize = 1000; % Take a mean gradient step over this many samplesopts.plot = 1; % enable plottingnn = nntrain(nn, tx, ty, opts, vx, vy); % nntrain takes validation set as last two arguments (optionally)[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');```[![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/rasmusbergpalm/deeplearntoolbox/trend.png)](https://bitdeli.com/free "Bitdeli Badge")
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值