ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

AI Agent 知识获取管道实战:TypeScript 构建 RAG 基础架构

AI Agent 知识获取管道实战:TypeScript 构建 RAG 基础架构 1. 为什么知识获取管道是 AI Agent 落地的第一道坎做 AI Agent 开发的人绕不开一个尴尬的现实模型本身很聪明但它不知道你公司内部的 API 文档长什么样不知道你上周刚改的那份产品需求更不知道你私有的那套业务规则。你问它一个通用问题它答得头头是道你问它一个只有你们团队才知道的细节它要么胡编要么直接摆烂说“我无法获取实时信息”。这就是知识获取管道要解决的核心问题。所谓管道不是单一的一个检索接口而是一条从原始数据到最终注入模型上下文的完整链路。它包含数据的采集、清洗、切分、向量化、存储、检索、重排、拼装这几个环节。任何一个环节出问题最终模型拿到的上下文就是脏的、缺的、或者不相关的回答质量自然崩盘。我见过太多团队在这一步翻车。有人直接把 PDF 扔进向量库就完事结果检索出来的全是页眉页脚和乱码有人切分粒度没调好一个完整的业务规则被切成三段检索到其中一段根本读不懂还有人只做了向量检索没有做关键词兜底遇到专有名词直接歇菜。这些问题不是模型能力不够是管道没搭好。这篇文章面向的是正在从 0 到 1 搭建 AI Agent 的开发者尤其是用 TypeScript 做技术栈的团队。我会把 RAG 基础这条链路拆开讲清楚每个环节在做什么、为什么这么做、以及实际落地时最容易踩的坑。读完你应该能自己搭一条可用的知识获取管道而不是停留在“调个 API 就完事”的阶段。2. RAG 基础架构的整体设计与选型思路2.1 为什么是 RAG 而不是微调很多人第一反应是模型不知道我的数据那我微调一下不就行了。这个思路在特定场景下成立但对绝大多数 AI Agent 项目来说微调是最后才考虑的选项原因有三。第一成本。微调需要标注数据、需要 GPU 资源、需要反复迭代。你改一次业务规则就得重新训一遍这个周期和成本对快速迭代的 Agent 项目来说不可接受。RAG 不一样你改数据只需要更新知识库模型本身不动。第二时效性。微调后的模型知识是冻结在权重里的你今天训完明天业务变了它就过时了。RAG 的检索是实时的数据源更新了下一次查询就能拿到新内容。第三可解释性。RAG 能告诉你答案是从哪段文档里检索出来的方便排查和审计。微调模型给出的答案你很难追溯它到底用了哪条知识。所以 RAG 的本质是把“知识”从模型权重里解耦出来放到外部可维护的存储中模型只负责理解和生成。这个设计思路决定了整条管道的架构。2.2 管道的五个核心阶段一条完整的知识获取管道我习惯把它拆成五个阶段数据接入把各种格式的原始数据Markdown、PDF、HTML、数据库记录读进来统一成纯文本。切分与清洗把长文本切成适合检索的片段同时去掉噪声。向量化与存储把文本片段转成向量存进向量数据库同时保留原文和元数据。检索与重排根据用户查询召回相关片段再用重排模型精排。上下文拼装把检索结果按一定策略拼进 Prompt交给模型生成。这五个阶段里前两个决定了知识库的质量上限后三个决定了检索的准确率。很多人只关注向量数据库选哪个却忽略了切分策略才是影响效果最大的变量。2.3 TypeScript 技术栈的选型考量用 TypeScript 做 RAG 管道生态上确实不如 Python 丰富但也不是不能用。核心选型我建议这样考虑环节推荐方案选型理由文档解析unpdf、mammoth、cheerio轻量纯 JS 实现无需额外运行时文本切分自己实现递归切分逻辑简单可控性强避免引入重依赖向量化OpenAI Embeddings API 或本地模型API 省事本地模型省成本且数据不出域向量存储pgvector、Qdrant、LanceDBpgvector 适合已有 Postgres 的团队LanceDB 适合嵌入式场景检索框架自己写或 LangChain.js自己写可控LangChain.js 生态全但抽象层厚我个人的偏好是核心链路自己写只在向量存储和 Embedding 调用上用现成库。原因是 RAG 管道的逻辑并不复杂自己写能完全掌控每个环节的参数出问题也好排查。LangChain.js 这类框架抽象层太厚调试的时候你得先读懂它的抽象才能定位问题反而拖慢进度。3. 数据接入与切分决定知识库质量的关键环节3.1 数据接入的常见格式与处理方式实际项目里数据来源五花八门。我整理了几种最常见的格式和处理要点Markdown 文件是最友好的直接读文本就行但要注意去掉代码块里的无关内容以及处理表格——表格在切分时容易被切碎建议把整个表格作为一个片段保留。PDF 文件是最麻烦的。PDF 本质是排版格式不是内容格式。用 unpdf 这类库提取文本时经常遇到页眉页脚混入、多栏排版顺序错乱、表格结构丢失的问题。我的做法是先用库提取再用正则清洗掉重复出现的页眉页脚模式对于多栏 PDF 尽量找原始文档或者手动整理。HTML 页面用 cheerio 解析重点是去掉 nav、footer、script、style 这些噪声标签只保留正文区域。很多网站的正文有特定的 class 或 id可以针对性提取。数据库记录相对简单但要注意把结构化数据转成自然语言描述。比如一条用户表记录不要直接拼成{id: 1, name: 张三}而是转成“用户张三ID 为 1”这样 Embedding 出来的向量才有语义。// 一个简单的多格式文本提取示例 import { extractText } from unpdf; import * as cheerio from cheerio; import * as fs from fs/promises; async function extractFromPDF(path: string): Promisestring { const buffer await fs.readFile(path); const { text } await extractText(new Uint8Array(buffer)); return text.join(\n); } async function extractFromHTML(html: string): Promisestring { const $ cheerio.load(html); $(nav, footer, script, style, header).remove(); return $(body).text().replace(/\s/g, ).trim(); }3.2 切分策略固定长度还是语义切分切分是 RAG 里最容易被低估的环节。切太大检索出来的片段包含太多无关信息模型容易被干扰切太小一个完整的语义单元被拆散检索到片段也读不懂。固定长度切分是最简单的做法比如每 500 个字符切一刀重叠 50 个字符。这种做法实现简单但问题是它会在句子中间切断导致语义不完整。语义切分是更好的选择核心思路是优先在段落、句子边界切分只有在单个段落超过阈值时才强制切分。我通常用递归切分先按双换行切段落段落还太长就按单换行切再长就按句号切最后才按字符数硬切。function recursiveSplit(text: string, maxLen: number, overlap: number): string[] { const separators [\n\n, \n, 。, ., , ]; function split(txt: string, sepIndex: number): string[] { if (txt.length maxLen) return [txt]; if (sepIndex separators.length) { // 硬切 const chunks: string[] []; for (let i 0; i txt.length; i maxLen - overlap) { chunks.push(txt.slice(i, i maxLen)); } return chunks; } const sep separators[sepIndex]; const parts txt.split(sep); const result: string[] []; let current ; for (const part of parts) { const candidate current ? current sep part : part; if (candidate.length maxLen) { current candidate; } else { if (current) result.push(current); if (part.length maxLen) { result.push(...split(part, sepIndex 1)); current ; } else { current part; } } } if (current) result.push(current); return result; } return split(text, 0); }切分粒度上我的经验值是中文 300 到 500 字英文 500 到 800 字符。重叠部分取切分长度的 10% 到 15%。这个范围在检索准确率和上下文完整性之间比较平衡。3.3 元数据设计别只存文本很多人建向量库的时候只存文本和向量这是不够的。元数据在检索阶段能帮你做过滤在拼装阶段能帮你做引用标注。我建议至少存这几个字段source来源文件路径或 URL、chunk_index片段在原文中的序号、heading所属章节标题、updated_at更新时间。有了这些你可以实现“只检索某个文档”“只检索最近更新的内容”“按章节聚合结果”等能力。注意元数据字段不要太多否则存储和检索开销都会上去。只存那些你确定会在过滤或展示时用到的字段。4. 向量化、存储与检索的实操细节4.1 Embedding 模型的选择与调用Embedding 模型决定了文本转向量后的语义表达能力。选型时主要看三个维度维度数、语言支持、成本。维度数越高表达能力越强但存储和检索开销也越大。常见的 1536 维OpenAI text-embedding-3-small和 1024 维BGE-M3在大多数场景下够用。如果你的知识库以中文为主BGE 系列的中文效果通常比 OpenAI 的通用模型更好。调用 Embedding API 时要注意批量处理。一条一条调太慢一次调太多又容易超时。我的做法是每批 50 到 100 条并发控制在 5 到 10 个请求。async function embedBatch(texts: string[], batchSize 64): Promisenumber[][] { const results: number[][] []; for (let i 0; i texts.length; i batchSize) { const batch texts.slice(i, i batchSize); const response await fetch(https://api.openai.com/v1/embeddings, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.OPENAI_API_KEY} }, body: JSON.stringify({ model: text-embedding-3-small, input: batch }) }); const data await response.json(); results.push(...data.data.map((d: any) d.embedding)); } return results; }4.2 向量存储的选型与索引配置向量存储的选择取决于你的部署环境和数据规模。小规模几万条以内用 LanceDB 这种嵌入式方案最省事不需要额外部署服务。中等规模几十万到几百万用 pgvector 或 Qdrant 比较合适。再大就要考虑专门的向量数据库集群了。pgvector 的优势是你如果已经在用 Postgres直接加个扩展就行不用维护新服务。建索引时用 HNSW 比 IVFFlat 查询更快但建索引更慢、占内存更多。数据量不大的话不建索引直接暴力搜索也能接受。-- pgvector 建表和索引示例 CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE knowledge_chunks ( id SERIAL PRIMARY KEY, content TEXT NOT NULL, embedding vector(1536), source TEXT, chunk_index INT, heading TEXT, updated_at TIMESTAMP DEFAULT NOW() ); -- HNSW 索引适合查询频繁的场景 CREATE INDEX ON knowledge_chunks USING hnsw (embedding vector_cosine_ops) WITH (m 16, ef_construction 64);4.3 检索策略向量检索不够要加关键词兜底纯向量检索有个致命问题对专有名词、缩写、代码标识符不敏感。比如你搜“RAG 管道”向量检索可能召回一堆讲“数据管道”的内容因为语义相近。但你真正想要的是包含“RAG”这个词的片段。解决办法是混合检索向量检索和关键词检索各跑一遍然后合并结果。关键词检索用 Postgres 的全文检索或者简单的ILIKE匹配都行。合并时用 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); }4.4 重排把最相关的片段顶上来检索召回阶段通常会取 Top 20 到 Top 50 个候选但最终拼进 Prompt 的可能只有 5 到 10 个。这中间的筛选就靠重排。重排模型Reranker和 Embedding 模型不同它是对“查询-片段”对做精细打分计算量更大但更准。常见的方案是用 Cohere Rerank 或者本地的 BGE-Reranker。如果不想引入重排模型也有轻量替代方案用 LLM 对候选片段做相关性打分。虽然慢一点但效果不错而且不用额外部署模型。实操心得重排的收益在候选集大的时候最明显。如果你召回阶段只取 Top 5重排提升有限如果取 Top 50重排能把准确率提升 20% 以上。5. 上下文拼装与常见问题排查5.1 Prompt 拼装的策略与陷阱检索到相关片段后怎么拼进 Prompt 也有讲究。最朴素的做法是把片段直接拼接但这会带来几个问题。第一片段之间可能重复。检索时不同片段可能包含相同内容直接拼进去浪费上下文窗口。我的做法是在拼装前做一次去重按内容相似度过滤。第二片段顺序影响模型理解。把最相关的片段放在最前面和最后面模型更容易注意到。中间位置的片段容易被忽略这是 LLM 的“中间遗忘”现象。第三要明确告诉模型哪些是参考资料哪些是用户问题。我通常用这样的结构以下是从知识库中检索到的参考资料 [1] 来源xxx.md 内容... [2] 来源yyy.md 内容... 请基于以上参考资料回答用户问题。如果参考资料中没有相关信息请明确说明。 用户问题...注意一定要加“如果参考资料中没有相关信息请明确说明”这句话。否则模型会倾向于用自己预训练的知识胡编而不是承认不知道。5.2 常见问题速查表问题现象可能原因排查方向解决方法检索结果完全不相关Embedding 模型不适合当前语言检查模型语言支持换用中文优化的 Embedding 模型检索结果相关但答案不对切分粒度太大噪声多检查片段长度减小切分粒度增加重排专有名词搜不到纯向量检索对关键词不敏感检查是否有混合检索加入关键词检索和 RRF 融合答案胡编乱造Prompt 没有约束检查 Prompt 模板加入“无相关信息请说明”约束检索速度慢向量索引未建或数据量大检查索引配置建 HNSW 索引或加缓存更新数据后检索不到索引未刷新检查数据同步流程建立增量更新机制5.3 几个我踩过的坑第一个坑是切分时把代码块切碎了。技术文档里的代码块如果被从中间切断检索出来的片段根本没法用。解决办法是在切分前识别代码块把整个代码块作为一个不可分割的单元。第二个坑是Embedding 模型和检索模型不一致。建库时用了一个模型查询时用了另一个向量空间对不上检索结果自然乱套。这个错误很隐蔽因为两边都不报错只是结果不对。一定要确保建库和查询用同一个 Embedding 模型。第三个坑是忽略了元数据过滤。早期我没存 source 字段后来想实现“只搜某个文档”的功能时发现做不到只能重建整个库。所以元数据设计要提前想清楚。第四个坑是Top K 设得太大。一开始我觉得召回越多越好设了 Top 50结果 Prompt 塞满了不相关内容模型反而被干扰。后来改成召回 20 个重排后取 5 个效果明显提升。5.4 增量更新与版本管理知识库不是建一次就完事的数据会变。增量更新的核心是只重新处理变化的文档而不是全量重建。实现上我给每个文档算一个内容哈希存进数据库。更新时对比哈希变了才重新切分和向量化。删除文档时按 source 字段批量删除对应的片段。版本管理上我建议保留每次更新的时间戳检索时可以按时间过滤。这样如果新数据有问题可以快速回滚到旧版本。import { createHash } from crypto; function contentHash(content: string): string { return createHash(sha256).update(content).digest(hex); } async function syncDocument(source: string, content: string) { const hash contentHash(content); const existing await db.query( SELECT hash FROM documents WHERE source $1, [source] ); if (existing.rows[0]?.hash hash) { return; // 内容未变跳过 } // 删除旧片段 await db.query(DELETE FROM knowledge_chunks WHERE source $1, [source]); // 重新切分、向量化、插入 const chunks recursiveSplit(content, 500, 50); const embeddings await embedBatch(chunks); for (let i 0; i chunks.length; i) { await db.query( INSERT INTO knowledge_chunks (content, embedding, source, chunk_index) VALUES ($1, $2, $3, $4), [chunks[i], JSON.stringify(embeddings[i]), source, i] ); } await db.query( INSERT INTO documents (source, hash, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (source) DO UPDATE SET hash $2, updated_at NOW(), [source, hash] ); }6. 从基础 RAG 到 Agentic RAG 的演进方向基础 RAG 跑通之后你会遇到新的问题单次检索不够用。用户的问题可能需要多步推理第一步检索到的信息用来生成第二步的查询再检索再推理。这就是 Agentic RAG 的思路。Agentic RAG 的核心是把检索变成 Agent 的一个工具而不是固定流程。Agent 自己决定什么时候检索、检索什么、检索几次。实现上你需要把检索封装成一个函数注册给 Agent 的 tool 列表然后在 Prompt 里告诉 Agent 这个工具的用途。另一个方向是 GraphRAG把知识图谱和向量检索结合。对于实体关系复杂的领域比如法律、医疗GraphRAG 能捕捉到向量检索漏掉的关系信息。不过 GraphRAG 的构建成本高很多需要先做实体抽取和关系抽取适合知识结构稳定的场景。我的建议是先把基础 RAG 跑稳把切分、检索、重排这几个环节的参数调优再考虑往 Agentic 方向演进。基础没打好就上高级方案问题会更多。最后分享一个我在实际项目里验证过的小技巧在检索结果拼装进 Prompt 之前加一步“相关性自检”——让 LLM 对每个候选片段打一个 0 到 1 的相关性分低于阈值的直接丢掉。这一步虽然多花一次 LLM 调用但能显著减少噪声片段对答案的干扰尤其是在召回阶段不够精准的时候。
返回列表