news 2026/11/4 15:01:27

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
TypeScript 从零搭建 RAG 知识获取管道:AI Agent 检索增强生成实战

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 一条完整管道的七个环节

在动手写代码之前,先把管道的全貌画清楚。我习惯把它分成离线索引和在线检索两条链路:

离线索引链路(文档进来时跑一次或定期跑):

  1. 文档加载:把 PDF、Markdown、网页、数据库记录统一读成纯文本
  2. 文本切分:按语义或固定长度切成 chunk
  3. 向量化:调用 Embedding 模型把每个 chunk 转成向量
  4. 存储:向量 + 原文 + 元数据一起写进向量库

在线检索链路(用户提问时跑): 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 的知识管道,建议就从这份骨架开始,先跑通,再打磨。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 15:00:17

PADS、AD、Cadence三套EDA工具互转实操指南:四条链路与避坑清单

简介&#xff1a;面向电子设计从业者与初学者的EDA工具互转实操文档&#xff0c;聚焦PADS、Altium Designer与Cadence三大平台之间设计文件的跨工具迁移难题。内容覆盖PADS转AD、AD转Cadence、AD转PADS、Cadence转PADS四大方向&#xff0c;并细分原理图与PCB两条路径&#xff0…

作者头像 李华
网站建设 2026/11/4 14:57:35

基于GNN的新冠疫情时空预测模型实践

1. 项目背景与核心价值 去年疫情最严重的时候&#xff0c;我在医院工作的朋友经常抱怨床位和呼吸机调配困难。当时我就在想&#xff0c;如果能提前几天预测疫情发展趋势&#xff0c;医疗资源的调度会不会更从容&#xff1f;这个想法促使我开始了"新冠预测"这个深度学…

作者头像 李华
网站建设 2026/11/4 14:56:47

FlyEnv:轻量级本地开发环境解决方案

1. 为什么我们需要更轻量的本地开发环境&#xff1f; 作为一名长期奋战在开发一线的程序员&#xff0c;我深知环境配置的痛苦。记得去年接手一个老项目&#xff0c;光是搭建本地开发环境就花了两天时间——安装特定版本的PHP、配置Nginx虚拟主机、折腾MySQL权限&#xff0c;最…

作者头像 李华
网站建设 2026/11/4 14:56:48

Spring Boot 外部 YAML 解析:SnakeYAML 绑定与安全

接手一个偏数据侧的 Spring Boot 服务时&#xff0c;需求看着不复杂&#xff1a;从一份几十行的 YAML 里读出若干条清洗规则&#xff0c;跑批时按规则处理数据。Spring Boot 对 YAML 的支持已经够好了&#xff0c;application.yml 写得飞起&#xff0c;可真到「在业务代码里读一…

作者头像 李华
网站建设 2026/11/4 14:56:10

5分钟用pnpm搭建React原型:依赖管理与Vite实践

pnpm 装 React 原型这件事&#xff0c;说起来简单&#xff0c;但我见过太多人卡在第一步——敲完命令盯着终端里滚动的进度条发呆&#xff0c;或者第二天发现 C 盘少了好几个 G。原型项目的特点很明确&#xff1a;起得快、扔得也快、数量还多&#xff0c;一个人手里同时挂着三五…

作者头像 李华
网站建设 2026/11/4 14:55:20

Flink 2.2实时数据集成实战:Kinesis与DynamoDB连接方案

1. 项目背景与核心价值 去年在帮一家跨境电商做实时数仓改造时&#xff0c;我们遇到了一个典型的数据孤岛问题——用户行为数据存在Kinesis&#xff0c;交易日志在DynamoDB&#xff0c;而风控系统需要实时消费这些数据做联合分析。当时用Flink 1.13手动写了不少connector代码&a…

作者头像 李华