跳到主要内容

一条 RAG 请求端到端走一遍

本章把前两章串起来:用第 0 页那个最小 RAG 管道,看数据怎么从「一句问题」流到「一句答案」。RAG = Retrieval-Augmented Generation(检索增强生成)。

1. 三个主角

组件输入输出在哪
InMemoryBM25Retrieverquery: strdocuments: list[Document]haystack/components/retrievers/in_memory/
ChatPromptBuilderdocuments, questionprompt: list[ChatMessage]haystack/components/builders/chat_prompt_builder.py
OpenAIChatGeneratormessages: list[ChatMessage]replies: list[ChatMessage]haystack/components/generators/chat/openai.py

数据流(怎么读:左到右,箭头上是流动的数据类型):

query(str) ─▶ Retriever ─list[Document]─▶ PromptBuilder ─list[ChatMessage]─▶ LLM ─list[ChatMessage]─▶ 答案

question(str) 从管道外直接喂进来

2. 第一步:检索

2.1 文档长什么样

流动的基本单位是 Documentdataclasses/document.py:48):

# 真实字段,见 document.py:64-70
id: str # 不给就按内容自动算哈希
content: str | None # 文本
meta: dict # 任意元数据
score: float | None # 检索打分,检索器填
embedding: list[float] | None # 向量

id 不指定时由 _create_iddocument.py:108)按字段值算哈希——所以内容相同的文档天然去重。

2.2 BM25 检索(关键词)

InMemoryDocumentStoredocument_stores/in_memory/document_store.py:59)内置三种 BM25 算法,默认 BM25Ldocument_store.py:67)。BM25(一种经典的「词频 × 逆文档频率」打分关键词检索算法)的打分在 _score_bm25ldocument_store.py:193),核心两步:

  • _compute_idf:词在越少文档里出现,权重越高(document_store.py:209);
  • _compute_tf:词在本文档里出现越多分越高,但有饱和(document_store.py:218),用了 BM25L 的 delta 平移项缓解长文档惩罚。

检索入口 bm25_retrievaldocument_store.py:654):tokenize 查询 → 对候选文档打分 → 取 top_k。scale_score 可把分数压到 0~1。

巧妙细节:negatives_are_valid = self.bm25_algorithm == "BM25Okapi" and not scale_scoredocument_store.py:698)——只有 BM25Okapi 会产生有意义的负分,所以只在它身上保留负分文档。

2.3 向量检索(语义)

换用 embedding 检索时走 embedding_retrievaldocument_store.py:721):先按 filters 过滤,只保留有 embedding 的文档(没有就 warning 返回空,document_store.py:754-759),再用 _compute_query_embedding_similarity_scores 算相似度(默认 dot_product,可选 cosine,见 __init__embedding_similarity_functiondocument_store.py:69),最后按分排序取 top_k(document_store.py:773)。

这就是 RAG 的两种典型检索:关键词(BM25)和语义(向量),两者可以用 DocumentJoiner 合并成「混合检索」(hybrid,靠第 1 章的 lazy variadic socket 收多路结果)。

3. 第二步:拼 prompt

ChatPromptBuilder 接收检索出的 documents 和外部喂进来的 question,用 Jinja2 模板渲染成一条 ChatMessage。模板长这样(取自 Pipeline.run docstring,pipeline.py:147-156):

Given these documents, answer the question.
Documents:
{% for doc in documents %}
{{ doc.content }}
{% endfor %}
Question: {{question}}
Answer:

注意 question 是从管道外直接喂进来的(run({"prompt_builder": {"question": ...}}))——在第 1 章的术语里,这是「用户输入」触发,且因为 documents 这个口要等检索器,PromptBuilder 会先 BLOCKED,等检索完才变 READY

4. 第三步:调 LLM

OpenAIChatGenerator.runmessages: list[ChatMessage],吐 replies: list[ChatMessage]

ChatMessagedataclasses/chat_message.py:269)是与厂商无关的统一消息格式:

  • 角色:ChatRolechat_message.py:19)—— system / user / assistant / tool;
  • from_user / from_system / from_assistant / from_tool 构造(chat_message.py:414+);
  • 内容可以是文本、图片、工具调用(ToolCall)、工具结果(ToolCallResult)、推理(ReasoningContent)——见各 @propertychat_message.py:307-394)。

这个统一格式是 Haystack「模型无关」的关键:不管底层是 OpenAI 还是 Anthropic,组件之间流的都是 ChatMessage。它也是下一章 Agent 的核心数据。

5. 收尾

管道返回时只给叶子组件(这里是 llm)的输出,于是 results["llm"]["replies"][0].text 就是答案。想看中间的检索结果,得传 include_outputs_from={"retriever"}(第 1 章 §2.5)。


下一章:当任务需要 LLM 反复调工具时,光靠管道的「循环」不够顺手,于是有了 Agent。见 04-agent-loop.md