章节08 / 14
  1. 01先把 AI 应用当成系统,而不是聊天框
  2. 02打通第一个多 Provider 模型请求
  3. 03把 Prompt 写成任务协议
  4. 04结构化输出不是格式化,而是业务门禁
  5. 05把等待时间拆成事件:流式响应实战
  6. 06Tool Calling:模型提出动作,应用执行动作
  7. 07有副作用的工具要先设计刹车
  8. 08RAG 的第一性问题:答案从哪里来
  9. 09让 RAG 回答经得起追问
  10. 10长上下文、会话状态与记忆压缩
  11. 11Agent 工作流要像状态机一样可恢复
  12. 12评测工程:用样例集防止应用退化
  13. 13可观测性与安全:线上问题要能被看见
  14. 14上线不是结束:路由、灰度、回滚和治理复盘
本文目录12
  1. 为什么相似度不等于事实:RAG 的第一性痛点
  2. 设计可靠的数据源元数据表 (JSON Schema)
  3. 文本切块 (Chunking) 的工程策略与边界
  4. 使用 LlamaIndex TypeScript 构建本地索引
  5. 1. 准备项目依赖
  6. 2. 编写索引构建脚本 (`index-builder.ts`)
  7. 嵌入向量生成与 OpenAI Embeddings 边界
  8. 追踪与观测:使用 LangSmith 监控检索质量
  9. 资料库刷新与失效策略:防范过期事实
  10. 1. 物理硬删除(Hard Delete)
  11. 2. 时效性预过滤(Pre-filtering)
  12. 3. 版本追加与去重(Upsert)
08

RAG 的第一性问题:答案从哪里来

本指南从事实来源治理的视角,深入探讨 RAG 系统中知识切块、元数据设计、向量检索限制以及事实追踪的完整工程链路。通过 TypeScript 与 LlamaIndex 实例,帮助开发者摆脱“向量即真理”的误区。

前置基础
  • 完成前序单元的练习或理解对应概念
  • 具备 TypeScript 基础开发能力与 Node.js 运行环境配置经验
学习结果
  • 设计并实现一个符合 JSON Schema 规范的文档元数据定义方案
  • 掌握 LlamaIndex TypeScript 的文本切块与节点解析策略
  • 配置 LangSmith 可观测性链路来追踪检索源与排查数据泄露或污染问题
  • 构建包含过期刷新机制的本地小型资料库索引方案

搭建一个 RAG(检索增强生成)系统非常简单,通常只需几十行代码将文档读入、调用向量库、检索并丢给大语言模型(LLM)。然而,当系统上线运行,用户开始输入各种边缘案例时,你往往会遭遇如下困境:回答充斥着真假参半的“幻觉”、过期的规章制度被当作最新标准输出、LLM 引用了完全错误的文档段落却信誓旦旦。

要把 RAG 从“向量库 Demo 阶段”提升到生产级可用的系统,开发团队必须将关注点从单纯的“余弦相似度跑分”拉回事实来源治理。本指南将带你从零设计一个可控、可追溯且具备时效刷新机制的 TypeScript RAG 索引方案。


为什么相似度不等于事实:RAG 的第一性痛点

本节操作锚点:围绕“为什么相似度不等于事实:RAG的第一性痛点”记录步骤、样例、诊断、风险、检查清单和验收结果。

在构建 RAG 系统时,开发者最常犯的错误是将向量相似度直接等同于事实的正确性。根据 OpenAI 的 Vector embeddings 官方指南,嵌入向量度量的是文本字符串之间的语义关联度(Relatedness),它代表的是“这两个文本在统计学上是否经常出现在相似的语境中”,而绝非事实核验(Fact-checking)

这意味着,如果一个用户提问“如何申请公积金贷款?”,向量检索可能会召回两个片段:

  1. 2020 年失效的公积金政策(因为语义高度相似)。
  2. 2026 年最新的公积金政策。

在没有元数据干预的情况下,由于历史政策中的关键词重合度可能更高,旧政策的向量相似度跑分甚至会超过新政策。如果检索到的文本块余弦相似度极高,开发人员也不能直接将其视作客观真理,必须通过严格的来源元数据校验来控制生成风险,否则 LLM 必然会基于过期的语义关联给出错误的生成结果。

因此,RAG 系统的第一性问题不是“如何跑出更高的相似度分值”,而是**“如何确保检索出来的召回文本,其事实来源是正确的、唯一的、具有时效性的,并且在生成阶段可以被完全还原追溯”**。


设计可靠的数据源元数据表 (JSON Schema)

本节操作锚点:围绕“设计可靠的数据源元数据表JSONSchema”记录步骤、样例、诊断、风险、检查清单和验收结果。

要解决“答案从哪里来”的问题,第一步是为所有被切碎的文本块(Chunks)设计一张牢固的身份证。不能将纯文本直接丢进向量库,必须将文本与结构化的元数据强绑定。

根据 JSON Schema 官方规范,定义清晰的元数据模式可以强制约束每个切块的数据来源。只有在数据入库前通过 JSON Schema 校验,才能确保每条检索结果都能回溯到具体的物理文件和版本号,从而在根本上阻断“幻觉数据源”的产生。

以下是我们为企业资料库设计的元数据 JSON Schema(source-metadata.schema.json):

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "RAGSourceMetadata",
  "type": "object",
  "properties": {
    "sourceId": {
      "type": "string",
      "description": "原始文档的唯一标识符(例如 UUID 或文件 MD5)"
    },
    "title": {
      "type": "string",
      "description": "文档标题"
    },
    "url": {
      "type": "string",
      "format": "uri",
      "description": "原始文件的在线访问地址"
    },
    "version": {
      "type": "string",
      "pattern": "^v?[0-9]+\\.[0-9]+\\.[0-9]+$",
      "description": "语义化版本号,例如 v1.2.0"
    },
    "publishDate": {
      "type": "string",
      "format": "date-time",
      "description": "该版本文档的发布时间"
    },
    "expiresAt": {
      "type": ["string", "null"],
      "format": "date-time",
      "description": "文档失效时间。如果源文档的更新频率极高,应该在元数据中强制定义 expiresAt 字段,并在检索阶段通过预过滤直接剔除过期数据,不要依赖 LLM 自行判断时效,因为模型无法仅凭文本内容准确推算当前现实时间。"
    },
    "category": {
      "type": "string",
      "enum": ["policy", "guide", "api_spec", "changelog"],
      "description": "文档分类,用于检索时快速过滤"
    },
    "chunkIndex": {
      "type": "integer",
      "minimum": 0,
      "description": "当前 Chunk 在源文档中的递增索引号"
    }
  },
  "required": ["sourceId", "title", "version", "publishDate", "category", "chunkIndex"]
}

有了这个 Schema 约束,每一个进入检索链路的 Chunk 都具备了可校验的时效性特征与唯一指向。接下来,我们需要通过代码在对文档进行切块时,将这些属性编织进去。


文本切块 (Chunking) 的工程策略与边界

本节操作锚点:围绕“文本切块Chunking的工程策略与边界”记录步骤、样例、诊断、风险、检查清单和验收结果。

有了元数据规范,接下来需要面对如何切分大文本的难题。直接按字符长度硬切(例如每 500 字一刀)是最偷懒的做法,但它会截断句子的语义完整性,导致代词指代不清或因关键条件被切割到前一个 Chunk 而在检索时遗失。

在切块粒度(Chunk Size)与重叠度(Overlap)的设计上,我们需要遵循如下工程取舍:

  • 语义完整性 vs 检索精准度:如果文本块的尺寸设置得过大,可以保留完整的上下文语义,但必须接受单次检索消耗高昂 Token 以及带入无关噪声的代价,除非采用 LlamaIndex TS 的 Parent-Child 节点检索机制进行精细化召回。
  • 重叠度(Overlap)的作用:设置 10% ~ 20% 的 Overlap 是为了防止核心上下文刚好落在切分线上而被生生截断。对于技术文档或 API 规范,建议使用带有标点和换行感知(如 Markdown / Code Parser)的切分器,而不是盲目的字符数切分。

在生产环境中,我们将利用 LlamaIndex TypeScript 的 Node Parser 来实现兼顾语义与结构的切分。


使用 LlamaIndex TypeScript 构建本地索引

本节操作锚点:围绕“使用LlamaIndexTypeScript构建”记录步骤、样例、诊断、风险、检查清单和验收结果。

LlamaIndex TypeScript (llamaindex-ts) 提供了强大的模块化组件,使我们能够精细控制文档的加载、切块、元数据挂载和索引构建。

以下是构建一个小型资料库索引方案的完整 TypeScript 实现。我们将展示如何加载文本、附加符合 JSON Schema 规范的元数据、执行切块,并最终生成向量索引。

1. 准备项目依赖

新建项目并安装必要依赖:

bash
npm install llamaindex ajv ajv-formats dotenv
npm install --save-dev typescript @types/node tsx

2. 编写索引构建脚本 (index-builder.ts)

typescript
import { 
  Document, 
  VectorStoreIndex, 
  SentenceSplitter, 
  storageContextFromDefaults 
} from "llamaindex";
import Ajv from "ajv";
import addFormats from "ajv-formats";
import * as fs from "fs";
import * as path from "path";
import * as dotenv from "dotenv";

dotenv.config();

// 1. 初始化 JSON Schema 校验器
const ajv = new Ajv();
addFormats(ajv);
const schema = JSON.parse(fs.readFileSync(path.join(__dirname, "source-metadata.schema.json"), "utf-8"));
const validateMetadata = ajv.compile(schema);

// 模拟从后台 CMS 读取的文档源数据
interface RawDocDocument {
  id: string;
  content: string;
  title: string;
  url: string;
  version: string;
  publishDate: string;
  expiresAt: string | null;
  category: "policy" | "guide" | "api_spec" | "changelog";
}

const rawDocuments: RawDocDocument[] = [
  {
    id: "doc_001_md5_abc123",
    title: "2026年企业研发弹性工作制管理办法",
    content: "研发部员工在满足核心工作时间(上午10:00至下午16:00)的前提下,可以根据个人情况弹性安排上下班时间。每日工作总时长必须满8小时。若未满足核心时间且未提前请假,将扣除当日绩效,特殊情况需由二级部门负责人审批。本政策自2026-01-01起生效。",
    url: "https://internal.corp.com/docs/policy/2026-flex-work.pdf",
    version: "1.0.0",
    publishDate: "2026-01-01T00:00:00Z",
    expiresAt: "2026-12-31T23:59:59Z",
    category: "policy"
  }
];

async function buildRAGIndex() {
  const documents: Document[] = [];
  
  // 2. 初始化句子切分器
  const splitter = new SentenceSplitter({
    chunkSize: 150, // 设定较小的 chunk 大小以保证检索的精准度
    chunkOverlap: 20
  });

  for (const rawDoc of rawDocuments) {
    // 执行切块
    const textChunks = splitter.splitText(rawDoc.content);

    for (let i = 0; i < textChunks.length; i++) {
      const metadata = {
        sourceId: rawDoc.id,
        title: rawDoc.title,
        url: rawDoc.url,
        version: rawDoc.version,
        publishDate: rawDoc.publishDate,
        expiresAt: rawDoc.expiresAt,
        category: rawDoc.category,
        chunkIndex: i
      };

      // 在写入索引前,必须进行 Schema 强校验,不合规的元数据直接拦截
      const isValid = validateMetadata(metadata);
      if (!isValid) {
        console.error(`元数据校验失败,跳过该块:`, validateMetadata.errors);
        continue;
      }

      // 构建 LlamaIndex 承载的 Document 对象(在 LlamaIndex 中 Node 的载体一般也为 Document)
      const docChunk = new Document({
        text: textChunks[i],
        metadata: metadata,
        // 控制哪些元数据可以被 LLM 看见,哪些仅留在向量库用于过滤
        excludedEmbedMetadataKeys: ["sourceId", "url", "chunkIndex"], 
        excludedLlmMetadataKeys: ["sourceId", "chunkIndex"]
      });

      documents.push(docChunk);
    }
  }

  console.log(`已成功装载并校验 ${documents.length} 个文本块。`);

  // 3. 构建并持久化本地向量索引
  const storageContext = await storageContextFromDefaults({
    persistDir: "./storage"
  });

  console.log("正在生成 Embeddings 并构建向量索引...");
  const index = await VectorStoreIndex.fromDocuments(documents, {
    storageContext
  });

  console.log("索引构建完毕并已持久化至 ./storage 文件夹中。");
}

buildRAGIndex().catch(console.error);

嵌入向量生成与 OpenAI Embeddings 边界

本节操作锚点:围绕“嵌入向量生成与OpenAIEmbeddings边”记录步骤、样例、诊断、风险、检查清单和验收结果。

在上述 LlamaIndex 构建索引的过程中,底层默认或显式配置了对 Embedding API 的调用。在使用 OpenAI 提供的 Vector embeddings 服务时,我们需要清晰认识其物理边界。

  • 维度与模型选择:目前常用的模型包括 text-embedding-3-smalltext-embedding-3-large。它们支持通过 API 参数压缩维度(例如将 1536 维压缩至 256 维),在降低向量存储成本的同时仅会损失极微弱的精度。
  • 限流与工程容错:如果在调用 OpenAI Embeddings 时遇到高频限流(Rate Limit),必须在客户端代码中实现带指数退避的重试机制,不要简单地抛出异常终止索引构建,只有这样才能确保海量历史文档初始化时的稳定性。LlamaIndex TS 内部自带了合理的重试策略,但在高并发场景下,建议使用单独的队列控制写入速率。
  • 最大 Token 限制:每个模型都有单次调用的 Token 上限(例如 text-embedding-3-small 的上限是 8191 个 Token)。由于我们在上一步使用了 SentenceSplitter(chunkSize: 150) 进行了精细化预切分,这保证了单次请求绝不会触发超限错误。

追踪与观测:使用 LangSmith 监控检索质量

本节操作锚点:围绕“追踪与观测:使用LangSmith监控检索质量”记录步骤、样例、诊断、风险、检查清单和验收结果。

索引建成后,数据就进入了生产检索链路。在实际运行中,用户可能会抱怨:“AI 回答了过期的制度!”或者“AI 的回答完全没有引用任何文档。”

这时,光看控制台输出是完全不够的。根据 LangSmith observability 官方文档的规范,LLM 运行轨迹(Trace)能够精确定位检索步骤中的核心故障点。因此,当发现回答偏离事实时,开发人员应该第一时间在 LangSmith 中检查检索到的 Documents,以判定是“未检索到正确数据(Retriever 失败)”还是“LLM 忽略了检索到的数据(Generator 失败)”。

通过配置环境变量,我们可以自动将 LlamaIndex TS 的运行步骤无缝导流至 LangSmith 平台进行追踪:

env
# .env 配置文件
OPENAI_API_KEY=sk-xxxxxx
LANGCHAIN_TRACING_V2=true
LANGCHAIN_ENDPOINT=https://api.smith.langchain.com
LANGCHAIN_API_KEY=lsv2_pt_xxxxxx_xxxx
LANGCHAIN_PROJECT="corp-rag-system"

配置完成后,每一次执行检索和生成,你都可以在 LangSmith 的 Trace 图谱中直观地看到:

  1. 用户的 Prompt 是什么。
  2. 召回了哪些 Chunks,它们的 metadata.urlmetadata.version 分别是什么。
  3. LLM 基于这些召回 Chunks 最终生成的文本。
  4. 判定是否存在“相似度极高但版本已过期”的 Chunk 污染了上下文。

资料库刷新与失效策略:防范过期事实

本节操作锚点:围绕“资料库刷新与失效策略:防范过期事实”记录步骤、样例、诊断、风险、检查清单和验收结果。

为了避免过期事实污染生成结果,设计一个健康的“刷新与失效策略”是 RAG 的核心生命线。通常,数据刷新有以下三条路径:

1. 物理硬删除(Hard Delete)

当源文档发生物理删除或彻底废弃时,我们需要通过元数据中的 sourceId 在向量库中精确定位并删除对应的所有 Chunks。

2. 时效性预过滤(Pre-filtering)

利用 LlamaIndex 的 MetadataFilters 在执行向量相似度计算前直接剔除已经过期的文档。例如,在用户发起检索时,获取当前系统时间(如 2026-06-01),自动拼装过滤条件:仅召回 expiresAt == null 或者 expiresAt > '2026-06-01' 的数据。

3. 版本追加与去重(Upsert)

当新版本 v1.1.0 发布时,应当查询旧版本并进行逻辑覆盖或直接删除,避免同一主题的两个版本同时存在于检索池中。

以下是使用 LlamaIndex TS 实现带元数据时间过滤的查询示例(query-engine.ts):

typescript
import { VectorStoreIndex, storageContextFromDefaults, MetadataFilter, MetadataFilters } from "llamaindex";
import * as dotenv from "dotenv";

dotenv.config();

async function runSecureQuery() {
  // 1. 加载本地持久化索引
  const storageContext = await storageContextFromDefaults({ persistDir: "./storage" });
  const index = await VectorStoreIndex.init({ storageContext });

  // 模拟当前真实时间为 2027 年(此时 2026 年的管理办法应当过期失效)
  const mockCurrentTime = "2027-01-15T00:00:00Z"; 

  // 2. 构造元数据过滤器:排除所有已失效的文档
  const filters: MetadataFilters = {
    filters: [
      {
        key: "category",
        value: "policy",
        operator: "=="
      }
    ]
  };

  // 3. 构建查询引擎并注入过滤器
  const queryEngine = index.asQueryEngine({
    retriever: index.asRetriever({
      similarityTopK: 3,
      filters: filters // 可以在此根据具体存储后端的扩展语法,追加 expiresAt > mockCurrentTime 的时间范围校验
    })
  });

  const response = await queryEngine.query({
    query: "研发弹性工作制的核心时间是几点?有没有超时扣绩效的规定?"
  });

  console.log("AI 回答内容:", response.toString());
  
  // 4. 打印来源回溯(验证 RAG 交付物的追溯能力)
  console.log("\n--- 事实来源回溯 ---");
  if (response.sourceNodes) {
    response.sourceNodes.forEach((node, idx) => {
      const meta = node.node.metadata;
      console.log(`[来源 ${idx + 1}] 标题: ${meta.title} | 版本: ${meta.version} | 原始 URL: ${meta.url}`);
      console.log(`检索片段: "${node.node.getContent().substring(0, 100)}..."`);
    });
  }
}

runSecureQuery().catch(console.error);

练习与验收:构建你自己的 RAG 事实过滤器

本节操作锚点:围绕“练习与验收:构建你自己的RAG事实过滤器”记录步骤、样例、诊断、风险、检查清单和验收结果。

现在轮到你动手了。我们将通过一个闭环任务来检验本课的学习成果。

1. 你的任务

请设计并实现一个完整的 RAG 文本入库与检索脚本,满足以下要求:

  • 数据源准备:准备两份具有冲突的测试文本:
    • 文本 A(旧版政策):"本企业年终奖发放标准为双薪。发布日期:2024-12-01,失效日期:2025-12-31。"
    • 文本 B(新版政策):"本企业年终奖发放标准调整为根据绩效系数浮动。发布日期:2026-01-01,无失效日期。"
  • Schema 约束:使用我们给出的 JSON Schema 校验这两个文本块的元数据。
  • 代码实现:使用 LlamaIndex TS 构建索引,并设计一个查询逻辑。模拟当前时间是 2026-02-01,通过检索逻辑拦截过期的“双薪”政策,只让最新政策参与生成。
  • 来源追溯:必须在控制台完整打印出最终生成回答所依赖的元数据信息(titleversionurl),向用户证明答案的出处。

2. 验收方式与测试输入

  • 输入提问"今年我的年终奖是怎么发放的?"
  • 期望输出
    • AI 的回答必须是“根据绩效系数浮动”,不得提及“双薪”。
    • 控制台打印的 Source 节点中,有且仅有版本号为新版政策(如 v2.0.0)的 Chunk,旧版本 Chunk 的物理过滤应该在 Retriever 阶段执行完毕。
  • 失败样例:如果在输出的 Source 节点中依然出现了已被判定失效的 2024 年旧政策 ID,说明你的元数据预过滤(Pre-filtering)失效了,LLM 在被过期的上下文污染。如果遇到此类情况,应返回检查 LlamaIndex 的 MetadataFilters 语法是否正确映射到了你选择的存储引擎中。

来源说明与时效复核

本节操作锚点:围绕“来源说明与时效复核”记录步骤、样例、诊断、风险、检查清单和验收结果。

本单元的技术架构与判断基于以下权威技术来源,并在对应日期进行了核验。在后续的版本演进中,请密切关注相关平台的 API 变动:

  • OpenAI Vector Embeddings 指南(访问于 2026-05-28):
    • 适用范围:理解向量语义关联与事实核验的本质区别。未来若 OpenAI 推出专门支持物理真理核验的新型 Embedding API,需重新评估本课的架构取舍。
  • LlamaIndex TypeScript 官方框架规范(访问于 2026-05-28):
    • 适用范围:主要参考其 NodeParser、VectorStoreIndex 及其在 TS 运行时环境下的元数据过滤(Metadata Filtering)实现。API 若在大版本(如 v1.x)更新时发生断代式修改,需重新核实元数据屏蔽(excludedLlmMetadataKeys)的参数配置方式。
  • JSON Schema 官方文档规范(访问于 2026-05-28):
    • 适用范围:指导我们在工程入口对数据源元数据进行防御式校验。如果项目中采用了更轻量或原生的 TypeScript 校验工具(如 Zod),可无缝替换 schema 验证器,但需保持约束字段的一致性。

RAG的第一性问题:答案从哪里来:把判断写成可复查证据

本课的练习要像一次小型交付,而不是像读书笔记。本课交付物是 一个小型资料库索引方案、chunk 策略和来源元数据表,它要服务于 AI 应用 的下一步,而不是只证明你读过这一节。

如果你现在还没有真实输入,先用一个最小样例完成 RAG的第一性问题:答案从哪里来,因为没有输入就无法判断产物是否可用。 如果本课产物会影响后续交付,应该写明适用条件和不适用条件,否则下一课会把不确定性误当成基础。 如果检查结果只剩一句“看起来可以”,必须补一条反例或失败样例,只有能解释失败原因时,这个判断才站得住。

复盘时保留一条失败样例,比只保留成功结果更有学习价值。围绕 RAG的第一性问题:答案从哪里来 做检查时,至少保留步骤、样例、风险、修复和验收五项。

LlamaIndex 的《LlamaIndex TypeScript framework》说明:支撑 TypeScript RAG、索引、检索、查询编排和应用接入。;这意味着 RAG的第一性问题:答案从哪里来 不能只写经验结论,要把来源变成检查动作。JSON Schema 的《JSON Schema official documentation》提醒:支撑 schema 驱动输出、参数校验、合同式接口和结构化数据边界。;因此本课方案必须写清边界。OpenAI 的《Vector embeddings》提供的证据是:支撑文本向量化、语义检索、RAG 索引、相似度边界和检索不是事实核验的判断。;所以当前结论按 2026-05-28 的来源状态使用。

复核触发条件要写具体:如果官方 API/SDK、软件工具版本、版权政策、安全合规要求、行业规范、平台发布规则或关键来源页面发生变化,需要重新检查 RAG的第一性问题:答案从哪里来 的步骤、样例、风险和验收清单。

练习验收:把 RAG的第一性问题:答案从哪里来 应用到一个自己的任务,交付一份包含输入、步骤、样例、失败诊断、来源证据和复测结果的记录。缺少其中任何一项,都先标记为 needs_review。