ByteFisher AI 工程化深度实战(四):RAG 优化(上)——检索质量

第 03 篇把上下文工程做完后,检索就成了 ByteBot 质量的下一个天花板。现在它用的是 ai-assistant.jsapi/chat.js 里各一份的 BM25 关键词检索。BM25 是 1994 年的算法,成熟、快、零成本,但语义理解为零——“五子棋”搜”棋类游戏”搜不到,”断竿跑鱼”搜”遛鱼技巧”也搜不到。

本篇把检索链路升级为业界标准的混合检索(Hybrid Search):向量召回 + 关键词召回 + 融合排序 + Rerank。用博客 260 篇文章做真实知识库,全部给出可落地的方案和代码。

一、为什么单路 BM25 撑不起 RAG

RAG 的完整链路是:切块(Chunking)→ 召回(Recall)→ 排序(Ranking)→ 生成(Generation)。ByteBot 现在只做了”召回 + 排序”里最低配的一部分,而且有个隐藏问题:

1
检索文本 = title + textForSearch + summary + excerpt + tags + categories

textForSearch 是构建时生成的全文字符串,BM25 是对整篇文章计分的。这带来两个问题:

  1. 细粒度丢失:文章很长时(比如 60KB 的 Unity 游戏框架那篇),关键词出现在第 500 行,BM25 的文档频率统计会稀释得分。
  2. 答案粒度错配:ByteBot 推荐的是”文章”(doc 级),但读者真正需要的是”文章的某一段”(chunk 级)。检索粒度和答案粒度不匹配,是 RAG 效果差的头号原因。

所以第一步不是上向量,而是先切块——不管用什么召回算法,chunk 粒度都应该是基础。

二、Chunking 策略

2.1 三种切块方案对比

方案 原理 优点 缺点
固定大小滑动窗口 按字符/token 数切,重叠窗口 实现简单、鲁棒 切断语义
结构化切块 按标题层级/段落/列表切 语义完整 依赖文档结构
语义切块 按 embedding 相似度切 边界最合理 计算成本高

对博客文章,结构化切块是最优解:文章天生有 ## 二级标题,Hexo 生成的 posts-index.json 里每个 post 甚至带了 headings 字段。按标题切块既保语义,又和 Markdown 结构天然对齐。

2.2 ByteBot 的切块方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def chunk_markdown(text, max_chars=800, overlap=120):
"""按二级/三级标题切块,块内超过 max_chars 再按段落切"""
chunks = []
cur_title = '简介'
cur_content = []

for line in text.split('\n'):
if line.startswith('## '):
flush()
cur_title = line[3:].strip()
elif line.startswith('### '):
flush()
cur_title = ' ' + line[4:].strip()
else:
cur_content.append(line)
# 超长块强制切分,带 overlap 保上下文
if sum(len(x) for x in cur_content) > max_chars:
flush(overlap=True)

flush()
return chunks

def flush(...):
"""把 (cur_title, cur_content) 打包成带元数据的 chunk"""
# chunk = { "article_id": ..., "title": cur_title, "text": "...", "headings": [...] }

关键设计:

  • chunk 带元数据article_id、所属标题路径、原文链接。这决定 RAG 能否”引用到段落级”。
  • overlap 120 字符:跨标题的上下文(比如一个概念在上一节结尾定义、下一节开头讨论)不会断裂。
  • 标题路径作为上下文前缀:向量化时把”文章标题 → 二级标题 → 段落”拼进 chunk 文本,embedding 才能捕捉层级语义。

三、Embedding 模型选型

3.1 评测标准

Embedding 模型的质量看三件事:MTEB 基准分中文能力维度/成本

模型 维度 中文 MTEB 一句话评估
text-embedding-3-small(OpenAI) 1536 API 简单但按量付费
bge-m3(BAAI,开源) 1024 优秀 开源最强梯队,支持稠密+稀疏
text-embedding-3-large(OpenAI) 3072 优秀 贵,效果更好
本地 mini(如 bge-small-zh) 512 良好 省成本,性能略降

对 ByteBot 的取舍:

  • 追求零成本、离线可跑bge-m3bge-small-zh,用 Ollama 或 sentence-transformers 本地跑,一次构建成本可忽略。
  • 追求省事:OpenAI embedding API,但每篇 260 篇 × 若干 chunk 需要付费,且 embedding 每次更新索引都要重新调用。
  • TiDB Cloud 内置向量:博客已经在用 TiDB Cloud,TiDB 7.5+ 支持向量类型(VECTOR 列 + 余弦距离),不需要引入新数据库——这是成本最优解。中文 Embedding 可以用 bge-m3 本地生成后写入 TiDB 向量列。

3.2 一个关键技巧:文档级 embedding

只存 chunk embedding 有局限——用户问”Unity 有哪些入门教程”这种文章级问题,chunk 级检索反而不准。业界实践是两级检索

  • chunk 级向量:回答”某篇讲什么、某段细节”
  • doc 级向量:回答”哪些文章相关、该推荐谁”

ByteBot 的推荐场景本质是 doc 级,所以 doc 级向量库必须有,且 doc 级向量的文本应由 title + summary + tags + 前 N 个 heading 组成(短、语义浓)。这正好复用第 01 篇分析的 posts-index.json 结构。

四、混合检索全链路

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户问题

├──(1) 查询改写 Query Rewrite──────┐
│ 扩展同义词 / 补全简称 / 意图澄清 │
▼ ▼
(2a) 向量召回 (2b) BM25 召回
top 50 chunk top 50 chunk
│ │
└──────── 融合排序(RRF)───────────┘
│ top 20

(3) Rerank 重排序
│ top 5~8 带分数的 chunk

(4) 注入 prompt → 生成

4.1 查询改写(Query Rewrite)

用户的问题往往口语化、不完整。”怎么钓鲫鱼?”——检索前把它改写成更利于召回的形态:”ByteFisher 博客 钓鲫鱼 技巧 教程”。三种常用改写:

1
2
3
4
5
6
7
8
9
10
REWRITE_PROMPT = """
你是检索查询改写器。把用户问题改写成适合搜索的查询,规则:
1. 补全缺失的领域术语
2. 补充博客主题词(Unity、C#、Python、Lua、Hexo、钓鱼、AI编程)
3. 输出1-3个查询变体,每行一个
4. 只输出查询,不要解释

用户问题:{question}
改写结果:
"""

改写是用小模型 + 低 temperature 做,因为它只是检索前置,不需要强推理。改写后的多查询还能配合”多路召回取并集”,进一步提升召回率(第 05 篇会用 Recall@K 度量这个提升)。

4.2 向量召回

用 embedding 计算”查询 embedding”,在 TiDB 向量索引上按余弦距离取 top 50:

1
2
3
4
5
SELECT article_id, chunk_title, chunk_text, 
VEC_COSINE_DISTANCE(embedding, '...') AS dist
FROM blog_chunks
ORDER BY dist
LIMIT 50;

4.3 融合排序:RRF

向量和 BM25 分数量纲不同,不能直接相加。RRF(Reciprocal Rank Fusion)用排名倒数融合,天然规避量纲问题:

1
RRF(score) = Σ 1/(k + rank_i)    # k 一般取 60
1
2
3
问题:"C# 委托怎么用"
向量召回第 3 名、BM25 召回第 1 名 → RRF = 1/(63) + 1/(61)
若两路都在前 10,融合分显著高于单路靠后结果

RRF 的意义:两路召回各取 50,融合后取 top 20——召回覆盖面更大,且不需要调对齐分数权重的参数,工程上极稳健。

4.4 Rerank 重排序

融合后的 top 20 仍有噪声,最后用交叉编码器 Reranker(如 bge-reranker-v2-m3)精排:把 query 和每个 chunk 拼在一起过一遍模型,输出相关性分。Cross-encoder 的精度远高于 Bi-encoder(embedding),但因为要逐对计算,只适合精排小集合。

1
2
输入:query + top20 个 chunk  →  输出:20 个 0~1 相关性分
取 top 5~8 注入 prompt

Rerank 是检索质量提升性价比最高的一步——很多 benchmark 显示 Rerank 能把 RAG 命中率提升 10~20 个百分点,远高于换更好的 embedding。

五、ByteBot 落地方案

对 ByteBot 的改造分两步,避免一次上全量架构导致风险失控:

Phase 1(本系列第 05 篇前完成):保留 BM25,增加 doc 级向量召回,RRF 融合,先不做 Rerank 和 chunk 级。产出:posts-index.json 增加 embedding 字段,chat.js 检索函数升级为混合。

Phase 2(第 12 篇 ByteBot v2):全量 chunk 化 + Rerank,检索粒度从”文章”细化到”段落”,回答可引用到具体章节。

架构存储建议:

数据 存储 说明
文章元数据 现有 posts-index.json 保持不变
doc 级 embedding TiDB 向量列 博客已在用 TiDB
chunk 级 embedding TiDB 向量列 Phase 2
BM25 索引 现有搜索逻辑 保留,RRF 融合

六、落地清单与小结

优先级 改造 收益
P0 切块(结构化 + 元数据) 检索粒度匹配答案粒度,RAG 效果的根基
P0 doc 级向量 + RRF 融合 语义召回补 BM25 盲区,成本极低
P1 Rerank 精排 单点提升最大的环节
P2 查询改写 口语化问题召回率提升
P3 chunk 级向量 + 段落引用 回答可精确引用章节

核心结论:

  • RAG 效果的天花板不是模型,而是检索:切块粒度错配是头号杀手。
  • 混合检索 = 向量召回(语义) + 关键词召回(精确) + RRF 融合(稳) + Rerank(精)。
  • RRF 用排名融合,规避了向量分数和 BM25 分数的量纲问题,是工程上最稳的融合方式。
  • Rerank 是性价比最高的一步,比换 embedding 模型收益更大。
  • 用博客已有的 TiDB 做向量存储,不引入新数据库,成本最优。

下一篇是 RAG 优化(下):评测与迭代。光改不测是耍流氓——我会用 RAGAS 框架 + 从博客真实读者收集的评估集,把本篇文章做的每个改动量化成指标。


本系列持续更新中,每周一篇。

ByteFisher
分享编程技术 · 记录钓鱼乐趣
扫码关注
▸ 扫码关注 ◂
ByteFisher AI工程化深度实战教程 系列
第 4/4 篇
分享: