☰
AI Agent 知识获取管道:TypeScript 实战 RAG 检索增强生成
2026/9/29 10:16:12 网站建设 项目流程

1. 为什么知识获取管道是 AI Agent 的分水岭

做 AI Agent 开发的人迟早会撞上一堵墙:模型本身很聪明,但你问它公司内部某个产品的退货政策,它要么胡编一个,要么说“我无法获取最新信息”。这不是模型不行,而是它缺了一条知识获取管道。RAG(检索增强生成)就是目前工程上最成熟、性价比最高的解法。

我接触过不少团队,模型选型讨论了好几轮,提示词改了十几版,最后卡在“回答不准”上。一查,压根没做检索,全靠模型参数里的那点公共知识硬撑。RAG 要解决的核心问题就一句话:让模型在生成回答之前,先去一个可信的知识库里把相关材料捞出来,再基于材料说话。这跟开卷考试是一个道理——闭卷考的是记忆力,开卷考的是检索和阅读理解能力,而实际业务场景里,我们几乎总是希望 Agent 开卷。

这篇文章面向的是正在从零搭建 AI Agent、准备接入知识库的开发者,尤其是用 TypeScript 做技术栈的团队。我会把 RAG 的基础链路拆开,讲清楚每个环节在干什么、为什么这么设计、TypeScript 里怎么落地,以及我踩过的那些坑。读完你应该能自己搭一条可用的知识获取管道,而不是停留在“RAG 就是向量检索”这种模糊认知上。

2. RAG 基础链路的整体设计与选型思路

2.1 一条完整的知识获取管道长什么样

很多人把 RAG 等同于“向量数据库 + 相似度搜索”,这只说对了一半。一条能上生产的 RAG 管道,至少包含五个阶段:

  1. 文档加载与解析:把 PDF、Markdown、网页、数据库记录等原始材料读进来,转成纯文本。
  2. 文本切分(Chunking):把长文档切成大小合适的片段,这是最容易被低估的一步。
  3. 向量化(Embedding):用嵌入模型把每个片段转成向量,存进向量库。
  4. 检索(Retrieval):用户提问时,把问题也向量化,找出最相似的若干片段。
  5. 生成(Generation):把检索到的片段拼进提示词,交给大模型生成回答。

这五步里,切分和检索策略决定了 RAG 的上限,模型只决定下限。我见过太多项目在切分上偷懒,直接按固定字数硬切,结果一句话被拦腰截断,检索出来的片段语义残缺,模型再强也救不回来。

2.2 为什么 TypeScript 技术栈值得认真对待

热词里出现了大量 TypeScript 相关内容,这不是偶然。Node.js 生态做 AI Agent 有几个实打实的优势:前后端同构,一套类型定义可以从数据库一路贯穿到前端展示;流式响应处理天然顺手;部署运维成本比 Python 服务低。LangChain.js、LlamaIndex.TS 这些库已经足够成熟,配合 OpenAI、通义、智谱等模型的 SDK,搭一条 RAG 管道并不比 Python 麻烦。

选 TypeScript 还有一个隐性好处:类型系统会逼你把数据流想清楚。文档对象、切分后的 chunk、检索结果、提示词模板,每个环节的数据结构定义清楚了,调试时能省掉大量 console.log。下面我会用 TypeScript 贯穿所有代码示例。

2.3 方案选型:从简到繁的三档配置

不是所有场景都需要上重型武器。我一般按知识库规模和更新频率分三档:

档位适用场景向量库嵌入模型检索策略
轻量文档少于 500 页,更新少内存数组或 SQLite 扩展本地小模型或 API纯向量相似度
标准千页级,需持久化pgvector / QdrantAPI 嵌入模型向量 + 关键词混合
进阶万页级,多源异构专用向量库集群微调嵌入模型混合 + 重排序

新手建议从轻量档起步,把链路跑通再逐步升级。一上来就搞集群和重排序,调试成本会让你怀疑人生。

3. 核心细节解析与实操要点

3.1 文档解析:脏数据是万恶之源

原始文档的质量直接决定后面所有环节的效果。PDF 是最麻烦的,尤其是扫描件和复杂排版。我的经验是:

  • 优先找结构化源:如果知识来自内部 Wiki 或数据库,直接走 API 拿 Markdown 或 JSON,别去解析导出的 PDF。
  • PDF 解析要验货:用 pdf-parse 或 pdfjs 解析后,务必人工抽查几页,看表格有没有错位、页眉页脚有没有混进正文。
  • 清洗规则前置:连续空行、页码、水印文字,在解析阶段就用正则清掉,别留到切分阶段。
import fs from "fs"; import pdf from "pdf-parse"; async function loadPdf(filePath: string): Promise<string> { const buffer = fs.readFileSync(filePath); const data = await pdf(buffer); // 清洗:去掉页码行、多余空行 return data.text .replace(/^\s*\d+\s*$/gm, "") .replace(/\n{3,}/g, "\n\n") .trim(); }

注意:解析出来的文本一定要存一份原始版本和清洗版本,出问题时能对比定位,别直接覆盖。

3.2 文本切分:决定检索质量的关键一步

切分的核心矛盾是:片段太大,检索精度下降;片段太小,语义不完整。我的实践参数是:

  • 目标片段长度 300 到 500 个 token,约合中文 400 到 700 字。
  • 相邻片段保留 10% 到 15% 的重叠(overlap),防止关键信息正好卡在边界上。
  • 优先按语义边界切:段落、标题、列表项,其次才是句号,最后才是硬切。
function splitByParagraph(text: string, maxLen = 500, overlap = 60): string[] { const paragraphs = text.split(/\n\n+/); const chunks: string[] = []; let current = ""; for (const para of paragraphs) { if ((current + para).length > maxLen && current.length > 0) { chunks.push(current.trim()); // 保留尾部重叠 current = current.slice(-overlap) + "\n\n" + para; } else { current += (current ? "\n\n" : "") + para; } } if (current.trim()) chunks.push(current.trim()); return chunks; }

这里有个细节:重叠部分我取的是上一个 chunk 的尾部,而不是简单复制。这样能保证跨段落的上下文连续。实测下来,带重叠的切分在问答类任务上召回率能提升一截。

3.3 向量化与存储:别忽视维度和成本

嵌入模型的选择要看两件事:维度和成本。维度越高表达能力越强,但存储和检索开销也越大。常见的有 768 维、1024 维、1536 维。中小知识库用 768 或 1024 维完全够用。

成本方面,API 嵌入模型按 token 计费,知识库首次全量向量化可能花掉一笔钱,但后续增量更新很便宜。如果数据敏感或量大,可以考虑本地部署嵌入模型,用 ONNX Runtime 在 Node 里跑,牺牲一点速度换零成本。

// 以 pgvector 为例的存储结构 // CREATE TABLE chunks ( // id SERIAL PRIMARY KEY, // content TEXT NOT NULL, // embedding vector(1024), // source TEXT, // chunk_index INT // ); async function storeChunk( content: string, embedding: number[], source: string, index: number ) { await db.query( "INSERT INTO chunks (content, embedding, source, chunk_index) VALUES ($1, $2, $3, $4)", [content, JSON.stringify(embedding), source, index] ); }

提示:向量字段一定要建索引(如 pgvector 的 ivfflat 或 hnsw),否则数据量一上来,检索会慢到无法接受。

3.4 检索策略:纯向量不够,混合才稳

纯向量检索有个硬伤:对精确匹配不敏感。用户问“产品型号 X200 的保修期”,向量检索可能返回一堆讲保修政策的片段,却没命中含“X200”的那条。解决办法是混合检索:向量相似度 + 关键词匹配(BM25 或全文索引),两路结果融合排序。

融合算法我常用 RRF(Reciprocal Rank Fusion),简单有效,不需要调权重:

function rrfFusion( vectorResults: string[], keywordResults: string[], k = 60 ): string[] { const scores = new Map<string, number>(); vectorResults.forEach((id, rank) => { scores.set(id, (scores.get(id) || 0) + 1 / (k + rank + 1)); }); keywordResults.forEach((id, rank) => { scores.set(id, (scores.get(id) || 0) + 1 / (k + rank + 1)); }); return [...scores.entries()] .sort((a, b) => b[1] - a[1]) .map(([id]) => id); }

RRF 的好处是不用关心两路分数的量纲差异,直接按排名融合。实测在混合场景下,比单纯向量检索的命中率高出一大截。

4. 实操过程与核心环节实现

4.1 从零搭一条最小可用管道

下面这条链路我跑通过很多次,你可以直接抄。假设知识源是一批 Markdown 文件,用 OpenAI 兼容的嵌入接口,向量存内存数组(生产环境换 pgvector)。

第一步,加载并切分所有文档:

import fs from "fs"; import path from "path"; interface Chunk { id: string; content: string; source: string; embedding?: number[]; } function loadAndSplit(dir: string): Chunk[] { const chunks: Chunk[] = []; const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")); for (const file of files) { const text = fs.readFileSync(path.join(dir, file), "utf-8"); const parts = splitByParagraph(text); parts.forEach((content, i) => { chunks.push({ id: `${file}-${i}`, content, source: file, }); }); } return chunks; }

第二步,批量向量化。这里要注意批处理,别一个 chunk 发一次请求,既慢又容易触发限流:

async function embedBatch(texts: string[]): Promise<number[][]> { const res = await fetch("https://api.example.com/v1/embeddings", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.EMBED_API_KEY}`, }, body: JSON.stringify({ model: "embedding-model", input: texts, }), }); const data = await res.json(); return data.data.map((d: any) => d.embedding); } async function buildIndex(chunks: Chunk[]) { const batchSize = 32; for (let i = 0; i < chunks.length; i += batchSize) { const batch = chunks.slice(i, i + batchSize); const vectors = await embedBatch(batch.map((c) => c.content)); batch.forEach((c, j) => (c.embedding = vectors[j])); console.log(`已处理 ${Math.min(i + batchSize, chunks.length)}/${chunks.length}`); } }

第三步,检索。把用户问题向量化,算余弦相似度,取 Top-K:

function cosineSimilarity(a: number[], b: number[]): number { let dot = 0, normA = 0, normB = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } async function retrieve(query: string, chunks: Chunk[], topK = 5): Promise<Chunk[]> { const [queryVec] = await embedBatch([query]); return chunks .map((c) => ({ chunk: c, score: cosineSimilarity(queryVec, c.embedding!) })) .sort((a, b) => b.score - a.score) .slice(0, topK) .map((r) => r.chunk); }

第四步,拼提示词生成回答:

async function answer(query: string, chunks: Chunk[]): Promise<string> { const context = chunks .map((c, i) => `[片段${i + 1}] 来源:${c.source}\n${c.content}`) .join("\n\n"); const prompt = `你是一个严谨的助手。请仅根据以下资料回答问题,资料中没有的信息不要编造,并注明来源。 资料: ${context} 问题:${query}`; const res = 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: "chat-model", messages: [{ role: "user", content: prompt }], }), }); const data = await res.json(); return data.choices[0].message.content; }

4.2 参数选择背后的计算逻辑

Top-K 取多少?我一般从 5 开始调。太小容易漏掉关键信息,太大则提示词变长、成本上升、还可能引入噪声干扰模型判断。一个粗略的估算:每个 chunk 约 500 token,K=5 就是 2500 token 的上下文,加上问题和系统提示,总输入在 3000 token 左右,主流模型都能轻松处理。

切分长度为什么定 500 token?因为嵌入模型通常有最大输入长度(常见 512 或 8192 token),超过会被截断。500 是个安全值,既留了余量,又保证单个片段语义相对完整。如果你的文档句子特别长,可以适当放宽到 800,但要同步测试检索效果。

4.3 增量更新:别每次全量重建

知识库会变,但全量重新向量化又慢又费钱。正确做法是按文档粒度做增量:记录每个文档的哈希值,只有内容变了才重新切分和向量化,并删除该文档的旧 chunk。

async function incrementalUpdate(file: string, chunks: Chunk[]) { const newText = fs.readFileSync(file, "utf-8"); const newHash = createHash("md5").update(newText).digest("hex"); const oldHash = await getStoredHash(file); if (newHash === oldHash) return; await deleteChunksBySource(file); const parts = splitByParagraph(newText); const newChunks = parts.map((content, i) => ({ id: `${file}-${i}`, content, source: file, })); await buildIndex(newChunks); await storeHash(file, newHash); }

这套逻辑跑起来,日常更新只处理变动的那几个文件,成本几乎可以忽略。

5. 常见问题与排查技巧实录

5.1 检索不准的排查顺序

检索效果差是最常见的问题,别急着换模型,按这个顺序查:

现象可能原因排查方法
完全答非所问切分太碎或太粗打印 Top-5 chunk 人工看
精确词查不到纯向量检索的短板加关键词混合检索
答案残缺Top-K 太小增大 K 值观察
答非所问但片段对提示词没约束好强化“仅根据资料”指令
相似度普遍偏低嵌入模型不匹配换模型或检查语言一致性

我踩过最深的一个坑:中英文混用的知识库,用了只擅长英文的嵌入模型,中文检索效果惨不忍睹。换模型后立刻好转。所以嵌入模型的语言支持一定要确认。

5.2 提示词里的“防幻觉”约束

RAG 不是万能的,模型仍可能无视检索结果自己编。提示词里必须明确三件事:只用给定资料、资料没有就说不知道、回答要标注来源。我常用的模板:

你是知识库助手。严格依据下方资料回答,禁止使用资料之外的知识。若资料不足以回答,直接说明“现有资料无法回答该问题”。回答末尾列出引用的片段编号。

这条约束能挡掉大部分幻觉。如果还不行,可以在生成后加一道校验:让模型自己检查回答里的每个事实是否能在资料中找到依据。

5.3 性能与成本的平衡

RAG 的成本主要在三块:嵌入、存储、生成。嵌入是一次性或增量成本,可控;存储看向量库选型;生成是持续成本,且随 Top-K 线性增长。优化手段:

  • 检索后做一次重排序,把最相关的 3 条留下,而不是把 Top-10 全塞进提示词。
  • 对高频问题做缓存,相同或相似问题直接返回历史答案。
  • 用更小的模型做初筛,大模型只负责最终生成。

5.4 几个容易被忽视的实操心得

第一,给 chunk 加上元数据。来源、标题、时间、章节,这些信息在检索时可以参与过滤,比如“只查最近半年的文档”,能大幅提升相关性。

第二,保留原始文档的层级结构。切分时把所属标题拼进 chunk 内容里,比如“## 退货政策\n\n退货需在 7 天内……”,这样即使片段被单独检索出来,模型也能知道它属于哪个主题。

第三,定期评估检索质量。准备一批“问题-标准答案”对,每次改动切分或检索策略后跑一遍,看命中率变化。没有评估的优化都是瞎猜。

第四,别迷信大而全的向量库。小知识库用内存数组完全够用,启动快、调试方便。等数据量真的上来了再迁移,迁移成本远低于一开始就过度设计。

6. 从基础 RAG 到 Agentic RAG 的演进方向

基础 RAG 跑通之后,你会发现它有几个天花板:单轮检索、固定 Top-K、无法处理需要多步推理的问题。这时候就该考虑 Agentic RAG 了——让 Agent 自己决定要不要检索、检索几次、用什么查询词。

一个典型的 Agentic RAG 循环是:Agent 先判断问题是否需要查知识库,需要就生成查询、检索、评估结果是否足够,不够就改写查询再检索,直到信息充分才生成回答。这比固定管道灵活得多,但也更复杂,需要仔细设计终止条件和成本控制。

TypeScript 生态里,LangChain.js 的 Agent 和 Tool 抽象已经能支撑这类实现。你可以把“检索”封装成一个 Tool,让 Agent 自主调用。这条路我还在摸索,等跑出稳定方案再单独写一篇。

最后分享一个我自己的体会:RAG 的效果,八成取决于数据质量,两成取决于技术实现。与其花时间调模型参数,不如先把文档清洗干净、切分合理。我见过太多团队在模型上反复折腾,却对着一堆脏数据视而不见。把知识获取管道的基础打牢,后面的 Agent 能力才有发挥的空间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询