1. 为什么知识获取管道是 AI Agent 的分水岭
做 AI Agent 开发的人,绕不开一个尴尬的现实:模型本身很聪明,但它对你私有的东西一无所知。你问它公司内部某个接口的鉴权逻辑,它只能编;你问它三个月前那场故障的根因,它只能猜。这不是模型不行,而是它的知识被冻结在训练截止那一刻,且只覆盖公开语料。知识获取管道要解决的,就是把这个断层补上——让 Agent 在回答问题之前,先去"查资料",再"开口说话"。
这套机制在业内叫RAG(Retrieval-Augmented Generation,检索增强生成)。名字听着唬人,拆开看就三件事:把资料切好存起来、把用户问题变成能检索的形式、把检索到的片段塞进上下文让模型基于事实作答。我在实际项目里踩过的坑告诉我,RAG 的难点从来不在"生成",而在"检索"——检索不准,后面全是垃圾进垃圾出。
这篇文章适合三类人:一是刚开始搭 AI Agent、想搞懂知识管道怎么落地的新手;二是用 TypeScript 做全栈、想把 RAG 集成进现有服务的开发者;三是已经用过现成 RAG 框架、但效果不理想、想从底层理解问题出在哪的进阶选手。我会用 TypeScript 作为主语言来演示,因为它在 Agent 开发里生态成熟、类型安全,配合 Node 运行时做流式处理很顺手。整篇内容围绕"从 0 到 1 搭一条能用的知识获取管道"展开,不讲空泛概念,只讲能抄作业的实现。
先说清楚一个认知:RAG 不是"把文档丢给模型"这么简单。它是一条完整的管道,从原始文档到最终答案,中间有切分、向量化、存储、检索、重排、拼装、生成七个环节,每个环节都有取舍。下面我按搭建顺序,把每个环节的"为什么"和"怎么做"讲透。
2. RAG 管道的整体设计与选型思路
2.1 一条完整管道的七个环节
在动手写代码之前,先把管道的全貌画清楚。我习惯把它分成离线索引和在线检索两条链路:
离线索引链路(文档进来时跑一次或定期跑):
- 文档加载:把 PDF、Markdown、网页、数据库记录统一读成纯文本
- 文本切分:按语义或固定长度切成 chunk
- 向量化:调用 Embedding 模型把每个 chunk 转成向量
- 存储:向量 + 原文 + 元数据一起写进向量库
在线检索链路(用户提问时跑): 5. 查询向量化:把用户问题转成同维度向量 6. 相似度检索:在向量库里找 Top-K 最相近的 chunk 7. 重排与拼装:对候选做精排,拼进 Prompt,交给 LLM 生成
这七个环节里,切分策略和检索策略是决定效果的两个大头。很多人一上来就调模型参数,其实方向错了——切分切得稀碎,检索再准也拼不出完整语义;检索只做单路向量召回,遇到专有名词就抓瞎。
2.2 为什么选 TypeScript 而不是 Python
RAG 的教程铺天盖地都是 Python,但我这套管道坚持用 TypeScript,理由很实在:
- 类型安全:chunk 的元数据结构、检索结果的返回类型,用 interface 定义清楚,重构时编译器帮你兜底,不会出现"字段名写错了运行时才炸"的情况。
- 前后端同构:如果你的 Agent 有 Web 界面,检索逻辑可以直接在 Node 侧复用,不用维护两套。
- 流式处理天然友好:Node 的 Stream API 配合 SSE,做逐字返回的对话体验非常顺。
- 生态够用:向量库有官方的 JS SDK,Embedding 和 LLM 调用就是 HTTP 请求,没有非 Python 不可的理由。
当然,如果你团队全是 Python 栈,那另说。技术选型看的是团队和场景,不是网上谁声音大。
2.3 向量库怎么选:从内存到生产
选向量库是新手最容易纠结的地方。我的建议是按阶段来:
| 阶段 | 推荐方案 | 理由 |
|---|---|---|
| 本地验证 | 内存数组 + 余弦相似度 | 零依赖,几百条数据够用,方便调试 |
| 小规模上线 | SQLite + 向量扩展 / 本地文件索引 | 单机部署简单,数据量万级以内没问题 |
| 生产环境 | 专业向量数据库(如支持 HNSW 索引的服务) | 支持增量更新、过滤、水平扩展 |
我见过太多人一上来就上重型向量库,结果本地跑个 demo 都要配半天环境,热情直接磨没了。先用内存版把管道跑通,再按需替换存储层,这是最省心的路径。存储层只要抽象成一个接口,后面换实现就是改一个类的事。
3. 核心细节解析与实操要点
3.1 文本切分:RAG 效果的第一道生死线
切分这件事,看起来最简单,实际上最考验功力。切得太长,一个 chunk 里混了好几个主题,检索出来噪声大;切得太短,一句话被拦腰截断,语义不完整,模型拿到也拼不出意思。
我常用的策略是递归字符切分 + 重叠窗口:
- 优先按段落(
\n\n)切 - 段落太长就按句子(
。、!、?、.)切 - 还太长就按固定字符数硬切
- 每个 chunk 之间保留 10%~20% 的重叠,防止边界信息丢失
参数上,我一般把 chunk 大小设在500~800 字符,重叠80~150 字符。为什么是这个区间?因为主流 Embedding 模型对 512 token 左右的输入表达最充分,超过之后语义会被"平均"掉,检索精度反而下降。中文一个字符大约对应 1~1.5 个 token,所以 500~800 字符是个稳妥的起点。
注意:切分时一定要保留元数据,比如来源文件名、章节标题、页码。检索命中后,这些信息能帮你在 Prompt 里标注出处,也能让用户知道答案从哪来。
3.2 向量化:Embedding 模型的取舍
Embedding 模型负责把文本变成向量。选型时看三个指标:维度、语义质量、成本。
维度不是越高越好。高维度表达能力强,但存储和检索成本线性上升。常见的中文友好模型维度在 768~1536 之间,这个区间对大多数场景够用。语义质量要看模型在中文检索基准上的表现,别只看英文榜单。成本则要算清楚:索引阶段是一次性开销,检索阶段是每次提问都要跑,量大起来很可观。
实操上有个细节容易被忽略:查询和文档必须用同一个 Embedding 模型。用 A 模型索引、B 模型查询,向量空间对不上,检索结果就是随机的。我见过有人换模型时只改了查询侧,结果线上检索全崩,排查了半天才发现是模型不一致。
3.3 检索策略:单路召回为什么不够
只用向量相似度做召回,会遇到两个典型问题:
- 专有名词失效:用户问"XX-2024 接口的超时配置",向量模型可能把"XX-2024"当成噪声,召回一堆泛泛的超时文档。
- 精确匹配缺失:用户明确要某个函数名、某个错误码,向量检索反而不如关键词匹配准。
所以生产级 RAG 普遍用混合检索:向量召回 + 关键词召回(BM25 之类),两路结果融合。融合算法常用 RRF(Reciprocal Rank Fusion),它不依赖分数归一化,只看排名,鲁棒性好。
再往上一层是重排(Rerank):先用便宜的向量检索召回 Top-50,再用精排模型对候选打分,取 Top-5 送进 LLM。精排模型比向量模型慢,但只对少量候选跑,总体成本可控,效果提升明显。这一步是很多"效果不好"的 RAG 系统的救命稻草。
4. 用 TypeScript 从零实现一条管道
4.1 项目结构与依赖
先搭骨架。我习惯把管道拆成独立模块,方便替换:
rag-pipeline/ ├── src/ │ ├── loader.ts // 文档加载 │ ├── splitter.ts // 文本切分 │ ├── embedder.ts // 向量化 │ ├── store.ts // 向量存储与检索 │ ├── retriever.ts // 检索编排(含混合检索) │ └── pipeline.ts // 对外主入口 ├── package.json └── tsconfig.json依赖上,核心只需要一个 HTTP 客户端(Node 18+ 自带 fetch)和一个向量库 SDK。切分逻辑我自己写,因为现成库的默认参数不一定适合中文。
4.2 文本切分的实现
先定义 chunk 的数据结构,这是整条管道的数据契约:
export interface Chunk { id: string; text: string; metadata: { source: string; // 来源文件 section?: string; // 章节标题 chunkIndex: number; // 在原文中的序号 }; }然后是递归切分函数。核心思路是维护一个分隔符优先级列表,逐级降级:
const SEPARATORS = ["\n\n", "\n", "。", "!", "?", ". ", " ", ""]; export function splitText( text: string, source: string, chunkSize = 600, overlap = 100 ): Chunk[] { const chunks: Chunk[] = []; let index = 0; function recurse(segment: string, sepIndex: number): void { if (segment.length <= chunkSize || sepIndex >= SEPARATORS.length) { if (segment.trim()) { chunks.push({ id: `${source}-${index}`, text: segment.trim(), metadata: { source, chunkIndex: index }, }); index++; } return; } const sep = SEPARATORS[sepIndex]; const parts = segment.split(sep); let buffer = ""; for (const part of parts) { const candidate = buffer ? buffer + sep + part : part; if (candidate.length > chunkSize && buffer) { recurse(buffer, sepIndex + 1); // 保留重叠部分 buffer = buffer.slice(-overlap) + sep + part; } else { buffer = candidate; } } if (buffer.trim()) recurse(buffer, sepIndex + 1); } recurse(text, 0); return chunks; }这段代码的关键在于降级逻辑:先尝试用大分隔符切,切出来的块还是太大就换更细的分隔符继续切。重叠部分用slice(-overlap)保留尾部,保证跨块的语义连续。
实操心得:中文标点后面往往没有空格,所以分隔符列表里要把中文标点单独列出来。我一开始只写了
\n\n和空格,结果中文长段落根本切不动,全被硬切成固定长度,检索效果惨不忍睹。
4.3 向量化与存储层
Embedding 调用就是发 HTTP 请求,封装成一个函数:
export async function embed(texts: string[]): Promise<number[][]> { const res = await fetch(`${EMBEDDING_BASE_URL}/embeddings`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}`, }, body: JSON.stringify({ model: EMBEDDING_MODEL, input: texts }), }); if (!res.ok) throw new Error(`Embedding failed: ${res.status}`); const data = await res.json(); return data.data.map((d: any) => d.embedding); }存储层先做内存版,把向量和 chunk 一起存,检索时算余弦相似度:
export class MemoryStore { private items: { chunk: Chunk; vector: number[] }[] = []; add(chunk: Chunk, vector: number[]): void { this.items.push({ chunk, vector }); } search(queryVec: number[], topK = 5): Chunk[] { const scored = this.items.map((item) => ({ chunk: item.chunk, score: cosine(queryVec, item.vector), })); scored.sort((a, b) => b.score - a.score); return scored.slice(0, topK).map((s) => s.chunk); } } function cosine(a: number[], b: number[]): number { let dot = 0, na = 0, nb = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; } return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-8); }内存版的好处是调试直观,你可以直接打印每个 chunk 的得分,看检索到底命中了什么。等管道跑通,把MemoryStore换成向量库 SDK 的实现即可,上层代码不用动。
4.4 混合检索与重排的编排
检索编排层把向量召回和关键词召回合起来。关键词召回我用一个简单的 BM25 近似实现,核心是词频和逆文档频率:
export function keywordSearch( query: string, chunks: Chunk[], topK = 5 ): Chunk[] { const terms = query.toLowerCase().split(/\s+|(?=[\u4e00-\u9fa5])/).filter(Boolean); const scored = chunks.map((chunk) => { const text = chunk.text.toLowerCase(); let score = 0; for (const term of terms) { const tf = (text.split(term).length - 1); if (tf > 0) score += 1 + Math.log(tf); } return { chunk, score }; }); scored.sort((a, b) => b.score - a.score); return scored.slice(0, topK).map((s) => s.chunk); }两路结果用 RRF 融合,公式是score = Σ 1/(k + rank),k 一般取 60:
export function rrfFuse( resultLists: Chunk[][], k = 60, topK = 5 ): Chunk[] { const scores = new Map<string, number>(); const chunkMap = new Map<string, Chunk>(); for (const list of resultLists) { list.forEach((chunk, rank) => { chunkMap.set(chunk.id, chunk); const prev = scores.get(chunk.id) ?? 0; scores.set(chunk.id, prev + 1 / (k + rank + 1)); }); } return [...scores.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, topK) .map(([id]) => chunkMap.get(id)!); }RRF 的好处是不用管两路分数尺度不同的问题,只看排名,融合结果稳定。实测下来,混合检索比单路向量召回在专有名词场景下命中率提升非常明显。
4.5 拼装 Prompt 与生成
最后一步是把检索到的 chunk 拼进 Prompt。这里有个原则:明确告诉模型"只基于给定资料回答,资料里没有就说不知道",否则模型会忍不住用自己的知识瞎编。
export function buildPrompt(query: string, chunks: Chunk[]): string { const context = chunks .map((c, i) => `[资料${i + 1}] 来源:${c.metadata.source}\n${c.text}`) .join("\n\n"); return `你是一个严谨的助手。请仅根据下面的资料回答问题。 如果资料中没有相关信息,请直接回答"资料中未提及",不要编造。 ${context} 问题:${query} 回答:`; }生成阶段调用 LLM 的对话接口,把拼好的 Prompt 发过去。如果要流式返回,用 SSE 逐块推给前端,体验会好很多。
5. 常见问题与排查技巧实录
5.1 检索命中率低的排查顺序
检索不准是最常见的问题。我总结了一套排查顺序,从便宜到贵依次试:
| 排查项 | 检查方法 | 常见原因 |
|---|---|---|
| 模型一致性 | 打印索引和查询用的模型名 | 两侧模型不一致,向量空间错位 |
| 切分质量 | 随机抽几个 chunk 看内容 | 切得太碎或太长,语义不完整 |
| 查询改写 | 看用户原始问题是否口语化 | 口语和文档书面语差距大 |
| 召回数量 | 把 Top-K 调大看是否命中 | K 太小,正确结果被挤掉 |
| 混合检索 | 加关键词召回对比 | 纯向量对专有名词不敏感 |
| 重排 | 加精排模型对比 | 粗排把正确结果排到后面 |
我遇到最多的是切分问题。有一次文档里全是表格,按段落切出来每个 chunk 都是残缺的表格行,检索出来完全没法用。后来针对表格单独做了处理,把整张表作为一个 chunk,问题才解决。
5.2 模型"幻觉"仍然严重怎么办
即使给了资料,模型有时还是会编。除了在 Prompt 里强调"只基于资料",还有几个技巧:
- 要求标注出处:让模型在答案里带上
[资料N],这样你能核对它到底用了哪段。 - 降低温度:生成时把 temperature 调到 0.1~0.3,减少发挥。
- 资料不足时明确拒答:在 Prompt 里给出拒答模板,比让模型自由发挥安全得多。
- 后置校验:对关键答案做一次事实核对,比如把答案再丢给模型判断"是否与资料矛盾"。
注意:幻觉不可能完全消除,只能降低。对准确性要求极高的场景,一定要保留人工复核环节,别把 RAG 当成万能药。
5.3 增量更新与数据一致性
文档会变,索引也得跟着变。全量重建索引成本高,尤其是文档量大时。我的做法是:
- 给每个文档算一个内容哈希,存进元数据
- 更新时先比对哈希,没变就跳过
- 变了就删掉该文档的所有旧 chunk,重新切分索引
- 删除文档时,按 source 字段批量删对应 chunk
这样增量更新只处理变化的文档,速度快很多。但要注意,向量库的删除操作不一定即时生效,有些实现是标记删除,检索时还要过滤,具体看用的库的文档。
5.4 性能与成本的平衡
RAG 的成本主要在 Embedding 调用和 LLM 生成。优化思路:
- 索引阶段批处理:Embedding 接口一般支持一次传多条文本,批量调用比逐条快得多,也省钱。
- 缓存查询向量:高频问题可以缓存查询向量,避免重复调用。
- 控制召回数量:Top-K 不是越大越好,召回太多既增加 LLM 输入成本,又引入噪声。一般 3~8 条够用。
- 精排只对候选跑:别对全库跑精排,先粗排缩小范围。
我在一个项目里把 Top-K 从 20 降到 5,配合重排,答案质量没降,LLM 调用成本降了将近一半。这个账要算清楚。
6. 从能跑到好用:几个进阶方向
管道跑通只是起点。想让 RAG 真正好用,还有几个方向值得投入。
查询改写是性价比很高的一环。用户的问题往往口语化、指代不清,直接拿去检索效果差。可以在检索前加一步:让 LLM 把用户问题改写成更适合检索的形式,或者生成多个查询变体分别检索再融合。这一步能显著提升召回率,成本也不高。
父子块检索解决的是"检索用小块、生成用大块"的矛盾。索引时存小 chunk 保证检索精度,但每个小 chunk 关联一个更大的父块,命中后把父块送给 LLM,保证上下文完整。这个思路在长文档场景下效果很好。
元数据过滤能让检索更精准。比如用户问的是某个产品线的问题,就先按产品线字段过滤,再在子集里做向量检索。这比纯语义检索可靠得多,尤其适合结构化程度高的企业知识库。
评估体系是最容易被忽略但最重要的。没有评估,你根本不知道改动是变好还是变坏。我一般准备一组"问题-标准答案"对,每次改动后跑一遍,看命中率和答案准确率的变化。评估集不用大,几十条有代表性的就够,关键是持续维护。
最后分享一个我踩过的坑:别在管道还没跑通时就追求完美。我见过有人花两周调切分参数,结果整个管道还没端到端跑通过一次。正确的顺序是先让最粗糙的版本跑起来,看到输入输出,再逐个环节优化。RAG 是个系统工程,局部最优不等于全局最优,先有全局再谈细节。
这套管道我在几个内部知识库项目里都用过,从内存版起步,逐步替换成生产级存储和混合检索,整个过程平滑可控。TypeScript 的类型系统在重构时帮了大忙,每次换实现,编译器都会把不兼容的地方标出来。如果你也在搭 Agent 的知识管道,建议就从这份骨架开始,先跑通,再打磨。