深入浅出RAG——第6章:构建第一条 RAG 流水线

165 阅读13分钟

📖 本章学习目标

  • 用 TypeScript 实现完整的 RAG 流水线:加载 → 切分 → 向量化 → 存储 → 检索 → 生成
  • 使用 LangChain 的 Chain 抽象串联多个步骤
  • 对比同一问题在"有 RAG"和"无 RAG"两种模式下的回答质量差异
  • 理解索引管道(离线)和检索-生成管道(在线)的职责分界

在《第2章:文本嵌入 —— 让计算机理解语义的桥梁》、《第3章:向量数据库入门》、《第4章:文档加载》、《第5章:文档切分》几个章节中,我们分别学会了生成文本向量、搭建向量数据库、加载各种格式文档、把长文档切成了合理大小的片段

现在是时候把所有零件组装成一台能真正工作的 RAG 引擎了。本章会走通Load → Split → Embed → Store → Retrieve → Generate 的完整链路。这是整个RAG基础篇最重要的章节,因为从这里开始,你不再是学习孤立的概念,而是将它们整合成一个可用的系统。

一、RAG 经典流水线的全景架构

1. 两个管道的职责分离

RAG流水线分索引管道和检索-生成管道。它们两个职责分离,运行的时机也不一样。

graph TD
    subgraph 索引管道
        A[原始文档] --> B[文档加载]
        B --> C[文档切分]
        C --> D[生成 Embedding]
        D --> E[(向量数据库)]
    end
    
    subgraph 检索-生成管道
        F[用户提问] --> G[生成问题 Embedding]
        G --> H[向量相似度搜索]
        E --> H
        H --> I[组装 Prompt]
        I --> J[LLM 生成答案]
        J --> K[返回答案给用户]
    end

索引管道通常是离线执行的,只在知识库更新时运行(每天几次到每周几次),更注重吞吐量而非延迟;而检索-生成管道是在线运行的,在每次用户提问时运行(每秒可能数百次),注重低延迟和高可用性。这个区分对后续的性能优化(《第17章:RAG 性能优化实战》)至关重要。很多初学者会把两个管道混在一起设计,导致索引任务阻塞查询请求,或者查询时重复生成 Embedding 浪费资源。

2. 数据流向详解

让我们跟踪一条数据从原始文档到最终答案的完整旅程:

索引阶段:

  1. 加载:从文件系统、API 或数据库读取原始文档(PDF、Markdown、CSV 等)
  2. 切分:将长文档按照给定的chunk大小切成小块,保持语义完整性
  3. 向量化:为每个 chunk 生成 Embedding 向量
  4. 存储:将向量、原文、元数据存入向量数据库,建立 HNSW 索引

检索-生成阶段:

  1. 接收查询:用户提出自然语言问题
  2. 向量化查询:为问题生成 Embedding 向量(使用与索引相同的模型)
  3. 相似度搜索:在向量数据库中找到 Top-K 最相关的 chunk
  4. 组装 Prompt:将检索到的 chunk 与用户问题、系统指令拼接成完整 Prompt
  5. 生成答案:LLM 基于 Prompt 生成最终答案
  6. 返回结果:将答案和引用来源返回给用户

二、环境准备

在开始编码之前,确保你已经安装了必要的依赖:

# 初始化项目
npm init -y
npm install langchain @langchain/openai @langchain/community chromadb

# 如果使用 PDF 加载需要额外依赖
npm install pdf-parse

配置环境变量(.env 文件):

OPENAI_API_KEY=sk-your-api-key

加载环境变量:

import dotenv from "dotenv";
dotenv.config();

三、索引管道实现

索引管道是离线的批处理任务,通常在文档首次导入时、文档更新时(通过文件监听或 Git Hook)或定时任务(如每天凌晨重新索引)时触发。

1. 文档加载与切分

import { TextLoader } from "langchain/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { Document } from "@langchain/core/documents";

async function loadAndSplit(filePath: string): Promise<Document[]> {
  console.log(`正在加载文档: ${filePath}`);
  
  // 加载文档
  const loader = new TextLoader(filePath);
  const docs = await loader.load();
  
  console.log(`加载完成,共 ${docs.length} 个文档`);
  
  // 切分文档
  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500,      // 每个 chunk 约 500 字符
    chunkOverlap: 50,    // 相邻 chunk 重叠 50 字符
  });
  
  const chunks = await splitter.splitDocuments(docs);
  
  console.log(`切分完成,共 ${chunks.length} 个片段`);
  console.log(`平均长度: ${(chunks.reduce((sum, c) => sum + c.pageContent.length, 0) / chunks.length).toFixed(0)} 字符`);
  
  return chunks;
}

// 使用示例
const chunks = await loadAndSplit("./knowledge-base/deploy-guide.md");

代码解读:

  1. 加载阶段:TextLoader 读取 Markdown 文件,将其转换为 Document 对象
  2. 切分阶段:RecursiveCharacterTextSplitter 按语义边界切分,优先在段落、句子边界断开
  3. 统计信息:输出 chunk 数量和平均长度,帮助评估切分质量

注意,如果文档很大(> 10MB),考虑分批加载避免内存溢出。你可以并行加载多个文件,提高索引速度(见《第4章:文档加载》中的并行加载示例)。生成chunk的时候,要记录每个 chunk 的 metadata,包括 source、page number 等,方便后续溯源。

2. 向量化与存储

import { OpenAIEmbeddings } from "@langchain/openai";
import { Chroma } from "@langchain/community/vectorstores/chroma";
import { ChromaClient } from "chromadb";

async function indexDocuments(chunks: Document[], collectionName: string) {
  console.log("开始向量化和存储...");
  
  // 初始化 Embedding 模型
  const embeddings = new OpenAIEmbeddings({
    modelName: "text-embedding-3-small",
  });
  
  // 连接 Chroma 客户端
  const client = new ChromaClient({ path: "http://localhost:8000" });
  
  // 检查 Collection 是否已存在,如果存在则删除(重新索引)
  try {
    await client.deleteCollection({ name: collectionName });
    console.log(`已删除旧的 Collection: ${collectionName}`);
  } catch (error) {
    // Collection 不存在,忽略错误
  }
  
  // 批量生成向量并存入 Chroma
  const vectorStore = await Chroma.fromDocuments(
    chunks,
    embeddings,
    {
      collectionName: collectionName,
      url: "http://localhost:8000",
    }
  );
  
  console.log(`索引完成,共存储 ${chunks.length} 个向量`);
  
  return vectorStore;
}

// 使用示例
const vectorStore = await indexDocuments(chunks, "deploy_guide");

代码解读:

  1. Embedding 模型:使用 OpenAI 的 text-embedding-3-small,性价比高
  2. Collection 管理:先删除旧的 Collection(如果存在),确保索引是最新的
  3. 批量操作:Chroma.fromDocuments() 内部做了三件事:逐条调用 Embedding API 生成向量、将向量和原文存入 Chroma、创建 HNSW 索引。

同样,对于大规模数据(> 10,000 chunks),可以考虑分批插入,避免单次请求过大。创建和存储的过程中可以监控 Embedding API 的调用次数和成本,必要时引入缓存,同时记录索引耗时,建立性能基线,方便做性能优化。

// 分批插入示例(适合大规模数据)
async function batchIndexDocuments(
  chunks: Document[],
  embeddings: OpenAIEmbeddings,
  collectionName: string,
  batchSize: number = 100
) {
  const client = new ChromaClient({ path: "http://localhost:8000" });
  
  // 创建 Collection
  const collection = await client.createCollection({ name: collectionName });
  
  console.log(`开始分批索引,共 ${chunks.length} 个 chunk,每批 ${batchSize} 个`);
  
  for (let i = 0; i < chunks.length; i += batchSize) {
    const batch = chunks.slice(i, i + batchSize);
    
    // 生成批次向量
    const texts = batch.map(c => c.pageContent);
    const vectors = await embeddings.embedDocuments(texts);
    
    // 插入到 Chroma
    await collection.add({
      ids: batch.map((_, idx) => `chunk_${i + idx}`),
      embeddings: vectors,
      documents: texts,
      metadatas: batch.map(c => c.metadata),
    });
    
    console.log(`已处理 ${Math.min(i + batchSize, chunks.length)} / ${chunks.length}`);
  }
  
  console.log("索引完成");
}

四、检索-生成管道实现

检索-生成管道是在线的实时服务,通常需要在毫秒级响应。

1. 检索阶段

import { VectorStoreRetriever } from "@langchain/core/vectorstores";

// 将向量数据库包装为 Retriever
const retriever = vectorStore.asRetriever({
  k: 4,  // 返回 Top-4 最相关片段
});

// 执行检索
const query = "预发环境的部署步骤是什么?";
console.log(`正在检索: "${query}"`);

const relevantDocs = await retriever.invoke(query);

console.log(`找到 ${relevantDocs.length} 个相关片段:\n`);
for (const [i, doc] of relevantDocs.entries()) {
  console.log(`[${i + 1}] 来源: ${doc.metadata.source}`);
  console.log(`    内容预览: ${doc.pageContent.slice(0, 100)}...`);
  console.log();
}

代码解读:

  1. Retriever 抽象:asRetriever() 将向量数据库包装为统一的检索接口
  2. k 值选择:返回 Top-4 结果,平衡召回率和噪声
  3. 结果展示:输出每个片段的来源和内容预览,便于调试

其中,k 值设置的大小会影响检索的效果,太小可能遗漏相关信息,太大则引入噪声。典型范围是 3-10,具体需要根据评估确定(见《第12章:RAG 系统评估与指标体系》)。可以结合元数据过滤缩小搜索范围(见《第9章:高级检索策略》)

2. 增强与生成:两种实现方式

检索的下一个阶段就是增强,所谓增强指的是将检索到的相关文档或片段,作为上下文信息添加到用户的原始提问中,从而构建出一个信息更丰富、指向性更明确的“增强提示(Prompt)”。在LangChain体系下,你不仅可以使用手动拼装的方式增强Prompt,也可以使用Chain来实现。

方式一:手动组装 Prompt

适合对 Prompt 格式有精细控制需求的场景。

import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage, SystemMessage } from "@langchain/core/messages";

const llm = new ChatOpenAI({ 
  modelName: "gpt-4o",
  temperature: 0,  // 降低随机性,提高一致性
});

// 手动拼接上下文
const context = relevantDocs
  .map((doc, i) => `[参考资料 ${i + 1}]\n${doc.pageContent}`)
  .join("\n\n");

const systemPrompt = `你是一个企业知识库助手。请根据以下参考资料回答问题。

要求:
1. 严格基于提供的参考资料回答,不要编造信息
2. 如果资料中没有相关信息,请明确说明"根据现有资料无法回答"
3. 在回答中标注引用来源,如"[1]"、"[2]"
4. 回答要简洁清晰,避免冗余`;

const userPrompt = `## 参考资料

${context}

## 问题

${query}`;

// 调用 LLM
const messages = [
  new SystemMessage(systemPrompt),
  new HumanMessage(userPrompt),
];

const response = await llm.invoke(messages);
console.log("答案:", response.content);

这种方式的优点是你完全控制 Prompt 格式和内容,易于调试和优化,也不依赖框架的抽象。缺点则是代码冗长,需要自己处理消息格式,在更换 LLM 等场景时需要手动调整代码。

方式二:使用 LangChain Chain 串联

适合标准化流程,代码更简洁。

import { createStuffDocumentsChain } from "langchain/chains/combine_documents";
import { pull } from "langchain/hub";
import { createRetrievalChain } from "langchain/chains/retrieval";
import { ChatPromptTemplate } from "@langchain/core/prompts";

// 从 LangChain Hub 拉取 RAG Prompt 模板
const promptTemplate = await pull<ChatPromptTemplate>("langchain-ai/retrieval-qa-chat");

// 创建文档组合链(负责将检索结果和问答组合)
const combineDocsChain = await createStuffDocumentsChain({
  llm,
  prompt: promptTemplate,
});

// 串联检索 + 组合 → 完整的 RAG 链
const ragChain = await createRetrievalChain({
  retriever,
  combineDocsChain,
});

// 执行完整流程
const result = await ragChain.invoke({ input: query });

console.log("答案:", result.answer);
console.log("\n引用来源:");
for (const doc of result.context) {
  console.log(`- ${doc.metadata.source}`);
}

这种方式的优点是代码简洁,几行即可完成 RAG 流程,且其内置了最佳实践(如 Prompt 模板、错误处理)。另外这种方式还易于替换组件(换 LLM、换向量数据库等)。缺点也很明显,整个实现是黑盒性质,难以精细控制。而且它还依赖 LangChain Hub 的模板质量,学习曲线较陡。

一般建议在开发阶段或有特殊需求的场景使用手写 Prompt的方式,便于理解和调试。其它的生产环境建议使用用 Chain 抽象,代码更简洁易维护,并且可以使用生态上的一些经过无数验证的解决方案。

3. 封装为可复用的服务

真实的实践中,我们不会到处写检索-生成管道的代码,通常会根据具体的需求,将其封装为一个函数,方便在各个地方调用。

interface RAGResponse {
  answer: string;
  sources: Array<{
    source: string;
    content: string;
  }>;
}

class RAGService {
  private retriever: VectorStoreRetriever;
  private llm: ChatOpenAI;
  
  constructor(vectorStore: Chroma) {
    this.retriever = vectorStore.asRetriever({ k: 4 });
    this.llm = new ChatOpenAI({ 
      modelName: "gpt-4o",
      temperature: 0,
    });
  }
  
  async query(question: string): Promise<RAGResponse> {
    // 检索
    const relevantDocs = await this.retriever.invoke(question);
    
    // 组装 Prompt
    const context = relevantDocs
      .map((doc, i) => `[${i + 1}] ${doc.pageContent}`)
      .join("\n\n");
    
    const prompt = `根据以下参考资料回答问题。如果资料中没有相关信息,请明确说明。

参考资料:
${context}

问题:${question}

回答:`;
    
    // 生成答案
    const response = await this.llm.invoke(prompt);
    
    // 组装返回结果
    return {
      answer: response.content as string,
      sources: relevantDocs.map(doc => ({
        source: doc.metadata.source,
        content: doc.pageContent.slice(0, 200) + "...",
      })),
    };
  }
}

// 使用示例
const ragService = new RAGService(vectorStore);
const result = await ragService.query("预发环境的部署步骤是什么?");

console.log("答案:", result.answer);
console.log("\n来源:");
result.sources.forEach((src, i) => {
  console.log(`${i + 1}. ${src.source}`);
});

五、效果对比:有 RAG vs 无 RAG

为了直观展示 RAG 的价值,我们对比同一个问题在两种模式下的回答:

async function compareRAGvsNoRAG(query: string) {
  console.log(`问题: "${query}"\n`);
  
  // 无 RAG:直接问 LLM
  console.log("=== 无 RAG(纯 LLM)===");
  const noRAGAnswer = await llm.invoke(query);
  console.log(noRAGAnswer.content);
  
  // 有 RAG:走完整流水线
  console.log("=== 有 RAG(检索增强)===");
  const ragResult = await ragChain.invoke({ input: query });
  console.log(ragResult.answer);

  
  // 显示引用来源
  console.log("=== 引用来源 ===");
  for (const [i, doc] of ragResult.context.entries()) {
    console.log(`[${i + 1}] ${doc.metadata.source}`);
    console.log(`    ${doc.pageContent.slice(0, 150)}...`);
  }
}

// 测试
await compareRAGvsNoRAG("预发环境的部署步骤是什么?");

结果如下:

问题: "预发环境的部署步骤是什么?"

=== 无 RAG(纯 LLM)===
抱歉,我无法访问您公司的内部部署流程信息。一般来说,预发环境的部署可能包括以下步骤:
1. 代码审查和合并
2. 构建和测试
3. 部署到预发服务器
...

=== 有 RAG(检索增强)===
根据部署文档,预发环境的部署步骤如下:

1. 代码审查:确保所有代码已通过 Code Review 并合并到 develop 分支 [1]
2. 构建镜像:使用 Docker 构建应用镜像,标签为 staging-{commit_hash} [1]
3. 部署到预发环境:通过 Kubernetes 部署到 staging namespace [2]
4. 验证:访问 xxx.xxx.xx 进行功能验证 [2]
5. 通知团队:在 Slack #deployments 频道发送部署通知 [3]

=== 引用来源 ===
[1] docs/deploy-guide.md
    部署流程分为代码审查、构建、部署三个主要阶段...
[2] docs/staging-env.md
    预发环境地址为xxx.xxxx.xxx,需要 VPN 访问...
[3] docs/team-workflow.md
    所有部署完成后需要在 Slack 频道通知团队...

从对比中可以真实的感受到如下差异:

维度无 RAG有 RAG
准确性通用回答,可能不适用于你的场景基于实际文档,准确可靠
可溯源性无法追溯信息来源每条信息都有明确的来源标注
幻觉风险高,可能编造不存在的步骤低,受检索内容约束
时效性依赖训练数据,可能过时实时更新,反映最新文档

对于"预发环境的部署步骤是什么"这类私有知识问题,无 RAG 的 LLM 只能编造通用答案甚至直接承认不知道;有 RAG 的版本能从你的部署文档中提取准确的步骤,并标注信息来源。

六、完整的端到端示例

将索引和检索-生成两个管道整合到一个完整的脚本中:

import { TextLoader } from "langchain/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { OpenAIEmbeddings } from "@langchain/openai";
import { Chroma } from "@langchain/community/vectorstores/chroma";
import { ChromaClient } from "chromadb";
import { createStuffDocumentsChain } from "langchain/chains/combine_documents";
import { pull } from "langchain/hub";
import { createRetrievalChain } from "langchain/chains/retrieval";
import { ChatOpenAI } from "@langchain/openai";

async function main() {
  console.log("=== RAG 系统演示 ===\n");
  
  // ========== 索引管道 ==========
  console.log("【阶段 1】索引管道\n");
  
  const filePath = "./knowledge-base/deploy-guide.md";
  const collectionName = "deploy_guide";
  
  // 加载和切分
  const loader = new TextLoader(filePath);
  const docs = await loader.load();
  
  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500,
    chunkOverlap: 50,
  });
  const chunks = await splitter.splitDocuments(docs);
  
  console.log(`✓ 文档已切分为 ${chunks.length} 个片段\n`);
  
  // 向量化和存储
  const embeddings = new OpenAIEmbeddings({
    modelName: "text-embedding-3-small",
  });
  
  const client = new ChromaClient({ path: "http://localhost:8000" });
  
  // 删除旧 Collection
  try {
    await client.deleteCollection({ name: collectionName });
  } catch (error) {
    // 忽略
  }
  
  const vectorStore = await Chroma.fromDocuments(
    chunks,
    embeddings,
    { collectionName, url: "http://localhost:8000" }
  );
  
  console.log(`✓ 索引完成,已存储 ${chunks.length} 个向量\n`);
  
  // ========== 检索-生成管道 ==========
  console.log("【阶段 2】检索-生成管道\n");
  
  const llm = new ChatOpenAI({ modelName: "gpt-4o", temperature: 0 });
  const retriever = vectorStore.asRetriever({ k: 4 });
  
  const promptTemplate = await pull("langchain-ai/retrieval-qa-chat");
  const combineDocsChain = await createStuffDocumentsChain({
    llm,
    prompt: promptTemplate,
  });
  
  const ragChain = await createRetrievalChain({
    retriever,
    combineDocsChain,
  });
  
  // 测试查询
  const questions = [
    "预发环境的部署步骤是什么?",
    "如何访问预发环境?",
    "生产发布由谁负责?",
  ];
  
  for (const question of questions) {
    console.log(`问题: ${question}`);
    const result = await ragChain.invoke({ input: question });
    console.log(`答案: ${result.answer}\n`);
  }
  
  console.log("=== 演示结束 ===");
}

main().catch(console.error);

运行这个脚本,你会看到完整的 RAG 流程从索引到查询的全过程。

注意,这个示例里是构建了一道完整的RAG管线,使用的vectorStore是创建索引时初始化的。而在真实的线上生产环境中,索引和检索-生成这两个过程是完全解耦的,它们通常作为两个独立的服务运行。此时vectorStore可以使用LangChain提供的Chroma.fromExistingCollectionAPI来初始化。

FAQ

Q1:索引管道的执行频率应该是多少?

取决于知识库的更新速度。最简单的策略是每次文档变更后触发一次索引更新。具体实现可以用文件监听(fs.watch)或 Git Hook。在《第18章:RAG 系统的生产部署》中会在 CI/CD 中集成这个流程。

对于小型团队,每天定时重新索引一次也是可行的方案。对于大型知识库,可以实现增量索引,只更新变更的文档。

Q2:为什么检索到的文档要注意顺序?

LLM 存在"Lost in the Middle"现象:对中间位置的信息关注度最低,最关注开头和结尾。因此应该把最相关的片段放在 Prompt 的开头或结尾。

在《第11章:Prompt 工程与上下文优化》中,我们会讨论如何通过重排序(Re-ranking)优化检索结果的顺序。

Q3:LangChain 的 Chain 需要学习吗?可以直接手写串联逻辑?

完全可以手写。本章展示了两种方式就是为了说明:Chain 只是语法糖,你完全可以用自己的逻辑串联。在《第15章:Agent RAG —— 让 RAG 具备自主决策能力》中讲 Agent 时会更多地走向手写 Function Calling 循环,而不是依赖框架的 Chain 抽象。

建议的学习路径是:

  1. 先手写理解每个环节的原理
  2. 再用 Chain 抽象简化代码
  3. 最后根据需求决定是否需要自定义逻辑

Q4:如何处理检索结果为空的情况?

如果检索不到相关文档,应该明确告知用户,而不是让 LLM 自由发挥。可以在 Prompt 中加入明确的指令:

if (relevantDocs.length === 0) {
  return "抱歉,知识库中没有找到相关信息。请尝试换一种提问方式,或联系人工客服。";
}

更好的做法是设置一个相似度阈值,只有超过阈值的結果才被视为相关:

const results = await collection.query({
  queryEmbeddings: [queryEmbedding],
  nResults: 10,
});

// 过滤低相似度结果
const threshold = 0.7;
const filteredDocs = results.documents[0].filter((_, i) => 
  1 - results.distances[0][i] >= threshold
);

Q5:RAG 系统的延迟主要来自哪些环节?

典型的延迟分布:

  • Embedding 生成(查询):50-200ms
  • 向量搜索:5-20ms
  • LLM 生成:500-2000ms(取决于答案长度)
  • 网络传输:50-100ms

总延迟通常在 1-3 秒之间。优化策略包括:

  • 缓存常用查询的 Embedding
  • 使用更快的 Embedding 模型
  • 流式输出 LLM 答案(见练习 3)
  • 优化向量数据库的索引参数

详细内容见《第17章:RAG 性能优化实战》。

练习

练习 1:跑通你的第一条 RAG 流水线

准备一份你自己的短文档(如简历、项目 README、技术博客),走通本章的完整流程。确保索引和检索-生成两个阶段都正确执行。

验证标准:

  • 针对文档中的具体信息提问,RAG 回答准确且有引用来源
  • 能够展示检索到的原始片段
  • 对比有 RAG 和无 RAG 的回答差异

提示:

  • 可以从 GitHub 上找一个开源项目的 README 作为测试文档
  • 准备 3-5 个测试问题,覆盖文档的不同部分
  • 记录每个环节的耗时,建立性能基线

练习 2:对比不同 k 值的效果

分别设置 k=1、k=3、k=8,用同一个问题测试,记录每种设置下的回答质量和检索延迟。

验证标准:

  • 能解释 k 值增加带来的质量提升与延迟增加的权衡
  • 画出 k 值与回答质量的关系图(主观评分 1-5 分)
  • 给出推荐的 k 值及理由

提示:

  • 对于简单问题,k=1-3 可能足够
  • 对于复杂问题,可能需要 k=5-10
  • 过大的 k 值会引入噪声,反而降低质量

练习 3:实现流式输出

将生成阶段的 llm.invoke() 改为 llm.stream(),实现逐 Token 流式输出答案。

验证标准:

  • 答案能够逐字显示,用户体验更流畅
  • 能够正确处理流式输出的异步迭代
  • 总延迟与非流式版本基本一致

提示:

const stream = await llm.stream(prompt);
for await (const chunk of stream) {
  process.stdout.write(chunk.content); // 逐块输出
}

练习 4:添加错误处理和日志

为 RAG 服务添加完善的错误处理和日志记录,包括:

  • Embedding API 调用失败的重试机制
  • 向量数据库连接失败的降级策略
  • 每个环节的耗时统计
  • 查询日志记录(问题、检索结果、答案)

验证标准:

  • 模拟 API 超时,系统能够重试并最终返回错误或降级结果
  • 日志中包含完整的查询链路信息
  • 能够统计平均查询延迟和成功率

📚 延伸阅读