说明

这篇文章记录的是一个学习型项目 mini-blog-rag 的阶段性过程。目标不是一上来做一个多牛的 AI 系统,而是把 RAG / Eval / Observability / LangChain 这些概念一点点拆开,看清楚每一步到底在干什么。

前言

最近在做一个小项目:把自己的 Hexo 博客文章当成知识库,让 AI 根据博客内容回答问题。

项目目录:

1
D:\MyStudy\mini-blog-rag

知识库来源是我的博客 Markdown:

1
D:\MyCode\JuneBlog\source\_posts

整个学习路线大概分成几步:

阶段 为了做什么 做了什么 新增了什么 优化了什么
阶段 1:Keyword RAG 先把最小 RAG 链路跑通,理解“检索 + 生成”的基本过程 读取博客 Markdown,清理 Hexo front matter,按标题和段落切片,再用关键词检索相关片段 main.py 的基础问答流程、博客读取、切片、关键词检索、Prompt 拼接 先不引入复杂框架,把 RAG 的每一步拆开看清楚
阶段 2:Embedding RAG 解决关键词不一致时搜不到的问题,让检索能理解语义相似 接入本地 Qwen3-Embedding-0.6B,构建向量索引,用向量相似度召回博客片段 embedding_index.pybuild-index--mode embedding 从不稳定的中转站 embedding 改成本地 embedding,减少外部接口依赖
阶段 3:Hybrid Search + RAG Eval 同时利用关键词的精确匹配和 embedding 的语义召回,并且能评估检索效果 把 keyword 分数和 embedding 分数归一化后融合,新增评估问题集,检查是否命中预期来源 --mode hybrideval/questions.jsoneval_rag.py--min-score-ratio 增加来源去重、分数阈值过滤,用 eval 从“凭感觉”变成“可重复测试”
阶段 4:Langfuse 可观测性 看清楚一次 RAG 请求内部到底发生了什么 记录用户问题、检索模式、top_k 片段、Prompt、回答、模型名、耗时等信息 observability.py、Langfuse trace、检索过程元数据记录 通过 Langfuse 观察 chunk、上下文、检索结果和最终回答之间的关系
阶段 5:LangChain 重构 对比手写 RAG 和框架化 RAG,理解 LangChain 封装了什么 保留手写版本作为 baseline,新增 LangChain 版本,把检索器、Prompt、模型、输出解析串成链 langchain_rag.pyBlogHybridRetrieverDocument、LCEL RAG 链、多轮问题改写 把零散函数调用整理成可组合的链路,并升级多轮追问时的检索问题改写
阶段 6:LangGraph Agent 后续让系统从“问答”升级到“多步骤任务执行” 计划做博客分析、旧文检索、选题推荐、大纲生成、草稿生成和人工确认 LangGraph 节点、状态流转、人工确认节点 还没正式展开,准备先从 RAG Debug Agent 这种低风险 Agent 入手

目前先记录前五个阶段。


1. 第一阶段:Keyword RAG

1.1 先把最小链路跑通

第一版没有上复杂框架,也没有向量库,先做最朴素的 RAG:

1
2
3
4
5
6
读取博客 Markdown
-> 清理 Hexo front matter
-> 按标题和段落切片
-> 关键词检索
-> 把片段塞进 Prompt
-> 调大模型回答

这一版的重点是理解 RAG 的骨架。

很多教程一上来就是向量库、框架、Agent,概念会堆在一起。实际拆开看,RAG 最核心的事情就是两步:

  1. 先从知识库里找到相关资料;
  2. 再让模型基于这些资料回答。

运行方式:

1
python main.py --mode keyword --once "我博客里写过哪些 Java 并发或 Token 相关的问题?"

1.2 Keyword 的优点和问题

关键词检索的好处是简单、稳定、可解释。

比如问题里带了 JavaToken并发 这些词,基本就能命中对应文章。

但它的问题也很明显:

Keyword

如果用户的问法和文章里的原文不一样,关键词检索就容易漏掉。

例如用户问:

1
为什么用户会突然掉线?

文章里真正的关键内容可能是:

1
2
3
token 过期
旧 token 被销毁
前端触发重新登录

这时用户问题里没有直接出现 token,纯关键词检索就不够聪明。


2. 第二阶段:Embedding RAG

2.1 为什么要上 Embedding

第二阶段开始做语义检索。

关键词检索看的是字面匹配,Embedding 看的是语义相似度。

也就是说,它更适合这种问题:

1
为什么用户会突然掉线?

虽然问题里没写 token,但 embedding 能把它和“登录失效、token 过期、重新登录”这些语义拉近。

2.2 从中转站 Embedding 换成本地 Qwen

一开始尝试过通过中转站接口做 embedding,但实际用下来不太稳定。

后来改成了本地模型:

1
Qwen/Qwen3-Embedding-0.6B

构建索引:

1
python main.py build-index

查询:

1
python main.py --mode embedding --once "为什么用户会突然掉线?"
这里踩过的坑

本地 embedding 虽然不依赖中转站,但首次加载模型时可能访问 HuggingFace。如果代理关了,可能会出现 HuggingFace 超时。

临时只想测试流程时,可以先切回 keyword:

1
python main.py --mode keyword --once "为什么用户会突然掉线?"

3. 第三阶段:Hybrid Search + RAG Eval

3.1 为什么要 Hybrid

Keyword 和 Embedding 各有优缺点:

模式 优点 问题
keyword 稳定、可解释、关键词强相关 问法不一致时容易漏
embedding 能理解语义相似 可能召回语义相近但不准确的内容

所以第三阶段做了 hybrid:

1
hybrid = keyword 分数 + embedding 分数

但这里有个问题:两边分数不在一个量纲上。

keyword 可能是:

1
2
3
5.0
3.0
1.0

embedding 可能是:

1
2
3
0.40
0.37
0.31

如果直接相加不公平,所以先做归一化:

1
归一化分数 = 当前分数 / 当前模式最高分

最后再融合:

1
hybrid_score = normalized_keyword_score + normalized_embedding_score

运行:

1
python main.py --mode hybrid --once "为什么用户会突然掉线?"

3.2 来源去重和分数阈值

刚开始 hybrid 能找到正确文章,但来源里也会混进一些弱相关文章。

比如真正相关的是:

1
异常记录\一次 Token 刷新并发 Bug 的排查与解决.md

但后面可能还会跟一些只是语义沾边的文章。

因此加了两步:

  1. 按来源去重:避免同一篇文章重复占满上下文;
  2. 分数阈值过滤:只保留接近最高分的结果。

例如:

1
python main.py --mode hybrid --min-score-ratio 0.8 --once "为什么用户会突然掉线?"

--min-score-ratio 0.8 的意思是:

1
只保留分数 >= 最高分 * 0.8 的结果

这样可以过滤掉明显低于最高分的噪声来源。

3.3 RAG Eval

做到这里还有一个问题:怎么判断检索是真的变好了?

如果只靠肉眼看一次回答,很容易误判。
因为模型有时候就算拿到的来源不太准,也能靠自己的语言能力答得像那么回事。
这时候看起来是“回答对了”,但实际 RAG 链路里的检索可能已经跑偏了。

所以第三阶段加了一个最小版的 RAG Eval。
它暂时不评价最终回答写得好不好,只评价一件更基础的事:

1
针对某个问题,检索结果里有没有找回我预期的那篇文章?

也就是说,这个 eval 先检查 Retriever,而不是检查 LLM。

3.3.1 评估样本怎么写

评估问题放在:

1
eval/questions.json

里面每一条数据都包含两个字段:

1
2
3
4
{
"question": "为什么用户会突然掉线?",
"expected_source": "异常记录/一次 Token 刷新并发 Bug 的排查与解决.md"
}

这里的含义是:

字段 含义
question 用来测试检索的问题
expected_source 我认为这个问题应该命中的博客文章

比如“为什么用户会突然掉线?”这个问题,预期来源应该是 Token 刷新并发 Bug 那篇文章。
如果检索结果里没有这篇文章,那就说明检索阶段没有把正确资料找回来。

注意

这里的 expected_source 不是让模型照着答案打分,而是用来检查“检索结果里有没有正确来源”。
也就是说,它评估的是检索命中率,不是评估最终回答质量。

3.3.2 eval_rag.py 做了什么

运行命令:

1
python eval_rag.py --mode all --top-k 3

大致流程是:

1
2
3
4
5
读取 eval/questions.json
-> 加载博客切片
-> 对每个 question 执行检索
-> 拿检索到的 top_k 来源和 expected_source 对比
-> 输出每种检索模式的命中情况

--mode all 表示同时测试三种检索模式:

1
2
3
keyword
embedding
hybrid

--top-k 3 表示每个问题只看检索分数最高的前 3 个片段。

举个例子,如果某个问题的 expected_source 是:

1
异常记录/一次 Token 刷新并发 Bug 的排查与解决.md

而检索出来的 top_sources 是:

1
2
3
异常记录\一次 Token 刷新并发 Bug 的排查与解决.md
异常记录\DataX 同步 StarRocks VARCHAR 超长导致数据丢失.md
异常记录\Pip版本更新导致友圈爬虫异常.md

那么这条就是命中了。
因为第一个来源就是预期文章。

3.3.3 输出结果怎么看

脚本会先输出一个汇总,例如:

1
[hybrid] hit@3 3/3,hit rate 100%,top1 2/3,avg precision@3 33%

这几个指标可以这样理解:

指标 含义 怎么看
hit@3 前 3 个结果里命中了几道题的 expected_source 越高越好
hit rate 命中率 3/3 就是 100%
top1 有几道题的正确来源排在第 1 位 越高越好
avg precision@3 平均 precision@3 看 top_k 里正确来源占比

这里最重要的是 hit@3top1

hit@3 说明正确文章有没有被找回来。
只要命中了,模型至少有机会基于正确资料回答。

top1 说明正确文章是不是排在最前面。
如果正确来源总是排在第二、第三,说明检索能找到,但排序还可以继续优化。

precision@3 在当前这个小项目里不用过度纠结。
因为每个问题通常只配置了一个 expected_source,而 top_k 是 3。
所以哪怕检索结果是:

1
2
3
第 1 条:正确文章
第 2 条:其他文章
第 3 条:其他文章

precision@3 也只有:

1
1 / 3 = 33%

这不是说效果很差,而是说明前 3 条里只有 1 条是预期来源。
在当前阶段,它更多是辅助观察指标。

3.3.4 PASS 和 FAIL 怎么理解

单条问题会输出类似:

1
2
3
4
5
- [PASS] 为什么用户会突然掉线?
expected_source: 异常记录/一次 Token 刷新并发 Bug 的排查与解决.md
top1_hit: True,rank: 1,precision@3: 33%
top_sources: 异常记录\一次 Token 刷新并发 Bug 的排查与解决.md, ...
unique_sources: 异常记录\一次 Token 刷新并发 Bug 的排查与解决.md, ...

这里可以按顺序看:

输出 含义
PASS top_k 结果里找到了 expected_source
FAIL top_k 结果里没有找到 expected_source
top1_hit: True 正确来源排在第 1 位
rank: 1 正确来源具体排第几
top_sources 实际检索出来的片段来源,可能同一篇文章出现多次
unique_sources 按文章去重后的来源,更适合人看

如果出现 FAIL,就说明这个问题当前检索不稳定。
下一步就要去看:

1
2
3
是 keyword 没有匹配到关键词?
还是 embedding 语义召回偏了?
还是 hybrid 融合分数把错误文章排上来了?

这也是为什么 --mode all 很有用。
它不是只告诉我“失败了”,而是能帮我比较:

1
2
3
4
keyword 失败,embedding 成功:说明问题更依赖语义理解
keyword 成功,embedding 失败:说明精确关键词更重要
hybrid 成功:说明融合起到了补位作用
hybrid 失败:说明融合权重、阈值或切片可能还要继续调
这个阶段的收获

RAG Eval 的作用不是证明系统已经很强,而是把“感觉还行”变成“可以重复测试”。
每次改检索逻辑、top_k、分数阈值、切片策略后,都可以跑一遍 eval,看正确来源有没有更稳定地被找回来。


4. 第四阶段:Langfuse 可观测性

4.1 为什么要接 Langfuse

RAG 跑起来后,新的问题来了:

1
2
3
4
模型到底看到了哪些上下文?
用的是 keyword、embedding 还是 hybrid?
Prompt 长什么样?
最终回答基于哪些片段?

命令行里可以打印,但不适合长期对比。

所以第四阶段接入 Langfuse,把每次 RAG 的过程记录下来。

记录内容包括:

  • 用户问题 question
  • 实际检索问题 retrieval_question
  • 检索模式 keyword / embedding / hybrid
  • top_k
  • min_score_ratio
  • 原始检索片段 raw_contexts
  • 最终进入 Prompt 的片段 final_contexts
  • Prompt
  • 回答
  • 模型名
  • 耗时

运行:

1
python main.py --mode hybrid --min-score-ratio 0.8 --once "为什么用户会突然掉线?"

4.2 观察 Chunking

通过 Langfuse 很容易看到一个细节:

1
模型拿到的不是整篇文章,而是切片后的某几个 chunk

当前 Markdown 是先按标题切 section,再按段落和 chunk_size 切片。

这解释了为什么有时候模型只看到“背景”,没看到“最终解决方案”。

于是又加了邻近片段扩展:

1
python main.py --mode hybrid --min-score-ratio 0.8 --expand-neighbors 1 --once "这个问题最后是怎么解决的?"

--expand-neighbors 1 表示:

1
命中某个片段后,把同一篇文章里它前后各 1 个片段也带上

4.3 多轮追问的第一版

接着又遇到一个真实问题:

1
2
第一轮:为什么用户会突然掉线?
第二轮:这个问题最后是怎么解决的?

第二轮里的“这个问题”本身没有独立语义,直接检索容易跑偏。

于是先做了一个简单规则:

1
基于上一轮问题“为什么用户会突然掉线?”,这个问题最后是怎么解决的?

这里开始区分两个概念:

1
2
question:用户原始问题,用来回答
retrieval_question:补全上下文后的问题,用来检索

这个设计后面在 LangChain 阶段继续升级。


5. 第五阶段:LangChain 重构

5.1 先做对照版,而不是替换原版

第五阶段没有直接把原来的 main.py 干掉,而是新增了:

1
langchain_rag.py

原来的手写版本保留作为 baseline。

这么做的目的不是“为了用框架而用框架”,而是对比:

1
2
手写 RAG 到底自己做了什么?
LangChain 又帮我们封装了什么?

5.2 Prompt、模型调用和输出解析

手写版大概是:

1
2
3
自己拼 Prompt
-> 自己调 OpenAI 兼容接口
-> 自己解析 choices[0].message.content

LangChain 版变成:

1
2
3
ChatPromptTemplate
-> ChatOpenAI
-> StrOutputParser

外部效果差不多,但内部组织方式更标准。

运行:

1
python langchain_rag.py --mode hybrid --min-score-ratio 0.8 --once "为什么用户会突然掉线?"

5.3 Retriever 和 Document

接着把检索也包成 LangChain 的 Retriever:

1
BlogHybridRetriever.invoke(question) -> list[Document]

这一步主要理解两个概念:

  • Retriever:负责根据问题找资料;
  • Document:LangChain 里表示资料片段的标准格式。

对比一下:

1
2
3
4
5
手写检索:
retrieve_hybrid(...) -> list[dict]

LangChain 检索:
BlogHybridRetriever.invoke(...) -> list[Document]

5.4 LCEL RAG 链

再往后,把整条链路用 LCEL 串起来。

LCEL 的全称是 LangChain Expression Language
一开始看到这个名字会觉得很抽象,但在这个项目里可以先简单理解成:

1
把多个处理步骤,像管道一样串成一条链

手写 RAG 时,大概是这样:

1
2
3
contexts = retrieve_hybrid(question)
prompt = build_prompt(question, contexts)
answer = call_llm(prompt)

也就是我自己控制每一步:

1
2
3
4
先检索
再拼 Prompt
再调模型
再取出回答文本

LangChain 版本里,这些步骤没有消失,只是被整理成了一条可以组合的链:

1
2
3
4
5
6
7
question
-> BlogHybridRetriever
-> format_documents_for_prompt
-> ChatPromptTemplate
-> ChatOpenAI
-> StrOutputParser
-> answer

每一环大概对应:

环节 作用
question 用户原始问题
BlogHybridRetriever 根据问题检索博客片段
format_documents_for_prompt Document 列表整理成 Prompt 里的上下文文本
ChatPromptTemplate 把问题和上下文填进提示词模板
ChatOpenAI 调用 OpenAI 兼容接口,让模型生成回答
StrOutputParser 把模型返回结果解析成普通字符串
answer 最终回答

所以 LCEL 不是又发明了一套新的 RAG 逻辑。
它更像是把原来这些零散函数调用,整理成一个固定流程。

最终使用时,不需要在业务代码里一层层手动调用:

1
answer = chain.invoke(question)

调用 .invoke(question) 后,链内部会自动按顺序执行:

1
2
3
4
5
检索资料
-> 格式化上下文
-> 拼 Prompt
-> 调模型
-> 解析输出
这一阶段的理解

LangChain 的价值不是替我“发明 RAG”,而是把 RAG 里常见的步骤变成标准组件。
手写版本更适合学习底层过程,LangChain 版本更适合把流程整理成可组合、可替换、可继续扩展的链。

5.5 多轮追问改写

最后,把第四阶段的规则版追问改写升级了一下。

这个问题来自一个很真实的场景:

1
2
3
第一轮:为什么用户会突然掉线?
第二轮:PicGo 上传失败可能是什么 token 问题?
第三轮:那第一个问题最后是怎么解决的?

第三轮里的“第一个问题”其实指的是第一轮的“用户突然掉线”。
但如果直接拿第三轮这句话去检索,问题就很模糊:

1
那第一个问题最后是怎么解决的?

这句话本身没有 用户掉线Token 刷新并发 Bug 这些关键信息。
Retriever 只看当前问题时,很可能不知道它要找哪篇文章。

所以多轮追问里需要多做一步:问题改写

也就是把带有上下文指代的问题,改写成一个可以独立检索的问题。

1
2
原始问题:那第一个问题最后是怎么解决的?
改写后:用户突然掉线这个问题最后是怎么解决的?

这里有两个概念:

名称 含义 用在哪里
question 用户原始输入的问题 最终回答时保留用户表达
retrieval_question 改写后的检索问题 给 Retriever 用来找资料

也就是说,用户看到的还是自己的问题。
但系统内部检索时,用的是更完整的 retrieval_question

5.5.1 为什么不能只看上一轮

一开始做过一个简单规则:

1
如果当前问题像是追问,就把上一轮问题拼进去

例如:

1
2
上一轮:为什么用户会突然掉线?
当前轮:这个问题最后是怎么解决的?

可以改成:

1
基于上一轮问题“为什么用户会突然掉线?”,这个问题最后是怎么解决的?

这对“连续追问同一个话题”是够用的。
但一旦中间插入了别的话题,就不稳了。

例如:

1
2
3
第 1 轮:为什么用户会突然掉线?
第 2 轮:PicGo 上传失败可能是什么 token 问题?
第 3 轮:那第一个问题最后是怎么解决的?

如果只看上一轮,第三轮就会被错误地理解成:

1
PicGo 上传失败这个问题最后是怎么解决的?

但用户说的“第一个问题”,其实是第一轮。

所以后面升级成:把最近 N 轮对话历史一起给 LLM,让它判断当前问题到底指向哪一轮。

如果配置了 API Key,会多调一轮 LLM:

1
2
3
4
最近 N 轮 history + 当前问题
-> LLM 改写成 standalone question
-> Retriever 检索
-> RAG 回答

5.5.2 LLM 改写到底做了什么

还是这个例子:

1
2
3
第 1 轮:为什么用户会突然掉线?
第 2 轮:PicGo 上传失败可能是什么 token 问题?
第 3 轮:那第一个问题最后是怎么解决的?

LLM 改写时会看到最近的对话历史。
它会判断:

1
“第一个问题” = 第 1 轮问题

然后把第三轮改写成类似:

1
用户突然掉线这个问题最后是怎么解决的?

接着 Retriever 不再拿原始问题去搜,而是拿这个改写后的问题去搜。

这样检索时就重新带上了 用户掉线 这个核心语义,更容易命中 Token 刷新并发 Bug 那篇文章。

注意

这一步多调了一次 LLM,所以它不是“免费”的。
好处是多轮指代更稳,代价是多一次模型调用、一次耗时和一点成本。

5.5.3 没有 API Key 时怎么办

如果没有 API Key,则回退到上一轮规则:

1
基于上一轮问题“...”,当前问题

这个回退策略比较粗糙,但至少能覆盖最常见的连续追问:

1
2
第一轮:为什么用户会突然掉线?
第二轮:这个问题最后是怎么解决的?

它的问题也很明显:

1
2
只能理解“上一轮”
不擅长理解“第一个问题”“刚才那个 token 问题”“前面说的 Docker 问题”

所以后面真正想把多轮对话做好,还是需要 LLM 改写或者更完整的对话状态管理。

5.5.4 Langfuse 里怎么看

Langfuse 里也记录了这些字段:

  • question
  • retrieval_question
  • chat_history
  • question_rewrite_strategy

它们的作用分别是:

字段 含义
question 用户这轮原始问题
retrieval_question 实际拿去检索的问题
chat_history 最近几轮对话历史
question_rewrite_strategy 这次用了 LLM 改写,还是规则回退

这样就能看清楚:

1
2
3
4
5
用户原话是什么?
系统有没有改写?
改写成了什么?
为什么会命中这篇文章?
如果检索错了,是不是改写阶段就错了?
这一阶段的理解

多轮 RAG 的关键不是简单地“记住聊天记录”,而是要把当前追问还原成一个适合检索的独立问题。
回答可以面向用户原问题,但检索最好使用信息更完整的 retrieval_question


6. 阶段性总结

这五个阶段做下来,最大的感受是:RAG 不是一个单点技术,而是一条链路。

1
2
3
4
5
6
7
8
9
10
11
文档读取
-> 文档清洗
-> 文档切片
-> 检索
-> 融合排序
-> 过滤去重
-> Prompt
-> 模型回答
-> 可观测性
-> 评估
-> 多轮上下文

每一步都会影响最终效果。

一开始我以为 RAG 的关键是“大模型够不够强”,但实际做下来发现,很多问题都出在模型之前:

  • chunk 切得太碎,模型看不到完整上下文;
  • 检索结果混进弱相关来源,回答会被带偏;
  • 没有 eval,就只能靠感觉判断好坏;
  • 没有 Langfuse,就很难解释模型到底看到了什么;
  • 多轮追问不改写,检索问题本身就是残缺的。
当前理解

RAG 的核心不是“把资料塞给模型”,而是稳定地把正确资料、以合适粒度、在可观测可评估的链路里交给模型。