从 Keyword RAG 到 LangChain:迷你博客 RAG 学习记录
这篇文章记录的是一个学习型项目 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.py、build-index、--mode embedding |
从不稳定的中转站 embedding 改成本地 embedding,减少外部接口依赖 |
| 阶段 3:Hybrid Search + RAG Eval | 同时利用关键词的精确匹配和 embedding 的语义召回,并且能评估检索效果 | 把 keyword 分数和 embedding 分数归一化后融合,新增评估问题集,检查是否命中预期来源 | --mode hybrid、eval/questions.json、eval_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.py、BlogHybridRetriever、Document、LCEL RAG 链、多轮问题改写 |
把零散函数调用整理成可组合的链路,并升级多轮追问时的检索问题改写 |
| 阶段 6:LangGraph Agent | 后续让系统从“问答”升级到“多步骤任务执行” | 计划做博客分析、旧文检索、选题推荐、大纲生成、草稿生成和人工确认 | LangGraph 节点、状态流转、人工确认节点 | 还没正式展开,准备先从 RAG Debug Agent 这种低风险 Agent 入手 |
目前先记录前五个阶段。
1. 第一阶段:Keyword RAG
1.1 先把最小链路跑通
第一版没有上复杂框架,也没有向量库,先做最朴素的 RAG:
1 | 读取博客 Markdown |
这一版的重点是理解 RAG 的骨架。
很多教程一上来就是向量库、框架、Agent,概念会堆在一起。实际拆开看,RAG 最核心的事情就是两步:
- 先从知识库里找到相关资料;
- 再让模型基于这些资料回答。
运行方式:
1 | python main.py --mode keyword --once "我博客里写过哪些 Java 并发或 Token 相关的问题?" |
1.2 Keyword 的优点和问题
关键词检索的好处是简单、稳定、可解释。
比如问题里带了 Java、Token、并发 这些词,基本就能命中对应文章。
但它的问题也很明显:
如果用户的问法和文章里的原文不一样,关键词检索就容易漏掉。
例如用户问:
1 | 为什么用户会突然掉线? |
文章里真正的关键内容可能是:
1 | 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 | 5.0 |
embedding 可能是:
1 | 0.40 |
如果直接相加不公平,所以先做归一化:
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 | 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 | { |
这里的含义是:
| 字段 | 含义 |
|---|---|
| question | 用来测试检索的问题 |
| expected_source | 我认为这个问题应该命中的博客文章 |
比如“为什么用户会突然掉线?”这个问题,预期来源应该是 Token 刷新并发 Bug 那篇文章。
如果检索结果里没有这篇文章,那就说明检索阶段没有把正确资料找回来。
这里的 expected_source 不是让模型照着答案打分,而是用来检查“检索结果里有没有正确来源”。
也就是说,它评估的是检索命中率,不是评估最终回答质量。
3.3.2 eval_rag.py 做了什么
运行命令:
1 | python eval_rag.py --mode all --top-k 3 |
大致流程是:
1 | 读取 eval/questions.json |
--mode all 表示同时测试三种检索模式:
1 | keyword |
--top-k 3 表示每个问题只看检索分数最高的前 3 个片段。
举个例子,如果某个问题的 expected_source 是:
1 | 异常记录/一次 Token 刷新并发 Bug 的排查与解决.md |
而检索出来的 top_sources 是:
1 | 异常记录\一次 Token 刷新并发 Bug 的排查与解决.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@3 和 top1。
hit@3 说明正确文章有没有被找回来。
只要命中了,模型至少有机会基于正确资料回答。
top1 说明正确文章是不是排在最前面。
如果正确来源总是排在第二、第三,说明检索能找到,但排序还可以继续优化。
precision@3 在当前这个小项目里不用过度纠结。
因为每个问题通常只配置了一个 expected_source,而 top_k 是 3。
所以哪怕检索结果是:
1 | 第 1 条:正确文章 |
precision@3 也只有:
1 | 1 / 3 = 33% |
这不是说效果很差,而是说明前 3 条里只有 1 条是预期来源。
在当前阶段,它更多是辅助观察指标。
3.3.4 PASS 和 FAIL 怎么理解
单条问题会输出类似:
1 | - [PASS] 为什么用户会突然掉线? |
这里可以按顺序看:
| 输出 | 含义 |
|---|---|
| PASS | top_k 结果里找到了 expected_source |
| FAIL | top_k 结果里没有找到 expected_source |
| top1_hit: True | 正确来源排在第 1 位 |
| rank: 1 | 正确来源具体排第几 |
| top_sources | 实际检索出来的片段来源,可能同一篇文章出现多次 |
| unique_sources | 按文章去重后的来源,更适合人看 |
如果出现 FAIL,就说明这个问题当前检索不稳定。
下一步就要去看:
1 | 是 keyword 没有匹配到关键词? |
这也是为什么 --mode all 很有用。
它不是只告诉我“失败了”,而是能帮我比较:
1 | keyword 失败,embedding 成功:说明问题更依赖语义理解 |
RAG Eval 的作用不是证明系统已经很强,而是把“感觉还行”变成“可以重复测试”。
每次改检索逻辑、top_k、分数阈值、切片策略后,都可以跑一遍 eval,看正确来源有没有更稳定地被找回来。
4. 第四阶段:Langfuse 可观测性
4.1 为什么要接 Langfuse
RAG 跑起来后,新的问题来了:
1 | 模型到底看到了哪些上下文? |
命令行里可以打印,但不适合长期对比。
所以第四阶段接入 Langfuse,把每次 RAG 的过程记录下来。
记录内容包括:
- 用户问题
question - 实际检索问题
retrieval_question - 检索模式
keyword / embedding / hybrid top_kmin_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 | 第一轮:为什么用户会突然掉线? |
第二轮里的“这个问题”本身没有独立语义,直接检索容易跑偏。
于是先做了一个简单规则:
1 | 基于上一轮问题“为什么用户会突然掉线?”,这个问题最后是怎么解决的? |
这里开始区分两个概念:
1 | question:用户原始问题,用来回答 |
这个设计后面在 LangChain 阶段继续升级。
5. 第五阶段:LangChain 重构
5.1 先做对照版,而不是替换原版
第五阶段没有直接把原来的 main.py 干掉,而是新增了:
1 | langchain_rag.py |
原来的手写版本保留作为 baseline。
这么做的目的不是“为了用框架而用框架”,而是对比:
1 | 手写 RAG 到底自己做了什么? |
5.2 Prompt、模型调用和输出解析
手写版大概是:
1 | 自己拼 Prompt |
LangChain 版变成:
1 | ChatPromptTemplate |
外部效果差不多,但内部组织方式更标准。
运行:
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 | 手写检索: |
5.4 LCEL RAG 链
再往后,把整条链路用 LCEL 串起来。
LCEL 的全称是 LangChain Expression Language。
一开始看到这个名字会觉得很抽象,但在这个项目里可以先简单理解成:
1 | 把多个处理步骤,像管道一样串成一条链 |
手写 RAG 时,大概是这样:
1 | contexts = retrieve_hybrid(question) |
也就是我自己控制每一步:
1 | 先检索 |
LangChain 版本里,这些步骤没有消失,只是被整理成了一条可以组合的链:
1 | question |
每一环大概对应:
| 环节 | 作用 |
|---|---|
| question | 用户原始问题 |
| BlogHybridRetriever | 根据问题检索博客片段 |
| format_documents_for_prompt | 把 Document 列表整理成 Prompt 里的上下文文本 |
| ChatPromptTemplate | 把问题和上下文填进提示词模板 |
| ChatOpenAI | 调用 OpenAI 兼容接口,让模型生成回答 |
| StrOutputParser | 把模型返回结果解析成普通字符串 |
| answer | 最终回答 |
所以 LCEL 不是又发明了一套新的 RAG 逻辑。
它更像是把原来这些零散函数调用,整理成一个固定流程。
最终使用时,不需要在业务代码里一层层手动调用:
1 | answer = chain.invoke(question) |
调用 .invoke(question) 后,链内部会自动按顺序执行:
1 | 检索资料 |
LangChain 的价值不是替我“发明 RAG”,而是把 RAG 里常见的步骤变成标准组件。
手写版本更适合学习底层过程,LangChain 版本更适合把流程整理成可组合、可替换、可继续扩展的链。
5.5 多轮追问改写
最后,把第四阶段的规则版追问改写升级了一下。
这个问题来自一个很真实的场景:
1 | 第一轮:为什么用户会突然掉线? |
第三轮里的“第一个问题”其实指的是第一轮的“用户突然掉线”。
但如果直接拿第三轮这句话去检索,问题就很模糊:
1 | 那第一个问题最后是怎么解决的? |
这句话本身没有 用户掉线、Token 刷新、并发 Bug 这些关键信息。
Retriever 只看当前问题时,很可能不知道它要找哪篇文章。
所以多轮追问里需要多做一步:问题改写。
也就是把带有上下文指代的问题,改写成一个可以独立检索的问题。
1 | 原始问题:那第一个问题最后是怎么解决的? |
这里有两个概念:
| 名称 | 含义 | 用在哪里 |
|---|---|---|
| question | 用户原始输入的问题 | 最终回答时保留用户表达 |
| retrieval_question | 改写后的检索问题 | 给 Retriever 用来找资料 |
也就是说,用户看到的还是自己的问题。
但系统内部检索时,用的是更完整的 retrieval_question。
5.5.1 为什么不能只看上一轮
一开始做过一个简单规则:
1 | 如果当前问题像是追问,就把上一轮问题拼进去 |
例如:
1 | 上一轮:为什么用户会突然掉线? |
可以改成:
1 | 基于上一轮问题“为什么用户会突然掉线?”,这个问题最后是怎么解决的? |
这对“连续追问同一个话题”是够用的。
但一旦中间插入了别的话题,就不稳了。
例如:
1 | 第 1 轮:为什么用户会突然掉线? |
如果只看上一轮,第三轮就会被错误地理解成:
1 | PicGo 上传失败这个问题最后是怎么解决的? |
但用户说的“第一个问题”,其实是第一轮。
所以后面升级成:把最近 N 轮对话历史一起给 LLM,让它判断当前问题到底指向哪一轮。
如果配置了 API Key,会多调一轮 LLM:
1 | 最近 N 轮 history + 当前问题 |
5.5.2 LLM 改写到底做了什么
还是这个例子:
1 | 第 1 轮:为什么用户会突然掉线? |
LLM 改写时会看到最近的对话历史。
它会判断:
1 | “第一个问题” = 第 1 轮问题 |
然后把第三轮改写成类似:
1 | 用户突然掉线这个问题最后是怎么解决的? |
接着 Retriever 不再拿原始问题去搜,而是拿这个改写后的问题去搜。
这样检索时就重新带上了 用户掉线 这个核心语义,更容易命中 Token 刷新并发 Bug 那篇文章。
这一步多调了一次 LLM,所以它不是“免费”的。
好处是多轮指代更稳,代价是多一次模型调用、一次耗时和一点成本。
5.5.3 没有 API Key 时怎么办
如果没有 API Key,则回退到上一轮规则:
1 | 基于上一轮问题“...”,当前问题 |
这个回退策略比较粗糙,但至少能覆盖最常见的连续追问:
1 | 第一轮:为什么用户会突然掉线? |
它的问题也很明显:
1 | 只能理解“上一轮” |
所以后面真正想把多轮对话做好,还是需要 LLM 改写或者更完整的对话状态管理。
5.5.4 Langfuse 里怎么看
Langfuse 里也记录了这些字段:
questionretrieval_questionchat_historyquestion_rewrite_strategy
它们的作用分别是:
| 字段 | 含义 |
|---|---|
| question | 用户这轮原始问题 |
| retrieval_question | 实际拿去检索的问题 |
| chat_history | 最近几轮对话历史 |
| question_rewrite_strategy | 这次用了 LLM 改写,还是规则回退 |
这样就能看清楚:
1 | 用户原话是什么? |
多轮 RAG 的关键不是简单地“记住聊天记录”,而是要把当前追问还原成一个适合检索的独立问题。
回答可以面向用户原问题,但检索最好使用信息更完整的 retrieval_question。
6. 阶段性总结
这五个阶段做下来,最大的感受是:RAG 不是一个单点技术,而是一条链路。
1 | 文档读取 |
每一步都会影响最终效果。
一开始我以为 RAG 的关键是“大模型够不够强”,但实际做下来发现,很多问题都出在模型之前:
- chunk 切得太碎,模型看不到完整上下文;
- 检索结果混进弱相关来源,回答会被带偏;
- 没有 eval,就只能靠感觉判断好坏;
- 没有 Langfuse,就很难解释模型到底看到了什么;
- 多轮追问不改写,检索问题本身就是残缺的。
RAG 的核心不是“把资料塞给模型”,而是稳定地把正确资料、以合适粒度、在可观测可评估的链路里交给模型。


