章节08 / 14
本文目录12 节
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)。
这意味着,如果一个用户提问“如何申请公积金贷款?”,向量检索可能会召回两个片段:
- 2020 年失效的公积金政策(因为语义高度相似)。
- 2026 年最新的公积金政策。
在没有元数据干预的情况下,由于历史政策中的关键词重合度可能更高,旧政策的向量相似度跑分甚至会超过新政策。如果检索到的文本块余弦相似度极高,开发人员也不能直接将其视作客观真理,必须通过严格的来源元数据校验来控制生成风险,否则 LLM 必然会基于过期的语义关联给出错误的生成结果。
因此,RAG 系统的第一性问题不是“如何跑出更高的相似度分值”,而是**“如何确保检索出来的召回文本,其事实来源是正确的、唯一的、具有时效性的,并且在生成阶段可以被完全还原追溯”**。
设计可靠的数据源元数据表 (JSON Schema)
本节操作锚点:围绕“设计可靠的数据源元数据表JSONSchema”记录步骤、样例、诊断、风险、检查清单和验收结果。
要解决“答案从哪里来”的问题,第一步是为所有被切碎的文本块(Chunks)设计一张牢固的身份证。不能将纯文本直接丢进向量库,必须将文本与结构化的元数据强绑定。
根据 JSON Schema 官方规范,定义清晰的元数据模式可以强制约束每个切块的数据来源。只有在数据入库前通过 JSON Schema 校验,才能确保每条检索结果都能回溯到具体的物理文件和版本号,从而在根本上阻断“幻觉数据源”的产生。
以下是我们为企业资料库设计的元数据 JSON Schema(source-metadata.schema.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. 准备项目依赖
新建项目并安装必要依赖:
npm install llamaindex ajv ajv-formats dotenv
npm install --save-dev typescript @types/node tsx
2. 编写索引构建脚本 (index-builder.ts)
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-small和text-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 配置文件
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 图谱中直观地看到:
- 用户的 Prompt 是什么。
- 召回了哪些 Chunks,它们的
metadata.url、metadata.version分别是什么。 - LLM 基于这些召回 Chunks 最终生成的文本。
- 判定是否存在“相似度极高但版本已过期”的 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):
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,无失效日期。"
- 文本 A(旧版政策):
- Schema 约束:使用我们给出的 JSON Schema 校验这两个文本块的元数据。
- 代码实现:使用 LlamaIndex TS 构建索引,并设计一个查询逻辑。模拟当前时间是
2026-02-01,通过检索逻辑拦截过期的“双薪”政策,只让最新政策参与生成。 - 来源追溯:必须在控制台完整打印出最终生成回答所依赖的元数据信息(
title,version,url),向用户证明答案的出处。
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)的参数配置方式。
- 适用范围:主要参考其 NodeParser、VectorStoreIndex 及其在 TS 运行时环境下的元数据过滤(Metadata Filtering)实现。API 若在大版本(如
- 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。