把 RAG 的基础知识串起来:从检索排序到训练与工程实践

整理学习笔记时,我发现,单独记住 BM25、RRF、Redis 或 Trainer 的定义并不难,真正容易混淆的是:它们分别解决哪个问题,又怎样放进同一条系统链路。本文把这些知识点放到一个教育知识库问答的例子中,补上计算过程、使用边界和工程细节。

本文中的系统流程是教学设计,数值算例用于解释算法,不代表线上业务效果。模型和数据库代码需要相应依赖与运行环境;训练示例不代表已经完成训练。

1. 先看一条完整的数据流

用户问:“课程开课三天后,还能退费吗?”

离线阶段,我们解析课程与政策文档,记录来源、版本与适用范围,清洗后分块,再生成文本索引和向量索引。在线阶段,系统先识别问题中的课程、开课时间等条件,检索候选片段,融合排序,必要时进一步重排,再把有出处的证据交给模型组织回答。

文档 → 解析与清洗 → 内容摘要与版本 → 分块 → 关键词/向量索引
问题 → 必要的澄清与改写 → 权限和适用范围过滤 → 多路召回
     → RRF 或加权融合 → 可选的模型重排 → 证据检查 → 回答与引用

关键词检索擅长保留“开课三天”“课程编号”等明确词项;语义检索有助于关联“退款”和“退费”这样的表达。二者都可能召回不适用的条款,因此课程版本、地区、生效时间和用户权限不能只交给相似度判断。

Hash 用于识别内容,PQ 用于压缩向量,Redis 用于缓存与临时状态,argparse 和 logging 用于让处理脚本可操作、可追踪。Trainer 与梯度裁剪则属于需要训练分类模型或微调模型时的另一条链路。

2. TF-IDF 与 BM25:先把一个小例子算对

TF 表示词在文档中出现的次数,IDF 表示它在整个语料中的区分能力。常见词能匹配很多文档,但不一定有助于区分哪篇更相关。

TF-IDF 不是唯一固定公式。这里明确采用原始词频,以及自然对数形式的 IDF(t) = ln(N / df(t)),并对查询词得分求和。实际工具还可能使用平滑、对数词频和向量归一化,不能把不同实现的分数混在一起比较。

沿用一个按空格分词的教学语料:

D1:我 喜欢 吃 苹果              长度 4
D2:我 今天 吃 了 一个 苹果       长度 6
D3:苹果 是 一种 水果 啊          长度 5
查询:吃 苹果

“吃”出现在两篇文档中,所以 IDF 为 ln(3/2) ≈ 0.405465;“苹果”出现在全部三篇中,所以 IDF 为 ln(3/3) = 0。按上述简化定义,D1 与 D2 都得到 0.405465,D3 得到 0。这个零分只说明当前公式把全语料共有词的区分贡献设为零,不代表 D3 没有任何语义相关性。

BM25 进一步引入词频饱和与文档长度归一化。本文采用以下常见写法:

IDF(t) = ln(1 + (N - df(t) + 0.5) / (df(t) + 0.5))
score(q,d) = Σ IDF(t) × f(t,d)(k1+1)
                         / [f(t,d) + k1(1-b+b×|d|/avgdl)]

取 k1=1.2、b=0.75,平均长度为 5。“吃”和“苹果”的 IDF 分别约为 0.470004 与 0.133531。代入后:

文档长度BM25 分数
D140.657315
D260.557890
D350.133531

这里 D1 高于 D2:查询词出现次数相同,D2 更长,长度归一化降低了它的得分。不能据此说“更长意味着上下文更多,所以 BM25 得分更高”。BM25 并不理解上下文语义,它处理的是词项统计。

b=0 时关闭这项长度归一化;提高 k1 会减缓词频收益的饱和。真实中文检索还受到分词、字段权重、停用词和同义词处理影响。TF-IDF 也可以做长度归一化,因此“TF-IDF 完全不考虑长度”不是普遍成立的说法。

公式中的 IDF 和参数含义可对照 Lucene BM25Similarity 文档。这里直接用词数计算教学分数,未模拟搜索引擎对长度等信息的内部编码。

3. BGE-M3:模型能力与封装返回值要分开

BGE-M3 中的 M3 指多语言、多功能和多粒度,不是“文本、图片、音频三种模态”。它是文本嵌入模型,支持稠密、稀疏和多向量检索方式,见 BGE-M3 论文。

BGEM3EmbeddingFunction 是 Milvus 生态中的模型封装。当前官方示例使用下面的导入路径,并展示 dense 与 sparse 两种返回结果。不能因为底层模型支持三种检索方式,就断言封装的每次调用都会返回三种表示。

pip install "pymilvus[model]"
from pymilvus.model.hybrid import BGEM3EmbeddingFunction

encoder = BGEM3EmbeddingFunction(
    model_name="BAAI/bge-m3",
    device="cpu",
    use_fp16=False,
)
documents = encoder.encode_documents([
    "课程开课后的退费规则,以适用协议及政策版本为准。",
    "课程资料可在学习平台下载。",
])
queries = encoder.encode_queries(["开课以后怎么申请退款?"])
print(documents.keys())
print(documents["dense"][0].shape)

第一次运行需要获取模型权重。查询和文档应使用兼容的编码配置;更换模型或影响向量空间的配置后,要评估并重建相关索引。稀疏输出是模型学到的词项权重,不等同于 BM25。上述接口与 CPU 精度设置来自 Milvus BGE-M3 文档。

4. RRF 与 WeightedRanker:融合的是排名还是分数

两路检索可能使用不同评分尺度。BM25 的 8.2 和向量相似度的 0.81,不能直接按大小比较。

RRF:只使用名次

排名从 1 开始时,公式为 RRF(d) = Σ 1 / (k + rank_i(d))。某一路没有召回该文档,该路贡献为零。如果程序使用从 0 开始的数组下标,分母才需要再加 1;这两种约定不要混用。

取 k=60:

文档路径一排名路径二排名总分
D1140.032018
D2210.032522
D3360.031025
D4未召回20.016129
D54未召回0.015625

排序为 D2、D1、D3、D4、D5。下面的独立示例可复算:

from collections import defaultdict

def rrf(rankings, k=60):
    if k < 0:
        raise ValueError("k 必须非负")
    scores = defaultdict(float)
    for ranking in rankings:
        unique = dict.fromkeys(ranking)
        for rank, document_id in enumerate(unique, start=1):
            scores[document_id] += 1 / (k + rank)
    return sorted(scores.items(), key=lambda item: (-item[1], item[0]))

print(rrf([
    ["D1", "D2", "D3", "D5"],
    ["D2", "D4", "D6", "D1", "D7", "D3"],
]))

完整列表还包含 D6、D7,表中只展示原先关心的五篇文档。融合前必须统一文档或分块 ID,否则同一段证据可能被当作不同对象。

WeightedRanker:使用归一化后的分数

概念公式为 final(d) = Σ w_i × normalized_score_i(d)。假设以下已经是归一化后的分数,稠密路权重 0.6,稀疏路权重 0.4:

文档稠密分数稀疏分数融合分数
A0.920.870.900
B0.830.910.862
C0.88未召回0.528

这是解释加权求和的算例,不是把原始 IP 或 L2 数值直接输入所得的 Milvus 结果。Milvus 会按度量类型进行归一化,权重顺序须对应搜索请求顺序;每个权重在 0 到 1 之间,权重总和并不要求必须等于 1。见 Milvus 排名融合文档。

缺少调参证据时,可以先建立 RRF 基线;有稳定验证集后再比较加权方案。两者都无法找回从所有候选列表中遗漏的证据,也不等于让交叉编码器重新阅读“问题—片段”并评分。

5. PQ:用码本换内存,同时接受近似误差

假设原始向量有 128 维,每维 float32,占 512 字节。把它分成 8 段,每段 16 维,分别训练 256 个聚类中心。一个子向量用最近中心的编号代替,编号只需 8 位;整条向量的编码因此为 8 字节。

512 / 8 = 64 是原始向量数据与 PQ 编码的大小比,不是整个数据库必然缩小 64 倍。数据库还包含共享码本、文档 ID、元数据及其他索引开销。Faiss 索引说明区分了不同索引的编码与存储成本。

查询时保持查询向量不量化,先计算每个查询子向量到各中心的平方距离,形成 8 × 256 的查找表。对一条数据库编码,只需取出对应的 8 个表项并相加,即可近似原始平方欧氏距离。这叫非对称距离计算。

“乘积”来自多个子空间码本组合形成的笛卡尔积:8 个子空间各有 256 种选择,理论上可组合成 256 的 8 次方种编码,但不必显式保存这么多个完整中心。

PQ 会产生量化误差,也不会自动消除遍历成本。数据分布、码本训练质量和候选搜索策略都会影响召回;IVF-PQ 还会利用粗聚类缩小扫描范围。评估时应固定查询集,以精确检索作为参照,同时看召回率、延迟和索引内存。

6. Hash:内容标识、去重与权限是三件事

固定长度摘要把任意长度内容映射到有限空间,因此碰撞在数学上不可避免。安全散列的目标是让特定攻击在计算上难以实现,不能把摘要称为绝对唯一的身份证。

下面是可复算的 MD5 示例,仅用于纠正数值与说明确定性:

import hashlib

print(hashlib.md5(b"Hello").hexdigest())
# 8b1a9953c4611296a827abf8c47804d7
print(hashlib.md5(b"hello").hexdigest())
# 5d41402abc4b2a76b9719d911017c592

MD5 和 SHA-1 存在已知碰撞弱点。一般内容摘要可使用 SHA-256,但普通快速散列不应直接充当密码存储方案;密码派生还需要盐和专用的成本机制。接口与安全边界见 Python hashlib 文档。

知识库里可以把业务 ID 与内容摘要分开:业务 ID 保持稳定,摘要标识某一版本的内容。同一内容出现在两份不同政策文件中时,也可能需要保留各自的出处和访问范围。

import hashlib
import json

def content_digest(text, normalization_version="v1"):
    payload = {
        "normalization_version": normalization_version,
        "text": text.replace("\r\n", "\n"),
    }
    raw = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
    return hashlib.sha256(raw).hexdigest()

这个函数只生成摘要,不能凭空知道数据库是否处理过它。实际去重仍需存在性判断以及能处理并发写入的约束或幂等设计。内容可猜时摘要同样可猜,使用哈希 URL 也不能替代权限检查。

7. Redis:按操作选择结构,而不是照搬 Python 类型

Redis 的 key 是二进制安全字符串,value 的数据类型由命令决定。Python 字典不会自动等同于 Redis Hash;把字典序列化成 JSON 再 SET,得到的依然是 String。

需要做的操作可考虑的结构注意点
整体读取回答缓存String + JSON设 TTL,并区分知识库和模型版本
单独更新对象字段Hash适合字段级读写
保存有序消息片段List设置长度上限与过期策略
判断标签是否存在Set成员去重,不保证顺序
按分数取优先项Sorted Set分数含义由业务定义
可靠消费事件流Stream仍需设计确认、重试和幂等

Redis 的能力不只传统五种结构;支持 JSON 功能的部署还能处理嵌套 JSON,见 数据类型总览和 JSON 文档。是否可用应核对实际部署。

下面是需要本地 Redis 服务的缓存示例,TTL 为教学取值:

import json
import redis

client = redis.Redis(host="localhost", port=6379, decode_responses=True)
key = "rag:answer:demo-user:kb-v3:model-v1:query-demo"
client.set(key, json.dumps({"answer": "请补充课程名称。"}, ensure_ascii=False), ex=300)
raw = client.get(key)
answer = json.loads(raw) if raw is not None else None
print(answer)

实际缓存键还需纳入会改变答案的上下文和权限范围。不能只用问题文本作为所有用户共享的缓存键。List 的 LPUSH 会从头部插入,多元素插入与 RPUSH 的顺序不同;JSON 中的布尔值读取后也应按类型处理,不能用 bool("0") 解释真假。

8. argparse 与日志:给脚本一个清楚的入口

两篇 argparse 笔记可以归并成一个独立的小例子。这里实现的是文件清单检查,不冒充完整文档入库系统:

import argparse
import logging
from pathlib import Path

def main():
    parser = argparse.ArgumentParser(description="检查知识库 Markdown 文件")
    parser.add_argument("--data-dir", type=Path, required=True)
    parser.add_argument("--verbose", action="store_true")
    args = parser.parse_args()
    if not args.data_dir.is_dir():
        parser.error("data-dir 必须是已存在的目录")

    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.INFO,
        format="%(asctime)s %(levelname)s %(name)s %(filename)s:%(lineno)d %(message)s",
    )
    logger = logging.getLogger("knowledge_inventory")
    files = sorted(args.data_dir.rglob("*.md"))
    logger.info("inventory_complete file_count=%d", len(files))
    for file in files:
        logger.debug("document=%s", file.name)

if __name__ == "__main__":
    main()
python inventory.py --data-dir ./data --verbose
python inventory.py --help

--data-dir 映射到 args.data_dir;store_true 表示传入开关时为真。不要用 type=bool 解析字符串形式的真假。argparse 文档还介绍了 choices、nargs 和子命令,功能变多后可拆成 ingest、query 等明确入口。

日志里,name 是记录器名称,filename 是文件名,lineno 是日志调用行号,funcName 是函数名。真实请求还可以记录 request_id、阶段耗时、候选数量和错误类型,避免记录密钥、用户完整隐私文本或完整提示词。

basicConfig() 在根记录器已有 handler 时通常不再生效;应用入口集中配置、模块里通过 getLogger(__name__) 获取记录器更容易维护。不要为了修改格式随意清空其他框架的 handler。行为详见 logging 文档。

9. Trainer 与 TrainingArguments:配置和执行分开

TrainingArguments 描述训练节奏和参数,Trainer 组织模型、数据、训练、评估与保存。使用它之前仍须定义任务、划分数据,并提供与模型输入兼容的字段。

当前文档使用 eval_strategy 和 processing_class;旧教程中可能出现 evaluation_strategy 和 tokenizer。应按安装版本核对,不要混抄不同版本的例子。官方训练指南展示了这组接口。

以下是可保存为独立脚本的流程演示,需要安装 torch、transformers、datasets、accelerate、numpy,并能够下载模型。只有少量人工编写样例,用于检查训练链路,不足以证明分类能力。

import numpy as np
from datasets import Dataset
from transformers import (
    AutoTokenizer, AutoModelForSequenceClassification,
    DataCollatorWithPadding, Trainer, TrainingArguments,
)

model_name = "bert-base-chinese"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2)
train = Dataset.from_dict({
    "text": ["我想退费", "如何申请退款", "不想继续上课了", "想取消报名",
             "什么时候上课", "在哪里查看课表", "课程几点开始", "这周有几节课"],
    "labels": [0, 0, 0, 0, 1, 1, 1, 1],
})
validation = Dataset.from_dict({
    "text": ["报名费用能退吗", "明天几点开课"],
    "labels": [0, 1],
})

def tokenize(batch):
    return tokenizer(batch["text"], truncation=True, max_length=128)

train = train.map(tokenize, batched=True, remove_columns=["text"])
validation = validation.map(tokenize, batched=True, remove_columns=["text"])

def metrics(prediction):
    predicted = np.argmax(prediction.predictions, axis=-1)
    return {"accuracy": float(np.mean(predicted == prediction.label_ids))}

args = TrainingArguments(
    output_dir="./training-demo",
    num_train_epochs=1,
    per_device_train_batch_size=2,
    per_device_eval_batch_size=2,
    gradient_accumulation_steps=2,
    learning_rate=2e-5,
    weight_decay=0.01,
    eval_strategy="epoch",
    save_strategy="epoch",
    load_best_model_at_end=True,
    metric_for_best_model="accuracy",
    greater_is_better=True,
    max_grad_norm=1.0,
    fp16=False,
    bf16=False,
    report_to="none",
    seed=42,
)
trainer = Trainer(
    model=model,
    args=args,
    train_dataset=train,
    eval_dataset=validation,
    processing_class=tokenizer,
    data_collator=DataCollatorWithPadding(tokenizer),
    compute_metrics=metrics,
)
trainer.train()
print(trainer.evaluate())
trainer.save_model("./training-demo/final")
tokenizer.save_pretrained("./training-demo/final")

这个例子让评估和保存都按 epoch 执行。若改成 steps 并加载最佳模型,还要核对评估与保存周期约束。动态 padding 由 collator 在组 batch 时完成,分词阶段只截断。

单卡 batch 为 2、累积 2 次时,通常每次参数更新汇总 4 个样本;多卡还需乘数据并行设备数。有效批量相同不保证训练轨迹完全相同。随机种子有助复现,但硬件、算子与版本也会影响结果。完整参数语义见 Trainer 文档。

10. 梯度裁剪:限制梯度范数,不保证训练成功

若梯度为 [3, 4, 0],L2 范数为 5。把阈值设为 1,整体乘以 1/5,得到 [0.6, 0.8, 0]。按全局范数裁剪时,各参数梯度等比例缩小,方向保持不变;逐元素裁剪则不一定保持方向。

g_clipped = g × min(1, threshold / (||g|| + epsilon))

普通训练循环中的位置是反向传播之后、优化器更新之前:

optimizer.zero_grad()
loss = model(**batch).loss
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()

这里是训练循环片段,model、batch、optimizer 和 torch 由外层训练程序提供。使用梯度累积时通常在累计结束后裁剪;使用梯度缩放时需要先还原梯度尺度。Trainer 配置 max_grad_norm 后一般不需再手工重复执行。

裁剪只能控制交给优化器的梯度,不保证消除 NaN,也不保证 Adam 等优化器的最终参数更新范数小于某个阈值。若 loss 发散,还要排查学习率、异常样本、标签错误、数值溢出和损失实现。API 见 PyTorch clip_grad_norm_。

11. 怎样把知识整理成可验证的项目成果

项目描述最有价值的是让别人知道:解决了什么问题,个人负责哪一段,采用什么设计,怎样证明有效。

以退费问答为例,可以先建立标注问题集,保留课程、政策版本、正确证据片段和不可回答样本;依次比较关键词基线、语义召回、融合检索及模型重排。检索阶段看正确证据是否进入候选集,回答阶段再看答案是否正确、是否引用了适用证据、证据不足时是否会澄清或拒答。

想表达的成果需要留下的证据
处理了多少数据原始文件数、去重后文档数、分块数,分开统计
召回效果提高同一测试集、同一 K、基线与改进结果
回答更可靠人工判定标准、样本数、错误类型与引用检查
服务更快相同负载下的延迟分布和测试环境
节省人工工作明确统计窗口、人工转接定义及真实业务记录

例如,“Top-5 提升 35%”仍不完整:是 Recall@5、Precision@5 还是其他指标?是相对提升还是增加 35 个百分点?示意上,从 0.60 到 0.81 是相对提升 35%,但只增加了 21 个百分点。这组数字只是解释口径,不能直接写成项目业绩。

学习完成、原型运行、离线实验、生产上线是不同阶段。先把已有证据讲清楚,再解释设计取舍,比堆砌模型名和没有记录支撑的百分比更有说服力。

12. 用五个问题检查是否真正理解

  1. 同样命中两个词,为什么长文档的 BM25 分数可能更低? 因为词频相同时,长度归一化会降低更长文档的贡献。
  2. RRF 的第一名是 1/(k+1) 还是 1/(k+2)? 排名从 1 开始时是前者;从 0 开始的下标才额外加 1。
  3. 知道文档哈希,是否就知道它已经入库? 不知道,还需要检查存储状态,并处理并发与失败重试。
  4. 缓存命中是否一定可以直接回答? 还要确认权限、知识版本及会话条件与生成缓存时一致。
  5. 训练能跑完,是否就能证明模型好用? 不能,还需要独立测试集、合适基线,以及贴近实际问题的错误分析。

这些知识点最终都应落回同一个问题:输入是什么,输出是什么,哪里可能出错,又用什么证据判断这一步做对了。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇