一条 RAG 请求端到端走一遍
本章把前两章串起来:用第 0 页那个最小 RAG 管道,看数据怎么从「一句问 题」流到「一句答案」。RAG = Retrieval-Augmented Generation(检索增强生成)。
1. 三个主角
| 组件 | 输入 | 输出 | 在哪 |
|---|---|---|---|
InMemoryBM25Retriever | query: str | documents: list[Document] | haystack/components/retrievers/in_memory/ |
ChatPromptBuilder | documents, question… | prompt: list[ChatMessage] | haystack/components/builders/chat_prompt_builder.py |
OpenAIChatGenerator | messages: 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 文档长什么样
流动的基本单位是 Document(dataclasses/document.py:48):
# 真实字段,见 document.py:64-70
id: str # 不给就按内容自动算哈希
content: str | None # 文本
meta: dict # 任意元数据
score: float | None # 检索打分,检索器填
embedding: list[float] | None # 向量
id 不指定时由 _create_id(document.py:108)按字段值算哈希——所以内容相同的 文档天然去重。
2.2 BM25 检索(关键词)
InMemoryDocumentStore(document_stores/in_memory/document_store.py:59)内置三种 BM25 算法,默认 BM25L(document_store.py:67)。BM25(一种经典的「词频 × 逆文档频率」打分关键词检索算法)的打分在 _score_bm25l(document_store.py:193),核心两步:
_compute_idf:词在越少文档里出现,权重越高(document_store.py:209);_compute_tf:词在本文档里出现越多分越高,但有饱和(document_store.py:218),用了 BM25L 的delta平移项缓解长文档惩罚。
检索入口 bm25_retrieval(document_store.py:654):tokenize 查询 → 对候选文档打分 → 取 top_k。scale_score 可把分数压到 0~1。
巧妙细节:
negatives_are_valid = self.bm25_algorithm == "BM25Okapi" and not scale_score(document_store.py:698)——只有 BM25Okapi 会产生有意义的负分,所以只在它身上保留负分文档。
2.3 向量检索(语义)
换用 embedding 检索时走 embedding_retrieval(document_store.py:721):先按 filters 过滤,只保留有 embedding 的文档(没有就 warning 返回空,document_store.py:754-759),再用 _compute_query_embedding_similarity_scores 算相似度(默认 dot_product,可选 cosine,见 __init__ 的 embedding_similarity_function,document_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.run 吃 messages: list[ChatMessage],吐 replies: list[ChatMessage]。
ChatMessage(dataclasses/chat_message.py:269)是与厂商无关的统一消息格式:
- 角色:
ChatRole(chat_message.py:19)—— system / user / assistant / tool; - 用
from_user/from_system/from_assistant/from_tool构造(chat_message.py:414+); - 内容可以是文本、图片、工具调用(
ToolCall)、工具结果(ToolCallResult)、推理(ReasoningContent)——见各@property(chat_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。