按源码顺序带你精读 rag_service.py

4 阅读15分钟

按源码顺序带你精读 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_for
  • asyncio.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 引擎

这个类的核心任务就是:

  1. 准备检索环境
  2. 检索文档
  3. 对文档进行加工
  4. 用模型生成总结

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 的模板对象。

作用是:

后面执行时可以传入变量,比如 inputcontext

也就是说,这个 prompt 不是固定句子,而是“可填充变量的模板”。

4.5 self.chat_model = chat_model

这里拿到了聊天模型。

注意:

  • RAG 虽然重点在“检索”
  • 但最后一定还需要“生成”

所以必须有一个聊天模型负责最终回答。

4.6 self.chain = self._init_chain()

这里提前把“总结链”准备好了。

也就是把下面几步串起来:

  1. Prompt 模板
  2. 聊天模型
  3. 输出解析器

这会在后面反复使用。

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 这一段在表达什么

意思是:

  1. 把输入变量填进 prompt
  2. 把完整 prompt 交给聊天模型
  3. 把模型结果解析成字符串

这就是一个最基础的 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 执行顺序是什么

这里的顺序非常清晰:

  1. 确保 retriever 已经准备好
  2. 先做 HyDE,得到假设性文档
  3. 用假设性文档去检索
  4. 返回检索结果

8.2 为什么不是直接 retriever.ainvoke(query)

因为作者想提升召回效果,所以在 query 和 retriever 之间加了一层 HyDE。

所以你要把这段理解成:

原始问题
-> 假设性答案
-> 用假设性答案检索

而不是:

原始问题
-> 直接检索

8.3 返回的 documents 是什么

这里返回的一般不是纯字符串,而是 Document 对象列表。

这些对象里通常会有:

  • page_content
  • metadata

后面真正要送给模型总结的,是里面的 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 这几步在干什么

先按执行顺序翻译成人话:

  1. 去知识库里检索相关文档
  2. Document 对象提取成纯文本内容
  3. 对这些文本做 rerank
  4. 如果最后没有任何文档,就直接返回“没找到”

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 这里在做什么

这一步本质上是:

对多个“局部摘要”再做一次全局综合

你可以把它理解成两阶段总结:

  1. 文档级总结
  2. 全局级总结

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 为什么这对学习也有帮助

因为它相当于把最小调用路径直接写给你看了:

  1. 创建 RagService
  2. 初始化检索器
  3. 调用 rag_summary

对初学者来说,这是非常友好的“最短使用示例”。


18. 把整份文件按执行流程再串一遍

如果用户发来一个问题,这份文件里的执行顺序大致是:

  1. 创建 RagService
  2. 初始化向量库服务、prompt、模型、总结链、HyDE 模板
  3. 调用 get_documents_and_summary
  4. 进入 retrieve_document
  5. 如果还没 retriever,就先初始化
  6. generate_hypothetical_document 生成假设性文档
  7. 用假设性文档去检索知识库
  8. 提取检索到的文档文本
  9. reorder_documents 重新排序
  10. 取前 3 个文档并发生成局部摘要
  11. 如果只有一个摘要,直接返回
  12. 如果有多个摘要,再做一次全局综合总结
  13. 返回 {documents, summary}
  14. 如果外部调用的是 rag_summary,就只取 summary

这 14 步就是这份文件的主线。


19. 这份文件最值得你学的 5 个点

19.1 用服务类封装一整条 RAG 链路

这样逻辑集中,职责明确。

19.2 用 HyDE 提升召回质量

这说明模型不只是负责生成,也能帮助检索。

19.3 用 rerank 进一步筛文档

这是对检索结果质量的二次优化。

19.4 用“先局部总结,再全局总结”的两阶段策略

这比粗暴一次性塞所有长文档更工程化。

19.5 全程做异常和超时兜底

这体现了服务端代码的稳定性意识。


20. 你接下来该怎么继续看

如果你已经看懂这篇,下一步最适合配套去看:

  1. backend/app/rag/vector_store.py 因为它负责这份文件依赖的检索底层
  2. backend/app/rag/reorder_service.py 因为它负责这里调用的重排序
  3. backend/app/prompt/rag_summarize.txt 因为它会直接影响摘要生成效果

21. 最后一句话总结

rag_service.py 的本质不是“简单调一次模型”,而是:

把 HyDE 检索、混合知识召回、文档重排序、分阶段摘要和超时兜底整合在一起,形成了一条比较完整、比较工程化的 RAG 执行链。