ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI Agent 知识获取管道:TypeScript 实战 RAG 检索增强生成

AI Agent 知识获取管道:TypeScript 实战 RAG 检索增强生成 1. 为什么知识获取管道是 AI Agent 的分水岭做 AI Agent 开发的人迟早会撞上一堵墙模型本身很聪明但你问它公司内部某个产品的退货政策它要么胡编一个要么说“我无法获取最新信息”。这不是模型不行而是它缺了一条知识获取管道。RAG检索增强生成就是目前工程上最成熟、性价比最高的解法。我接触过不少团队模型选型讨论了好几轮提示词改了十几版最后卡在“回答不准”上。一查压根没做检索全靠模型参数里的那点公共知识硬撑。RAG 要解决的核心问题就一句话让模型在生成回答之前先去一个可信的知识库里把相关材料捞出来再基于材料说话。这跟开卷考试是一个道理——闭卷考的是记忆力开卷考的是检索和阅读理解能力而实际业务场景里我们几乎总是希望 Agent 开卷。这篇文章面向的是正在从零搭建 AI Agent、准备接入知识库的开发者尤其是用 TypeScript 做技术栈的团队。我会把 RAG 的基础链路拆开讲清楚每个环节在干什么、为什么这么设计、TypeScript 里怎么落地以及我踩过的那些坑。读完你应该能自己搭一条可用的知识获取管道而不是停留在“RAG 就是向量检索”这种模糊认知上。2. RAG 基础链路的整体设计与选型思路2.1 一条完整的知识获取管道长什么样很多人把 RAG 等同于“向量数据库 相似度搜索”这只说对了一半。一条能上生产的 RAG 管道至少包含五个阶段文档加载与解析把 PDF、Markdown、网页、数据库记录等原始材料读进来转成纯文本。文本切分Chunking把长文档切成大小合适的片段这是最容易被低估的一步。向量化Embedding用嵌入模型把每个片段转成向量存进向量库。检索Retrieval用户提问时把问题也向量化找出最相似的若干片段。生成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): Promisestring { 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 或全文索引两路结果融合排序。融合算法我常用 RRFReciprocal Rank Fusion简单有效不需要调权重function rrfFusion( vectorResults: string[], keywordResults: string[], k 60 ): string[] { const scores new Mapstring, 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[]): Promisenumber[][] { 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-Kfunction 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): PromiseChunk[] { 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[]): Promisestring { 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 tokenK5 就是 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 能力才有发挥的空间。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进