第 03 篇把上下文工程做完后,检索就成了 ByteBot 质量的下一个天花板。现在它用的是 ai-assistant.js 和 api/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 是对整篇文章计分的。这带来两个问题:
- 细粒度丢失:文章很长时(比如 60KB 的 Unity 游戏框架那篇),关键词出现在第 500 行,BM25 的文档频率统计会稀释得分。
- 答案粒度错配:ByteBot 推荐的是”文章”(doc 级),但读者真正需要的是”文章的某一段”(chunk 级)。检索粒度和答案粒度不匹配,是 RAG 效果差的头号原因。
所以第一步不是上向量,而是先切块——不管用什么召回算法,chunk 粒度都应该是基础。
二、Chunking 策略
2.1 三种切块方案对比
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 固定大小滑动窗口 | 按字符/token 数切,重叠窗口 | 实现简单、鲁棒 | 切断语义 |
| 结构化切块 | 按标题层级/段落/列表切 | 语义完整 | 依赖文档结构 |
| 语义切块 | 按 embedding 相似度切 | 边界最合理 | 计算成本高 |
对博客文章,结构化切块是最优解:文章天生有 ## 二级标题,Hexo 生成的 posts-index.json 里每个 post 甚至带了 headings 字段。按标题切块既保语义,又和 Markdown 结构天然对齐。
2.2 ByteBot 的切块方案
1 | def chunk_markdown(text, max_chars=800, overlap=120): |
关键设计:
- 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-m3或bge-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 | 用户问题 |
4.1 查询改写(Query Rewrite)
用户的问题往往口语化、不完整。”怎么钓鲫鱼?”——检索前把它改写成更利于召回的形态:”ByteFisher 博客 钓鲫鱼 技巧 教程”。三种常用改写:
1 | REWRITE_PROMPT = """ |
改写是用小模型 + 低 temperature 做,因为它只是检索前置,不需要强推理。改写后的多查询还能配合”多路召回取并集”,进一步提升召回率(第 05 篇会用 Recall@K 度量这个提升)。
4.2 向量召回
用 embedding 计算”查询 embedding”,在 TiDB 向量索引上按余弦距离取 top 50:
1 | SELECT article_id, chunk_title, chunk_text, |
4.3 融合排序:RRF
向量和 BM25 分数量纲不同,不能直接相加。RRF(Reciprocal Rank Fusion)用排名倒数融合,天然规避量纲问题:
1 | RRF(score) = Σ 1/(k + rank_i) # k 一般取 60 |
1 | 问题:"C# 委托怎么用" |
RRF 的意义:两路召回各取 50,融合后取 top 20——召回覆盖面更大,且不需要调对齐分数权重的参数,工程上极稳健。
4.4 Rerank 重排序
融合后的 top 20 仍有噪声,最后用交叉编码器 Reranker(如 bge-reranker-v2-m3)精排:把 query 和每个 chunk 拼在一起过一遍模型,输出相关性分。Cross-encoder 的精度远高于 Bi-encoder(embedding),但因为要逐对计算,只适合精排小集合。
1 | 输入:query + top20 个 chunk → 输出:20 个 0~1 相关性分 |
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 框架 + 从博客真实读者收集的评估集,把本篇文章做的每个改动量化成指标。
本系列持续更新中,每周一篇。