1. 为什么知识获取管道是 AI Agent 落地的第一道坎
做 AI Agent 开发的人,绕不开一个尴尬的现实:模型本身很聪明,但它不知道你公司内部的业务规则、不知道你昨天刚更新的产品文档、更不知道你私有的那套数据表结构。你问它一个关于你自家系统的问题,它要么一本正经地胡说八道,要么礼貌地告诉你“我无法访问实时信息”。这不是模型不行,而是它的知识边界被训练数据锁死了。
知识获取管道要解决的就是这件事——在模型和外部知识之间架一条可控、可维护、可扩展的通道。而RAG(检索增强生成,Retrieval-Augmented Generation)就是目前工程上最成熟、性价比最高的那条通道。它的核心思路朴素到一句话就能说清:先去知识库里把相关内容捞出来,再把捞出来的内容塞进提示词,让模型基于这些“证据”来回答。
我见过太多团队一上来就想着微调模型、搞复杂的 Agent 编排,结果连最基础的“把文档切好、存好、查准”都没做扎实,最后效果一塌糊涂,还以为是模型不行。实际上,一个 Agent 的问答质量,七成取决于知识获取管道做得好不好,三成才是模型和提示词的功劳。这个比例可能有点绝对,但方向是对的。
这篇内容面向的是正在用TypeScript从 0 到 1 搭建 AI Agent 的开发者,尤其是那些已经跑通了基础对话、准备接入私有知识库的人。我会把 RAG 的基础链路拆开,从文档处理、切分策略、向量化、存储选型,到检索、重排、拼装提示词,每一步都讲清楚“为什么这么做”和“实际做的时候会踩什么坑”。代码示例用 TypeScript 写,因为这是目前 Agent 开发里工程化程度最高、类型安全最好的选择之一。
提示:RAG 不是银弹。它擅长的是“基于已有文档回答问题”,不擅长“推理出文档里没有的结论”。搞清楚它的能力边界,比盲目上马更重要。
2. RAG 基础链路的五个核心环节拆解
2.1 文档加载:别小看“把文件读进来”这件事
很多人觉得文档加载就是读个文件,能有多难?实际做起来,坑全在这里。你的知识来源可能是 PDF、Word、Markdown、HTML 网页、数据库记录、甚至飞书/Notion 的导出内容。每种格式的解析质量直接决定了后续所有环节的上限。
PDF 是最典型的“看起来简单实际很坑”的格式。扫描版 PDF 需要 OCR,文字版 PDF 的排版信息在提取后往往丢失,表格会变成一堆乱序文本,页眉页脚会混进正文。我试过直接用某个流行的 PDF 解析库读一份产品手册,结果表格里的参数全乱了,模型拿到的是“电压 220 频率 50 功率 1000”这种没有对应关系的碎片,回答出来的东西自然错得离谱。
在 TypeScript 生态里,文档加载通常有两种做法。一种是使用现成的加载器库,比如langchain的DocumentLoader系列,它封装了 PDF、CSV、HTML 等常见格式的读取逻辑。另一种是针对特定格式自己写解析器,比如用pdf-parse读 PDF、用mammoth读 Word、用cheerio解析 HTML。
import fs from "fs/promises"; import pdfParse from "pdf-parse"; async function loadPdf(filePath: string): Promise<string> { const buffer = await fs.readFile(filePath); const data = await pdfParse(buffer); return data.text; }这段代码能跑,但你要知道它的局限:pdf-parse对复杂排版的还原能力有限,表格和分栏内容会丢失结构。如果知识库里表格很多,建议在解析后做一次结构化处理,或者干脆把表格单独抽出来用结构化方式存储。
注意:文档加载阶段一定要保留元数据。文件来源、更新时间、所属分类、权限标签这些信息,在后续检索和过滤时非常关键。很多人只存了文本内容,等到需要按部门过滤或者按时间排序时就傻眼了。
2.2 文本切分:切得好不好,直接决定检索准不准
文本切分(Chunking)是 RAG 里最容易被低估的环节。切得太粗,一个 chunk 里混了好几个主题,检索时噪声大;切得太细,语义被割裂,模型拿到半句话也答不出完整内容。这里没有万能参数,但有几条经过验证的原则。
按语义边界切,而不是按固定字数切。最朴素的做法是每 500 个字符切一刀,但这会把一句话拦腰截断。更好的做法是按段落、按标题层级、按句子边界来切。Markdown 文档天然有#、##这样的结构标记,可以优先按标题切分,再在段落内部按长度做二次切分。
保留重叠区域。相邻两个 chunk 之间保留 10% 到 20% 的重叠内容,可以避免关键信息刚好落在切割线上导致两边都拿不到完整语义。比如 chunk 大小设为 500 字符,重叠设为 80 字符,这样每个 chunk 的尾部会和下一个 chunk 的头部有交集。
给每个 chunk 加上下文头。一个孤立的 chunk 可能只写着“该参数默认为 30 秒”,但读者不知道这是什么参数。可以在 chunk 前面拼接它所属的章节标题或文档标题,变成“超时配置 > 该参数默认为 30 秒”。这个小小的改动,对检索命中率的提升非常明显。
interface Chunk { content: string; metadata: { source: string; section: string; chunkIndex: number; }; } function splitByHeadings(markdown: string, source: string): Chunk[] { const lines = markdown.split("\n"); const chunks: Chunk[] = []; let currentSection = "正文"; let buffer: string[] = []; let index = 0; const flush = () => { if (buffer.length === 0) return; const content = `【${currentSection}】\n${buffer.join("\n")}`; chunks.push({ content, metadata: { source, section: currentSection, chunkIndex: index++ }, }); buffer = []; }; for (const line of lines) { if (line.startsWith("#")) { flush(); currentSection = line.replace(/^#+\s*/, ""); } else { buffer.push(line); } } flush(); return chunks; }实际项目中,我通常会把 chunk 大小控制在 300 到 800 个 token 之间。太短了语义不完整,太长了检索精度下降。具体数值要根据你的文档类型来调,技术文档可以偏小,叙事性内容可以偏大。
2.3 向量化:把文字变成可计算的方向
向量化的本质是把一段文本映射到一个高维空间里的点,语义相近的文本在这个空间里的距离就近。这样检索时就不需要做关键词匹配,而是找“距离最近的邻居”。这就是语义检索比传统关键词检索强的地方——你搜“怎么配置超时”,它能找到写着“timeout 参数设置方法”的段落,哪怕一个字都不重合。
在 TypeScript 里调用嵌入模型(Embedding Model)通常走 HTTP API。你需要关注几个参数:模型名称、向量维度、批量大小。维度越高表达能力越强,但存储和计算成本也越高。常见的嵌入模型维度在 768 到 3072 之间。
async function embedTexts(texts: string[]): Promise<number[][]> { const response = await fetch("https://api.example.com/v1/embeddings", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.EMBEDDING_API_KEY}`, }, body: JSON.stringify({ model: "text-embedding-3-small", input: texts, }), }); const data = await response.json(); return data.data.map((item: any) => item.embedding); }这里有个实操经验:批量调用比逐条调用快得多,但要注意单次请求的 token 上限。我一般会把待嵌入的 chunk 按总 token 数分组,每组控制在模型限制的 80% 左右,留出余量。另外,嵌入结果一定要做缓存,同一段文本不要重复调用 API,既省钱又省时间。
提示:嵌入模型和生成模型可以是不同的服务商。嵌入模型选性价比高的即可,生成模型才需要重点考虑能力。两者解耦,后续替换升级都方便。
2.4 向量存储与索引:选型要看数据规模和查询模式
向量存到哪里?小规模场景(几千到几万条)用内存数组加余弦相似度计算就够了,简单直接。中等规模(十万到百万级)建议用专门的向量数据库,比如 pgvector、Qdrant、Milvus、Chroma 这些。大规模场景才需要考虑分布式方案。
选型时重点看三个维度:数据量、查询延迟要求、是否需要混合检索。如果你的知识库只有几千份文档,pgvector 挂在现有的 PostgreSQL 上是最省事的方案,不用额外维护一套数据库。如果数据量上百万且查询频繁,Qdrant 或 Milvus 的专用索引结构会更有优势。
// 用 pgvector 存储和检索的简化示例 import { Pool } from "pg"; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); async function insertChunk(content: string, embedding: number[], metadata: any) { await pool.query( `INSERT INTO chunks (content, embedding, metadata) VALUES ($1, $2, $3)`, [content, JSON.stringify(embedding), JSON.stringify(metadata)] ); } async function searchSimilar(queryEmbedding: number[], topK: number = 5) { const result = await pool.query( `SELECT content, metadata, 1 - (embedding <=> $1) AS similarity FROM chunks ORDER BY embedding <=> $1 LIMIT $2`, [JSON.stringify(queryEmbedding), topK] ); return result.rows; }<=>是 pgvector 的余弦距离操作符,1 - 距离就是相似度。这个查询在数据量不大时很快,但要注意建索引。pgvector 支持 IVFFlat 和 HNSW 两种索引,HNSW 查询更快但建索引更慢、占内存更多。我的经验是:数据量低于十万用 IVFFlat 就够了,超过十万再考虑 HNSW。
2.5 检索与重排:从“能查到”到“查得准”
基础检索就是拿用户问题去向量库里找最相似的 topK 个 chunk。但实际用下来你会发现,纯向量检索有几个明显问题:对专有名词和精确匹配不敏感、容易召回语义相近但实际无关的内容、topK 里的排序未必合理。
混合检索是第一个改进方向。把向量检索和关键词检索(BM25)的结果融合,既能抓住语义相似,又能保证关键词命中。融合算法常用 RRF(Reciprocal Rank Fusion),它对两路结果的排名做加权合并,简单有效。
重排(Rerank)是第二个改进方向。先用向量检索召回比如 20 个候选,再用一个重排模型对这 20 个做精细打分,选出最相关的 5 个。重排模型通常比嵌入模型更大更慢,但只对少量候选做计算,总体开销可以接受。实测下来,加了重排之后,检索命中率(Hit Rate)能有明显提升。
async function hybridSearch(query: string, topK: number = 5) { const queryEmbedding = (await embedTexts([query]))[0]; const vectorResults = await searchSimilar(queryEmbedding, topK * 4); const keywordResults = await searchByKeyword(query, topK * 4); // RRF 融合 const scores = new Map<string, number>(); const k = 60; vectorResults.forEach((r, i) => { scores.set(r.content, (scores.get(r.content) || 0) + 1 / (k + i + 1)); }); keywordResults.forEach((r, i) => { scores.set(r.content, (scores.get(r.content) || 0) + 1 / (k + i + 1)); }); return [...scores.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, topK) .map(([content]) => content); }注意:重排不是必须的。如果你的知识库很小、查询很简单,基础向量检索可能就够了。加组件之前先确认它带来的收益是否值得那部分延迟和成本。
3. 用 TypeScript 搭一条最小可用的 RAG 管道
3.1 项目结构与依赖选择
我习惯把 RAG 管道拆成几个独立的模块:loader负责文档加载,splitter负责切分,embedder负责向量化,store负责存储和检索,pipeline负责串联整个流程。这样每个环节都可以单独替换和测试,不会牵一发动全身。
依赖方面,核心的几个包是:pdf-parse或mammoth做文档解析,pg加pgvector做向量存储,zod做配置和数据的类型校验。如果你用 LangChain.js,它提供了很多现成的封装,但我的建议是:先理解底层链路,再用框架提效。直接上框架容易在出问题时不知道从哪查。
// config.ts import { z } from "zod"; export const configSchema = z.object({ embeddingApiKey: z.string(), embeddingModel: z.string().default("text-embedding-3-small"), databaseUrl: z.string(), chunkSize: z.number().default(500), chunkOverlap: z.number().default(80), topK: z.number().default(5), }); export type Config = z.infer<typeof configSchema>;用 zod 做配置校验的好处是,环境变量缺失或格式错误时能在启动阶段就报错,而不是跑到一半才崩。这个习惯在 Agent 项目里特别重要,因为 RAG 管道涉及的配置项很多,散落在各处很容易漏。
3.2 从文档到向量的完整代码链路
把前面几个环节串起来,一个最小的入库流程是这样的:加载文档、切分成 chunk、批量向量化、写入向量库。每一步都要处理错误和边界情况。
import { loadPdf } from "./loader"; import { splitByHeadings } from "./splitter"; import { embedTexts } from "./embedder"; import { insertChunk } from "./store"; async function ingestDocument(filePath: string, source: string) { const rawText = await loadPdf(filePath); const chunks = splitByHeadings(rawText, source); const batchSize = 20; for (let i = 0; i < chunks.length; i += batchSize) { const batch = chunks.slice(i, i + batchSize); const embeddings = await embedTexts(batch.map((c) => c.content)); for (let j = 0; j < batch.length; j++) { await insertChunk(batch[j].content, embeddings[j], batch[j].metadata); } console.log(`已处理 ${Math.min(i + batchSize, chunks.length)}/${chunks.length}`); } }这段代码看起来简单,但有几个细节值得说。批量大小设为 20 是经验值,太小了 API 调用次数多,太大了单次请求可能超 token 限制。每批处理完打印进度,方便排查卡在哪一步。写入时逐条插入而不是批量插入,是为了在出错时能定位到具体是哪条 chunk 有问题。
3.3 查询侧的组装与提示词拼装
检索到相关 chunk 之后,下一步是把它们和用户问题一起拼成提示词发给模型。拼装方式直接影响回答质量。我的做法是给每个 chunk 编号,并在提示词里明确要求模型“基于以下资料回答,如果资料中没有相关信息就直说不知道”。
async function answerQuestion(question: string): Promise<string> { const queryEmbedding = (await embedTexts([question]))[0]; const chunks = await searchSimilar(queryEmbedding, 5); const context = chunks .map((c, i) => `[资料${i + 1}] ${c.content}`) .join("\n\n"); const prompt = `你是一个严谨的助手。请仅根据以下资料回答问题。 如果资料中没有足够信息,请明确说明“根据现有资料无法回答”。 ${context} 问题:${question}`; const response = await fetch("https://api.example.com/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.CHAT_API_KEY}`, }, body: JSON.stringify({ model: "gpt-4o-mini", messages: [{ role: "user", content: prompt }], temperature: 0.2, }), }); const data = await response.json(); return data.choices[0].message.content; }temperature设低一点(0.1 到 0.3),因为 RAG 场景要的是忠实于资料,不是发挥创意。提示词里明确“无法回答就说不知道”,能有效减少幻觉。给资料编号是为了让模型在回答时能引用来源,方便后续做溯源。
4. 实测中那些文档不会告诉你的坑
4.1 检索命中率上不去,先查切分再查嵌入
很多人发现检索效果差,第一反应是换嵌入模型。但根据我的经验,八成的问题出在切分环节,而不是嵌入模型。切分粒度不对、没有保留上下文、chunk 里混入了页眉页脚这些噪声,都会让检索质量大打折扣。
排查顺序应该是:先看召回的 chunk 内容是否完整、是否包含答案、是否有明显噪声;再看 topK 设置是否合理;最后才考虑换嵌入模型或加重排。我遇到过一个案例,团队换了三次嵌入模型效果都没提升,最后发现是 PDF 解析时把每页的页脚“第 X 页 共 Y 页”混进了正文,导致每个 chunk 都带着这串无关文字,干扰了向量表达。
4.2 向量维度和距离度量必须匹配
不同嵌入模型输出的向量维度不同,距离度量方式也可能不同。有的模型训练时用的是余弦相似度,有的用的是点积或欧氏距离。如果你用余弦距离去检索一个用点积训练的模型输出的向量,结果可能完全不对。
注意:换嵌入模型时,必须把库里所有向量重新生成一遍。新旧模型的向量空间不兼容,混用会导致检索结果混乱。这个操作成本不低,所以选模型时要慎重,别频繁换。
4.3 元数据过滤比想象中更重要
实际业务里,用户往往不是在整个知识库上检索,而是在某个子集上检索。比如“只查最新版本的文档”“只查我有权限看的部门文档”“只查产品手册不查会议纪要”。这些需求都要靠元数据过滤来实现。
在向量检索时加上WHERE条件过滤元数据,可以大幅缩小检索范围、提升精度。pgvector 支持在向量索引之外加普通 B-tree 索引,对元数据字段做过滤效率很高。设计元数据 schema 时,把常用的过滤维度(来源、版本、部门、时间)都预留出来,后面会省很多事。
4.4 别忽略“查不到”的情况
RAG 系统最危险的不是答错,而是在查不到相关内容时强行编造答案。模型看到提示词里有资料,即使资料和问题无关,它也可能硬凑出一个回答。解决办法有两个:一是设置相似度阈值,低于阈值的 chunk 直接丢弃;二是在提示词里强化“资料不足时明确说不知道”的指令。
相似度阈值怎么定?没有统一标准,要在你的数据上实测。我的做法是拿一批已知答案的问题跑一遍,看正确命中的 chunk 相似度分布,取一个能过滤掉大部分无关结果又不误杀正确结果的值。这个值通常在 0.7 到 0.8 之间(余弦相似度),但具体要看模型和数据。
5. 从基础 RAG 到 Agentic RAG 的演进思路
基础 RAG 是一条直线:问题进来,检索,拼提示词,出答案。但真实场景里,用户的问题往往需要多步检索、需要判断该查哪个知识库、需要根据中间结果调整查询。这就是Agentic RAG的思路——把检索能力交给 Agent 来编排,让 Agent 自己决定什么时候查、查什么、查几次。
比如用户问“我们产品和竞品 X 在超时配置上有什么区别”,Agent 需要先查自家产品的超时配置文档,再查竞品 X 的相关资料,然后对比。这已经不是单次检索能解决的了。Agent 可以把检索当成一个工具(Tool),在推理过程中按需调用,甚至对检索结果不满意时自动改写查询再试一次。
在 TypeScript 里实现这种模式,通常是把检索封装成一个符合 Agent 工具调用规范的函数,然后在 Agent 的循环里注册进去。LangChain.js 和 AgentScope 这类框架都支持这种模式。但我的建议还是:先把基础 RAG 跑稳,再上 Agentic。基础链路都没调通就搞复杂编排,出了问题根本不知道是哪一层的问题。
知识获取管道这件事,说到底是个工程活。模型能力在快速进步,但“把正确的知识在正确的时机喂给模型”这个核心问题不会消失。把切分、嵌入、检索、重排这几个环节的细节抠到位,比追新框架、新概念实在得多。我在实际项目里最大的体会是:RAG 的效果提升,往往来自那些不起眼的工程细节,而不是某个神奇的新模型。把 chunk 切好、把元数据管好、把阈值调好,这三件事做到位,效果就已经能超过大多数仓促上线的方案了。