按源码顺序带你精读 rag_service.py
1. 先说这份文件在项目里的位置
backend/app/rag/rag_service.py 是这个项目里最核心的 RAG 文件之一。
如果你用一句话来理解它,可以这样说:
这个文件负责把“检索文档、重排序文档、总结文档、生成最终回答”这几件事串成一条完整的 RAG 链路。
它不是:
- 路由入口
- 向量库底层实现
- 模型定义文件
它更像是:
RAG 的总指挥
所以看这份文件时,建议你不要把它当成“算法细节集合”,而要把它看成一条执行流程。
2. 先看文件顶部导入
源码开头是这些导入:
import asyncio
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from app.rag.vector_store import VectorStoreService
from app.rag.reorder_service import reorder_service
from app.utils.factory import chat_model
from app.utils.prompt_loader import load_prompt
from app.core.logger_handler import logger
这几行其实已经暴露了整个文件的核心依赖:
2.1 asyncio
说明这份文件里会有异步逻辑,而且很可能有:
- 超时控制
- 并发执行
后面你果然会看到:
asyncio.wait_forasyncio.gather
2.2 PromptTemplate
说明它会用模板构造 prompt。
也就是说,这个文件不是直接把用户问题扔给模型,而是:
先把问题和上下文按模板组织,再送给模型
2.3 StrOutputParser
说明模型输出会经过一层解析。
虽然这里解析得很简单,只是变成字符串,但这体现了 LangChain 的标准链式写法。
2.4 VectorStoreService
说明真正的知识库检索底层不在这个文件里,而在:
vector_store.py
这里是调用它,不是重写它。
2.5 reorder_service
说明作者不仅检索文档,还做了 rerank。
这很关键,因为它意味着这个 RAG 不只是“查一下”,还会“再筛一次”。
2.6 chat_model
说明本文件自己不负责初始化模型,而是直接拿统一工厂里创建好的聊天模型来用。
这是很好的工程做法。
2.7 load_prompt
说明 Prompt 不是写死在代码中的,而是从外部模板文件加载。
这意味着:
- 更容易改 Prompt
- 更容易做配置化管理
3. 正式进入 RagService 类
接下来看到:
class RagService:
这说明作者把整套 RAG 流程封装进了一个服务类。
你可以把它理解成:
每创建一个
RagService实例,就得到了一套可运行的 RAG 引擎
这个类的核心任务就是:
- 准备检索环境
- 检索文档
- 对文档进行加工
- 用模型生成总结
4. 精读 __init__
源码:
def __init__(self):
self.vector_store = VectorStoreService()
self.retriever = None # 延迟初始化
self.prompt_text = load_prompt(prompt_type="rag_summary_prompt")
self.prompt_template = PromptTemplate.from_template(self.prompt_text)
self.chat_model = chat_model
self.chain = self._init_chain()
self.hyde_prompt_template = PromptTemplate.from_template("基于以下问题,生成一个详细的假设性回答,我会根据你的这个假设性回答在向量数据库里检索文档:\n\n问题:{query}\n\n假设性回答:")
这一段信息量非常大。
我们拆开看。
4.1 self.vector_store = VectorStoreService()
这一步是在准备知识库底层能力。
你可以理解成:
RAG 服务一启动,就先拿到一个“会读知识库、会建 retriever、会访问向量库”的助手
这个助手就是 VectorStoreService。
也就是说,rag_service.py 自己不去操作 Chroma、BM25、文件加载这些底层细节,而是交给 vector_store.py。
这是一种典型的职责分离。
4.2 self.retriever = None
这里很重要。
作者没有在初始化时立刻创建 retriever,而是先设为 None。
注释写的是:
延迟初始化
这是什么意思?
意思是:
- 类对象先创建
- 真正需要检索时,再去初始化 retriever
这样做的好处通常有:
- 避免对象创建时就做重操作
- 更节省资源
- 可以根据当前 query 动态决定检索权重
4.3 self.prompt_text = load_prompt(...)
这里是在加载 RAG 用的 prompt 模板。
说明这个系统在做 RAG 总结时,不是随便问模型一句“帮我总结一下”,而是有专门设计的提示词。
这在 AI 项目里非常重要。
4.4 self.prompt_template = PromptTemplate.from_template(...)
这里把 prompt 文本包装成 LangChain 的模板对象。
作用是:
后面执行时可以传入变量,比如
input和context
也就是说,这个 prompt 不是固定句子,而是“可填充变量的模板”。
4.5 self.chat_model = chat_model
这里拿到了聊天模型。
注意:
- RAG 虽然重点在“检索”
- 但最后一定还需要“生成”
所以必须有一个聊天模型负责最终回答。
4.6 self.chain = self._init_chain()
这里提前把“总结链”准备好了。
也就是把下面几步串起来:
- Prompt 模板
- 聊天模型
- 输出解析器
这会在后面反复使用。
4.7 self.hyde_prompt_template = ...
这里非常关键,它说明作者在这个 RAG 里加入了:
HyDE
也就是先让模型生成一个“假设性回答”,再拿这个回答去检索。
这个设计说明项目已经不是最基础的 RAG,而是做了检索增强优化。
5. 精读 initialize_retriever
源码:
async def initialize_retriever(self, query: str = None):
if self.retriever is None:
self.retriever = await self.vector_store.get_retriever(query)
这段代码不长,但很关键。
5.1 它在做什么
它的作用就是:
如果当前还没有 retriever,就向
VectorStoreService要一个
5.2 为什么要传 query
因为这个项目的 vector_store.py 支持:
- 根据 query 动态调整混合检索权重
所以 query 不只是“搜索内容”,它还会影响:
- 向量检索权重
- BM25 权重
5.3 为什么只初始化一次
因为如果一个 RagService 实例里已经有 retriever,就没必要反复构建。
这有利于减少开销。
不过你也要注意一个工程点:
retriever 一旦初始化后,后续 query 的动态权重不一定会重新生效
也就是说,这种“只初始化一次”的策略有性能优势,但也可能让 query 级动态配置变弱。
这是你以后可以进一步思考的地方。
6. 精读 _init_chain
源码:
def _init_chain(self):
chain = (
self.prompt_template
| self.chat_model
| StrOutputParser()
)
return chain
这是 LangChain 的经典写法。
6.1 这一段在表达什么
意思是:
- 把输入变量填进 prompt
- 把完整 prompt 交给聊天模型
- 把模型结果解析成字符串
这就是一个最基础的 LLM Chain。
6.2 为什么这里提前建好 chain
因为后面会反复用它做:
- 单文档摘要
- 多文档汇总摘要
提前封装好后,代码更清晰。
6.3 你要怎样理解 | 写法
可以把它翻译成人话:
上一步的输出,喂给下一步
也就是一条流水线。
7. 精读 generate_hypothetical_document
源码核心:
hyde_chain = (
self.hyde_prompt_template
| self.chat_model
| StrOutputParser()
)
hypothetical_doc = await hyde_chain.ainvoke({"query": query})
7.1 这段代码在干什么
它先临时构造了一个专门用于 HyDE 的 chain,然后让模型根据 query 生成一段假设性文档。
例如用户问:
小户型适合什么扫地机器人?
模型可能会先写出一段“看起来像答案”的内容,里面包含:
- 小户型
- 避障能力
- 吸力
- 噪音
- 续航
- 集尘能力
这段文本通常比原始 query 更丰富。
7.2 为什么这有用
因为用户问题往往很短。
短问题直接检索时,可能召回不够稳定。
而用假设性答案检索,相当于把查询扩展了。
这本质上是一种:
用模型帮助检索
7.3 异常处理为什么返回 query
这里如果 HyDE 失败,会直接返回原 query。
这是一个很实用的降级策略:
- 最差也还能继续检索
- 不会因为 HyDE 失败让整个 RAG 完全不可用
这属于工程里很重要的“兜底思维”。
8. 精读 retrieve_document
源码关键逻辑:
if self.retriever is None:
await self.initialize_retriever(query)
hypothetical_doc = await self.generate_hypothetical_document(query)
documents = await self.retriever.ainvoke(hypothetical_doc)
8.1 执行顺序是什么
这里的顺序非常清晰:
- 确保 retriever 已经准备好
- 先做 HyDE,得到假设性文档
- 用假设性文档去检索
- 返回检索结果
8.2 为什么不是直接 retriever.ainvoke(query)
因为作者想提升召回效果,所以在 query 和 retriever 之间加了一层 HyDE。
所以你要把这段理解成:
原始问题
-> 假设性答案
-> 用假设性答案检索
而不是:
原始问题
-> 直接检索
8.3 返回的 documents 是什么
这里返回的一般不是纯字符串,而是 Document 对象列表。
这些对象里通常会有:
page_contentmetadata
后面真正要送给模型总结的,是里面的 page_content。
8.4 为什么异常时返回空列表
这同样是兜底策略。
它保证即使检索出问题,上层逻辑也能继续处理,而不是直接把整个服务打崩。
9. 精读 reorder_documents
源码:
result = await reorder_service.reorder_documents(query, documents)
if result["success"]:
reordered_documents = [doc.get("document", "") for doc in result["documents"]]
return reordered_documents
else:
return documents
9.1 它的任务很纯粹
就是:
把检索回来的文档重新排序
9.2 为什么这里只返回文档文本,不返回分数
因为这个方法的下游需求是:
- 拿文档内容去总结
而不是展示 rerank 打分。
所以它把结果从:
- 含相似度的结构化对象列表
变成了:
- 纯文本列表
这是为后续步骤做的数据简化。
9.3 为什么失败时直接返回原文档
这也是工程兜底。
它意味着:
- rerank 是增强项
- 不是绝对依赖项
即使重排序失败,系统仍然可以继续走“原顺序总结”。
这能显著提高系统稳定性。
10. 精读 get_documents_and_summary 前半段
这是整份文件最核心的方法。
源码前半段:
documents = await self.retrieve_document(query)
document_contents = [doc.page_content for doc in documents]
reordered_documents = await self.reorder_documents(query, document_contents)
if not reordered_documents:
return {
"documents": [],
"summary": "抱歉,我没有找到相关的信息。"
}
10.1 这几步在干什么
先按执行顺序翻译成人话:
- 去知识库里检索相关文档
- 把
Document对象提取成纯文本内容 - 对这些文本做 rerank
- 如果最后没有任何文档,就直接返回“没找到”
10.2 为什么这里先提取 page_content
因为真正要送给模型总结的是文本,不是文档对象本身。
所以从这一刻开始,流程重点从:
- “检索对象处理”
切换成:
- “文本摘要处理”
10.3 为什么空结果直接返回
因为如果没有资料,就不应该强行让模型乱答。
这也是一个很好的 AI 工程习惯:
没检索到,就明确说没找到,而不是硬生成一个看起来很像真的答案
11. 精读 get_documents_and_summary 中段:单文档摘要
源码核心:
max_documents = 3
async def summarize_document(i, doc):
single_context = f"【参考资料{i}】:{doc}\n"
single_summary = await asyncio.wait_for(
self.chain.ainvoke({"input": query, "context": single_context}),
timeout=30.0
)
return single_summary
11.1 为什么只取前 3 个文档
这里有个非常重要的工程权衡:
- 文档越多,信息越丰富
- 但上下文越长、成本越高、干扰越大
所以作者选择了:
只处理最相关的前 3 个文档
这通常是一个比较实际的折中方案。
11.2 为什么不是把 3 个文档一次性塞进去
作者采用的是:
先逐个总结,再最后汇总
这样做的好处包括:
- 每次输入更短
- 更容易控制 token
- 每个文档先形成局部结论
- 最后再综合,减少长上下文互相污染
11.3 single_context 是什么
这是给模型的单文档上下文。
可以理解成:
现在先只看第 i 份参考资料,基于它回答问题
所以这里不是最终答案,而是:
- 面向单文档的局部摘要
11.4 为什么用了 asyncio.wait_for
这是超时控制。
因为调用模型可能慢、可能卡住。
如果不加超时,整个请求可能被长时间拖死。
30 秒超时意味着:
- 单文档总结不能无限等
- 超时后可以优雅失败
这属于非常典型的服务端保护措施。
12. 精读 get_documents_and_summary 中段:并发总结
源码:
tasks = []
for i, doc in enumerate(reordered_documents[:max_documents], 1):
tasks.append(summarize_document(i, doc))
individual_summaries = await asyncio.gather(*tasks)
12.1 这段的核心思想
不是:
- 先总结第 1 个
- 再总结第 2 个
- 再总结第 3 个
而是:
3 个摘要任务并发跑
12.2 为什么要并发
因为每个总结任务本质上都是远程模型调用,等待时间较多。
如果串行执行,耗时可能是:
T1 + T2 + T3
并发执行后,耗时更接近:
max(T1, T2, T3)
这会明显提升响应速度。
12.3 注释里写“线程池并发”严不严谨
严格说这里其实是:
- 协程并发
不是传统线程池。
但对初学者来说,你可以先理解成:
同时发起多个异步任务,而不是一个一个排队
13. 精读 get_documents_and_summary 后半段:单文档特判
源码:
if len(individual_summaries) == 1:
return {
"documents": reordered_documents,
"summary": individual_summaries[0]
}
13.1 为什么要单独处理只有一个摘要的情况
因为如果只有一个文档:
- 再做“摘要的摘要”没有太大意义
- 还会增加一次模型调用
- 可能让内容反而变形
所以这里直接返回第一个摘要,是很合理的。
这是一种:
简化路径优化
14. 精读 get_documents_and_summary 后半段:多摘要合并
源码核心:
combined_context = "以下是多个文档的摘要,请综合这些信息生成最终的回答:\n\n"
for i, summary in enumerate(individual_summaries, 1):
combined_context += f"【文档{i}摘要】:{summary}\n\n"
final_summary = await asyncio.wait_for(
self.chain.ainvoke({"input": query, "context": combined_context}),
timeout=30.0
)
14.1 这里在做什么
这一步本质上是:
对多个“局部摘要”再做一次全局综合
你可以把它理解成两阶段总结:
- 文档级总结
- 全局级总结
14.2 为什么这种方式有效
因为它避免了直接把多个长文档一次性塞给模型。
相反,它先把长文档压缩成摘要,再做最终融合。
优点通常包括:
- 更节省 token
- 更利于控制输入长度
- 更容易突出重点
14.3 为什么最终总结也要加超时
因为最后一步依然是模型调用。
所以同样需要:
- 防止超时
- 防止服务阻塞
这说明作者在整条 RAG 链路里对“外部模型调用风险”是有意识的。
15. 精读异常处理
在 get_documents_and_summary 里有两层异常处理:
15.1 内层:asyncio.TimeoutError
这层主要处理:
- 文档总结超时
- 最终总结超时
超时时不会让整个服务崩,而是返回:
- 已有文档
- “生成摘要超时”的提示
15.2 外层:总异常处理
如果整条链路中有其他异常,会返回:
- 空文档
- “处理请求时出现错误”
这种设计说明作者想让接口具备:
- 可控失败
- 对用户友好
- 对上层接口稳定
这在生产系统里很重要。
16. 精读 rag_summary
源码:
async def rag_summary(self, query: str) -> str:
result = await self.get_documents_and_summary(query)
return result.get("summary", "抱歉,处理您的请求时出现了错误。")
16.1 为什么还要包一层
因为有的调用方不关心:
- 检索到了哪些文档
它只关心:
- 最终摘要结果
于是作者又提供了一个简化入口:
你给我 query,我直接给你 summary
16.2 这个方法一般给谁用
典型是:
- 路由层
- Agent 工具层
当外部只想要最终回答时,这个方法就很方便。
17. 文件底部的 __main__ 是什么
源码:
if __name__ == '__main__':
async def main():
service = RagService()
await service.initialize_retriever()
result = await service.rag_summary("小户型适合什么扫地机器人")
print(result)
17.1 这段的作用
这是一个简单的本地调试入口。
它允许开发者直接运行这个文件,快速验证:
- retriever 能不能初始化
- RAG 能不能跑通
- 某个问题能不能出结果
17.2 为什么这对学习也有帮助
因为它相当于把最小调用路径直接写给你看了:
- 创建
RagService - 初始化检索器
- 调用
rag_summary
对初学者来说,这是非常友好的“最短使用示例”。
18. 把整份文件按执行流程再串一遍
如果用户发来一个问题,这份文件里的执行顺序大致是:
- 创建
RagService - 初始化向量库服务、prompt、模型、总结链、HyDE 模板
- 调用
get_documents_and_summary - 进入
retrieve_document - 如果还没 retriever,就先初始化
- 用
generate_hypothetical_document生成假设性文档 - 用假设性文档去检索知识库
- 提取检索到的文档文本
- 用
reorder_documents重新排序 - 取前 3 个文档并发生成局部摘要
- 如果只有一个摘要,直接返回
- 如果有多个摘要,再做一次全局综合总结
- 返回
{documents, summary} - 如果外部调用的是
rag_summary,就只取summary
这 14 步就是这份文件的主线。
19. 这份文件最值得你学的 5 个点
19.1 用服务类封装一整条 RAG 链路
这样逻辑集中,职责明确。
19.2 用 HyDE 提升召回质量
这说明模型不只是负责生成,也能帮助检索。
19.3 用 rerank 进一步筛文档
这是对检索结果质量的二次优化。
19.4 用“先局部总结,再全局总结”的两阶段策略
这比粗暴一次性塞所有长文档更工程化。
19.5 全程做异常和超时兜底
这体现了服务端代码的稳定性意识。
20. 你接下来该怎么继续看
如果你已经看懂这篇,下一步最适合配套去看:
backend/app/rag/vector_store.py因为它负责这份文件依赖的检索底层backend/app/rag/reorder_service.py因为它负责这里调用的重排序backend/app/prompt/rag_summarize.txt因为它会直接影响摘要生成效果
21. 最后一句话总结
rag_service.py 的本质不是“简单调一次模型”,而是:
把 HyDE 检索、混合知识召回、文档重排序、分阶段摘要和超时兜底整合在一起,形成了一条比较完整、比较工程化的 RAG 执行链。