☰
AI Agent 知识获取管道实战:TypeScript 构建 RAG 基础架构
2026/9/28 21:01:07 网站建设 项目流程

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): Promise<string> { const buffer = await fs.readFile(path); const { text } = await extractText(new Uint8Array(buffer)); return text.join('\n'); } async function extractFromHTML(html: string): Promise<string> { 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): Promise<number[][]> { 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匹配都行。合并时用 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); }

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 调用,但能显著减少噪声片段对答案的干扰,尤其是在召回阶段不够精准的时候。

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

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

立即咨询