news 2026/9/28 21:05:40

AI Agent知识获取管道:RAG检索增强生成从切片到检索的TypeScript实战

作者头像

张小明

前端开发工程师

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

1. 为什么知识获取管道是 AI Agent 的分水岭

很多人搭 AI Agent 的时候,第一反应是去调模型、写提示词、接工具,觉得只要模型够强、工具够多,Agent 就能干活。但真正跑过几个项目之后你会发现,决定一个 Agent 好不好用的,往往不是模型本身,而是它能不能拿到对的信息。这就是知识获取管道要解决的问题。

我在实际项目里踩过最典型的一个坑:做一个内部技术文档问答的 Agent,模型用的是当时能力很强的那一档,提示词也反复调了好几轮,但回答质量始终不稳定。后来排查了半天才发现,问题根本不在模型,而在于我喂给它的文档切片方式太粗暴——按固定字数硬切,把一段完整的配置说明从中间截断,前半段在切片 A,后半段在切片 B。模型检索到切片 A 的时候,看到的是一个没有结尾的句子,自然答不对。

这个经历让我彻底改变了对 RAG 的认知。RAG 全称是 Retrieval-Augmented Generation,检索增强生成,说白了就是让模型在回答问题之前,先去一个知识库里把相关资料捞出来,再基于这些资料组织答案。它不是一个可选项,而是 AI Agent 从"能聊天"进化到"能干活"的关键基础设施。

这一篇是 AI Agent 系列的第四篇,前面几篇聊了 Agent 的基本架构、工具调用和任务规划,这一篇专门讲知识获取管道,也就是 RAG 的基础部分。我会用 TypeScript 作为主要示例语言,因为它在类型安全性和工程化方面对 Agent 开发非常友好。内容会覆盖从文档处理、切片策略、向量化、检索到最终拼装上下文的完整链路,适合已经了解 Agent 基本概念、想动手搭建知识管道的开发者。如果你之前只听说过 RAG 这个词但没真正落地过,这篇可以当作一份从零开始的实操参考。

需要先说明一点:RAG 的基础版本并不复杂,核心链路就那么几步。真正难的是每一步的细节决策——切片切多大、用什么模型做向量化、检索召回多少条、怎么处理多轮对话里的上下文。这些决策没有标准答案,只有适合你场景的答案。我下面会把每个环节的取舍逻辑讲清楚,你可以根据自己的实际情况调整。

2. 知识获取管道的完整链路拆解

2.1 从原始文档到可检索知识库的五个阶段

一条完整的知识获取管道,从原始文档到最终被 Agent 使用,大致会经过五个阶段。理解这五个阶段的边界和职责,是后面做技术选型和问题排查的基础。

第一个阶段是文档加载。你的知识可能散落在各种地方:Markdown 文件、PDF、网页、数据库、甚至飞书或 Notion 的页面。加载阶段要做的事情就是把这些异构来源统一读成纯文本或结构化文本。这一步看起来简单,但坑不少,比如 PDF 里的表格和公式、网页里的导航栏噪音,都会影响后续质量。

第二个阶段是文本切片。模型和向量化模型都有输入长度限制,你不可能把一整本书塞进去。切片就是把长文档拆成一段段语义相对完整的小块。这是整个管道里最影响效果的一步,后面我会专门展开讲。

第三个阶段是向量化。把每个文本切片通过一个嵌入模型转成一串数字向量,这串向量在数学上代表了这段文本的语义。语义相近的文本,向量距离就近。这一步是让"检索"变得可能的核心。

第四个阶段是存储与索引。把向量和原始文本一起存进向量数据库,并建立索引,让后续的相似度查询能快速返回结果。小规模场景用内存数组也能凑合,但一旦上量就必须用专业的向量库。

第五个阶段是检索与上下文拼装。用户提问时,把问题也向量化,去向量库里找最相近的若干切片,再把这些切片和原始问题一起拼成提示词,交给模型生成答案。

这五个阶段串起来,就是 RAG 的基础形态。下面这张表可以帮你快速对照每个阶段的核心任务和常见工具:

阶段核心任务常见方案最容易出问题的地方
文档加载异构来源统一为文本各类解析库、爬取脚本表格、公式、噪音处理
文本切片拆成语义完整的小块固定长度、递归、语义切片切断语义、块过大过小
向量化文本转向量各类嵌入模型模型选型、维度、成本
存储索引向量入库建索引向量数据库、内存数组索引类型、过滤条件
检索拼装召回并组装上下文相似度检索、重排序召回数量、上下文超长

2.2 为什么"检索"比"生成"更值得投入

很多团队在 RAG 项目上的资源分配是反的:花大量时间调提示词、换更强的生成模型,却在检索环节草草了事。我的经验是,检索质量决定了 RAG 效果的上限,生成模型只是逼近这个上限。

打个比方,RAG 就像开卷考试。检索环节相当于你翻书找答案的那一步,生成环节相当于你根据找到的内容组织语言写答案。如果书翻错了页,找到的是完全不相关的内容,那你语言组织能力再强也写不出正确答案。反过来,只要你翻对了页,哪怕表达能力一般,也能把要点答出来。

这个道理在实际项目里体现得特别明显。我做过一个对比测试:同一套知识库,同一批问题,只换生成模型,从能力中等换到能力很强,答案准确率的提升大概在几个百分点;但把检索策略从"固定长度切片+单路召回"换成"递归切片+多路召回+重排序",准确率提升能到二十个百分点以上。这个差距说明,检索才是性价比最高的优化点。

所以在这一篇里,我会把重心放在切片、向量化和检索这三个环节,生成环节只讲怎么把上下文拼好。这也是"知识获取管道"这个说法的由来——重点在"获取",不在"生成"。

2.3 TypeScript 在 Agent 知识管道里的角色

为什么用 TypeScript 来讲这套东西?因为 Agent 开发本质上是工程问题,而工程问题最怕的就是类型混乱。知识管道里数据在多个阶段之间流转,每个阶段的数据结构都不一样:加载阶段是文档对象,切片阶段是切片对象,向量化后是带向量的切片,检索后是带分数的结果。如果用动态类型语言,这些结构很容易在传递过程中被改得面目全非,排查起来非常痛苦。

TypeScript 的接口和类型别名可以把每个阶段的数据契约固定下来。比如你定义一个Chunk接口,规定它必须有id、content、metadata三个字段,那么任何试图传一个缺字段的对象进来的代码,在编译阶段就会报错,而不是等到运行时才炸。这在多人协作的项目里价值巨大。

另外,现在主流的 Agent 框架和向量数据库基本都提供了 TypeScript 或 JavaScript 的 SDK,生态是完整的。你完全可以用一套 TypeScript 代码把加载、切片、向量化、检索、拼装全部串起来,不需要在多种语言之间来回切换。下面我就按这条链路,一步步讲怎么落地。

3. 文档加载与切片:决定 RAG 效果的第一道关

3.1 加载阶段要处理的三种典型脏数据

文档加载听起来就是把文件读成字符串,但实际项目里,脏数据的处理才是重头戏。我总结了三类最常见的脏数据,几乎每个项目都会遇到。

第一类是格式噪音。从网页抓下来的内容,往往带着导航栏、页脚、广告、版权声明这些和正文无关的东西。如果不清理,这些噪音会被切进切片,向量化之后污染检索结果。处理办法是在加载阶段就做一轮清洗,用正则或 DOM 解析把正文区域提取出来。如果是用爬虫抓的,尽量在抓取时就定位到正文容器,而不是抓整个页面。

第二类是结构丢失。PDF 和 Word 文档里的标题层级、列表、表格,转成纯文本后往往就没了。标题变成普通一行字,列表变成一串没有标记的句子,表格变成一堆错位的文字。这会严重影响切片时的语义判断。我的做法是尽量用能保留结构的解析库,把标题标记成 Markdown 的#,列表保留-,表格转成 Markdown 表格。这样后续切片时就能根据结构做更聪明的切分。

第三类是编码和空白问题。中文文档里常见的全角空格、不间断空格、各种换行符混用,都会影响后续处理。加载后统一做一次规范化:全角转半角、连续空白合并、统一换行符。这一步花不了多少代码,但能省掉后面很多莫名其妙的 bug。

下面是一个加载和清洗的 TypeScript 示例,展示了基本的处理思路:

interface RawDocument { id: string; source: string; content: string; metadata: Record<string, unknown>; } function normalizeText(text: string): string { return text .replace(/\u00a0/g, ' ') // 不间断空格转普通空格 .replace(/[\u3000]/g, ' ') // 全角空格转半角 .replace(/\r\n/g, '\n') // 统一换行符 .replace(/\n{3,}/g, '\n\n') // 连续空行合并 .replace(/[ \t]{2,}/g, ' ') // 连续空格合并 .trim(); } function loadMarkdown(source: string, raw: string): RawDocument { return { id: source, source, content: normalizeText(raw), metadata: { type: 'markdown', loadedAt: Date.now() }, }; }

注意:清洗不要过度。有些空白和换行在代码块、表格里是有意义的,一刀切地全部合并会破坏这些结构。建议在清洗前先识别出代码块和表格区域,跳过这些区域的空白处理。

3.2 切片策略的取舍:固定长度、递归还是语义

切片是 RAG 里最需要动脑子的一步。切得太碎,每个切片信息量不足,检索出来答不完整;切得太大,一个切片里混了好几个主题,向量化后语义被稀释,检索精度下降。而且切片边界如果切断了完整的句子或段落,模型拿到的就是残缺信息。

我按从简单到复杂,介绍三种主流策略,以及它们各自适合的场景。

固定长度切片是最简单的做法:设定一个字符数上限,比如 500 字,从头到尾硬切。实现容易,但问题很明显——它完全不考虑语义边界,经常把一句话从中间切断。这种策略只适合对效果要求不高、或者文档本身结构很规整的场景。如果非要用,至少要加一个重叠窗口,比如每片 500 字、相邻片重叠 50 字,这样被切断的信息有机会在相邻片里补全。

递归切片是我最推荐的默认策略。它的思路是:优先按最大的语义单位切,如果切完还超长,再往下一级单位切。具体来说,先按段落(双换行)切,段落还超长就按句子切,句子还超长才按字符硬切。这样能最大程度保证每个切片是语义完整的。LangChain 等框架里都有现成的递归切片器,但自己实现也不难,核心就是一个递归函数。

语义切片是更进阶的做法:用嵌入模型计算相邻句子的语义相似度,在相似度骤降的地方切开,因为那通常意味着话题转换了。效果最好,但成本也最高,因为要对每个句子做向量化。适合知识库规模不大、但对精度要求极高的场景。

下面这张表对比了三种策略:

策略实现难度效果成本适用场景
固定长度低一般低结构规整、要求不高
递归切片中好低大多数场景的默认选择
语义切片高最好高小规模、高精度要求

3.3 切片大小和重叠窗口的经验值

切片大小到底设多少?这个问题我被问过无数次。网上流传的说法是"500 到 1000 个 token",但这个范围太宽,实际用起来还是没底。我分享几个从项目里总结的经验值。

首先要区分字符数和token 数。中文里一个汉字大约对应一到两个 token,英文一个单词大约对应一到一点三个 token。很多嵌入模型的限制是按 token 算的,所以切片时要按 token 估算,不能只看字符数。粗略换算的话,中文场景下 500 个 token 大约对应 350 到 500 个汉字。

对于技术文档和知识库问答,我一般把切片控制在 300 到 500 个 token。这个大小能容纳一个完整的知识点,又不会混入太多无关内容。对于长篇文章和书籍,可以放宽到 800 到 1000 个 token,因为这类内容段落本身就长,切太小反而破坏完整性。

重叠窗口的作用是防止边界信息丢失。经验值是切片大小的 10% 到 20%。比如切片 500 token,重叠就设 50 到 100 token。重叠太小起不到作用,太大则会让知识库膨胀、检索时返回大量重复内容。我一般从 15% 起步,根据实际效果微调。

还有一个容易被忽略的点:切片要带上足够的元数据。至少要有来源文档 ID、切片在文档中的位置、所属的标题层级。这些元数据在检索时可以用来做过滤,比如"只在某个产品的文档里搜",或者在拼装上下文时告诉模型"这段内容来自第三章第二节",帮助模型理解上下文。

interface Chunk { id: string; docId: string; content: string; position: number; heading: string; tokenCount: number; } function recursiveSplit( text: string, docId: string, maxTokens = 500, overlap = 75 ): Chunk[] { const separators = ['\n\n', '\n', '。', '!', '?', '. ', ' ']; const chunks: Chunk[] = []; let position = 0; function split(content: string, sepIndex: number): string[] { if (estimateTokens(content) <= maxTokens || sepIndex >= separators.length) { return [content]; } const sep = separators[sepIndex]; const parts = content.split(sep); const result: string[] = []; let buffer = ''; for (const part of parts) { const candidate = buffer ? buffer + sep + part : part; if (estimateTokens(candidate) > maxTokens && buffer) { result.push(buffer); buffer = part; } else { buffer = candidate; } } if (buffer) result.push(buffer); return result.flatMap((p) => estimateTokens(p) > maxTokens ? split(p, sepIndex + 1) : [p] ); } for (const piece of split(text, 0)) { chunks.push({ id: `${docId}-${position}`, docId, content: piece, position, heading: '', tokenCount: estimateTokens(piece), }); position += 1; } return chunks; } function estimateTokens(text: string): number { // 粗略估算:中文按字符数,英文按空格分词 const cjk = (text.match(/[\u4e00-\u9fa5]/g) || []).length; const others = text.length - cjk; return Math.ceil(cjk * 1.5 + others / 4); }

提示:estimateTokens只是粗略估算,生产环境建议用对应嵌入模型的官方分词器来精确计算,避免切片超长导致向量化失败。

4. 向量化与存储:让知识变得可检索

4.1 嵌入模型选型要看哪几个维度

向量化的核心是嵌入模型,它决定了文本被映射到什么样的语义空间。选型时我主要看四个维度。

语义质量是第一位的。不同模型对中文、英文、代码的理解能力差异很大。有些模型英文很强但中文一般,有些专门针对中文优化过。选型时最靠谱的办法是拿你自己的数据做一轮小规模测试:准备几十个问题和对应的正确文档,看哪个模型能把正确文档排到前面。别只看榜单,榜单和你的实际数据往往对不上。

向量维度影响存储和检索成本。维度越高,表达能力越强,但存储和计算开销也越大。常见的有 768 维、1024 维、1536 维。对于大多数知识库场景,768 到 1024 维已经够用,没必要盲目追求高维。

输入长度限制决定了单个切片能有多长。有些模型只支持 512 token,有些支持 8192。如果你的切片比较大,就要选支持长输入的模型,否则会被截断。

成本和部署方式也很关键。有的模型只能调云端接口,按调用量计费;有的可以本地部署,一次性投入。如果知识库很大、更新频繁,云端接口的费用会累积得很快,这时候本地部署可能更划算。如果知识库小、更新少,云端接口省事。

维度关注点建议
语义质量中英文、代码理解用自有数据实测,别只看榜单
向量维度存储与计算成本768-1024 维通常够用
输入长度是否匹配切片大小至少覆盖最大切片长度
成本部署调用费用与本地化大规模考虑本地部署

4.2 向量数据库的选型与索引类型

向量化之后要把向量存起来,并且能快速做相似度查询。小规模场景(几千条以内)用内存数组加暴力计算也能跑,但一旦上万条,就必须用向量数据库。

选向量数据库时,我关注三点:索引类型、过滤能力、运维成本。索引类型决定了查询速度和召回率的平衡。常见的索引有扁平索引(暴力计算,最准但最慢)、倒排文件索引(速度快,召回率略降)、分层可导航小世界图(速度快,召回率高,内存占用大)。对于大多数场景,分层可导航小世界图是默认选择。

过滤能力指的是能不能在向量检索的同时按元数据过滤。比如"只在 2024 年之后的文档里搜",如果数据库不支持过滤,你就得先全量检索再手动筛,效率很低。这个能力在实际项目里非常重要,选型时一定要确认。

运维成本包括部署难度、是否需要额外服务、数据持久化方式等。有些向量库是嵌入式的,直接跑在应用进程里,适合小项目;有些是独立服务,需要单独部署和维护,适合大规模场景。

interface VectorRecord { id: string; vector: number[]; content: string; metadata: Record<string, unknown>; } class InMemoryVectorStore { private records: VectorRecord[] = []; add(records: VectorRecord[]): void { this.records.push(...records); } search( queryVector: number[], topK = 5, filter?: (meta: Record<string, unknown>) => boolean ): Array<VectorRecord & { score: number }> { const candidates = filter ? this.records.filter((r) => filter(r.metadata)) : this.records; return candidates .map((r) => ({ ...r, score: cosineSimilarity(queryVector, r.vector) })) .sort((a, b) => b.score - a.score) .slice(0, topK); } } function 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) + 1e-8); }

注意:上面的内存实现只适合演示和小规模数据。生产环境请用专业向量数据库,它们对索引、持久化、并发都有专门优化。

4.3 批量向量化时的并发与限流处理

实际项目里,知识库动辄几千上万条切片,逐条调嵌入接口会非常慢。必须做批量处理,但批量又会遇到接口的速率限制。我的做法是控制并发数加失败重试。

并发数不能太高,否则容易触发限流;也不能太低,否则跑得慢。我一般从 5 到 10 并发起步,根据接口的响应情况调整。同时要处理失败重试,网络抖动或临时限流导致的失败,退避几秒后重试通常就能成功。重试要有次数上限,避免死循环。

async function embedBatch( chunks: Chunk[], embedFn: (text: string) => Promise<number[]>, concurrency = 5, maxRetries = 3 ): Promise<VectorRecord[]> { const results: VectorRecord[] = []; const queue = [...chunks]; async function worker(): Promise<void> { while (queue.length > 0) { const chunk = queue.shift(); if (!chunk) break; let attempt = 0; while (attempt < maxRetries) { try { const vector = await embedFn(chunk.content); results.push({ id: chunk.id, vector, content: chunk.content, metadata: { docId: chunk.docId, position: chunk.position }, }); break; } catch (err) { attempt += 1; if (attempt >= maxRetries) { console.error(`切片 ${chunk.id} 向量化失败`, err); } else { await new Promise((r) => setTimeout(r, 1000 * attempt)); } } } } } await Promise.all(Array.from({ length: concurrency }, () => worker())); return results; }

这段代码的核心是"工作池"模式:启动固定数量的 worker,每个 worker 从队列里不断取任务处理,直到队列空。这样并发数可控,又不会浪费等待时间。重试用了简单的线性退避,实际项目里可以换成指数退避,效果更好。

5. 检索与上下文拼装:把知识喂给模型

5.1 相似度检索的召回数量怎么定

检索阶段第一个要定的参数是召回数量 topK,也就是每次返回多少个最相似的切片。这个值太小,可能漏掉关键信息;太大,会引入无关内容,还会撑爆模型的上下文窗口。

我的经验是 topK 从 3 到 5 起步。对于问题比较聚焦、知识库切片质量高的场景,3 条往往就够。如果问题比较复杂、需要综合多处信息,可以提到 8 到 10 条。但不要盲目加大,因为相似度排序靠后的切片,相关性下降得很快,加进来多半是噪音。

一个更聪明的做法是先多召回再重排序。第一轮用向量检索召回比如 20 条,然后用一个重排序模型对这 20 条做精排,取前 5 条。重排序模型比向量检索更准,因为它能同时看到问题和文档,做更精细的相关性判断。这样既保证了召回率,又保证了精度。

5.2 多路召回与重排序的实战价值

单一向量检索有个天然短板:它擅长语义相似,但对关键词精确匹配不敏感。比如用户问一个具体的错误码,向量检索可能返回一堆语义相关但不含这个错误码的文档。这时候就需要多路召回:一路走向量检索,一路走关键词检索(比如 BM25),两路结果合并后再重排序。

我在一个技术文档项目里加了这个策略,效果提升很明显。之前用户搜具体 API 名称时经常搜不到,加了关键词召回之后,这类问题的命中率大幅提升。原因是向量检索把 API 名称这种专有名词的语义"泛化"了,而关键词检索能精确匹配。

多路召回的合并策略有两种:一种是简单去重后合并,另一种是按分数加权融合。加权融合需要把两路的分数归一化到同一量纲,稍微复杂一点,但效果更好。如果嫌麻烦,简单合并加重排序也能拿到大部分收益。

interface RetrievedChunk { id: string; content: string; score: number; source: 'vector' | 'keyword'; } async function hybridRetrieve( query: string, vectorStore: InMemoryVectorStore, keywordIndex: Map<string, string[]>, topK = 5 ): Promise<RetrievedChunk[]> { const queryVector = await embedQuery(query); const vectorResults = vectorStore.search(queryVector, topK * 4); const keywords = extractKeywords(query); const keywordIds = new Set<string>(); for (const kw of keywords) { for (const id of keywordIndex.get(kw) || []) { keywordIds.add(id); } } const merged = new Map<string, RetrievedChunk>(); for (const r of vectorResults) { merged.set(r.id, { id: r.id, content: r.content, score: r.score, source: 'vector', }); } for (const id of keywordIds) { if (!merged.has(id)) { merged.set(id, { id, content: '', score: 0.5, source: 'keyword', }); } } return Array.from(merged.values()) .sort((a, b) => b.score - a.score) .slice(0, topK); } function extractKeywords(query: string): string[] { return query .split(/[\s,,。??!!]+/) .filter((w) => w.length >= 2); }

5.3 上下文拼装的顺序与格式技巧

检索到相关切片后,最后一步是把它们和用户问题拼成提示词。这一步看似简单,但格式和顺序会影响模型的理解。

顺序上,我习惯把最相关的切片放在最前面和最后面,中间放次相关的。这是利用了模型对首尾内容注意力更强的特点。如果切片数量少,直接按相关度降序排也行。

格式上,每个切片要标清楚来源和边界。我一般用这样的格式:

[文档1] 来源:xxx.md 第三章 内容:... [文档2] 来源:yyy.md 第一节 内容:...

这样模型能区分不同切片,回答时也能引用来源。切片之间用明确的分隔符隔开,避免模型把它们当成连续文本。

提示词里要明确告诉模型:只根据提供的资料回答,资料里没有的信息不要编造。这句话能显著降低幻觉。同时可以要求模型在回答里标注引用了哪个文档,方便用户核实。

function buildPrompt( question: string, chunks: RetrievedChunk[] ): string { const context = chunks .map( (c, i) => `[文档${i + 1}] 来源:${c.id}\n内容:${c.content}` ) .join('\n\n'); return `你是一个严谨的知识助手。请只根据下面提供的资料回答问题。 如果资料中没有相关信息,请直接说明"资料中未找到相关内容",不要编造。 资料: ${context} 问题:${question} 回答:`; }

提示:上下文不是越长越好。如果拼装后的提示词超过了模型的上下文窗口,要么截断切片,要么减少召回数量。截断时优先保留相关度高的切片。

6. 从能跑到好用:几个必须知道的实战细节

6.1 知识库更新时怎么避免全量重跑

知识库不是一次性的,文档会更新、会新增。如果每次更新都全量重新向量化,成本高、耗时长。我的做法是给每个切片记录来源文档的哈希值,更新时只处理哈希变化的文档。

具体来说,加载阶段先算每个文档内容的哈希,和上次存储的哈希对比。没变的文档直接跳过,变的文档删掉旧切片、重新切片向量化。这样增量更新能把处理量降到最低。对于频繁更新的知识库,这个优化能省下大量时间和费用。

6.2 检索结果不理想时的排查顺序

RAG 效果不好时,很多人第一反应是换模型,但往往问题不在模型。我总结了一个排查顺序,从最可能的原因开始查。

先看切片质量:把检索到的切片打印出来,看内容是否完整、是否包含答案。如果切片本身就是残缺的或无关的,那问题在切片环节,换模型没用。

再看向量化:确认切片和查询用的是同一个嵌入模型。用不同模型向量化,语义空间对不上,检索必然失败。这个错误很隐蔽,因为代码不会报错,只是效果差。

然后看检索参数:topK 是不是太小,相似度阈值是不是设得太高。可以临时把 topK 调大,看正确切片有没有出现在结果里。如果出现了但排名靠后,说明是排序问题,考虑加重排序。

最后才看生成环节:如果检索到的切片明明包含答案,但模型答错了,那才是提示词或模型的问题。这时候再调提示词、换模型。

6.3 评估 RAG 效果的一套简易方法

没有评估就没法优化。我一般会建一个小规模的评估集:准备 30 到 50 个真实问题,每个问题标注出应该被检索到的文档 ID,以及期望答案的要点。

评估时看两个指标:检索命中率,即正确文档有没有出现在召回结果里;答案准确率,即最终回答是否覆盖了期望要点。前者反映检索质量,后者反映端到端效果。每次调整切片策略或检索参数后,跑一遍评估集,对比指标变化,就能知道改动是变好还是变坏。

这个评估集不需要很大,但一定要用真实问题,不要自己编。真实问题里的表述方式、用词习惯,和你拍脑袋想出来的问题差别很大,只有真实问题才能反映实际效果。

6.4 一个容易忽略的坑:查询改写

用户的问题往往很短、很口语化,直接拿去检索效果不一定好。比如用户问"那个报错怎么解决","那个"指什么完全不知道。这时候需要查询改写:用模型把用户问题改写成更适合检索的形式,或者结合对话历史补全指代。

在多轮对话场景里,查询改写尤其重要。用户第二轮问"那它支持吗",如果不结合上一轮补全"它"指什么,检索必然失败。我的做法是在检索前加一步:把对话历史和当前问题一起交给模型,让它输出一个独立的、完整的检索查询。这一步能显著提升多轮场景的检索质量。

async function rewriteQuery( history: Array<{ role: string; content: string }>, current: string, llm: (prompt: string) => Promise<string> ): Promise<string> { if (history.length === 0) return current; const historyText = history .map((m) => `${m.role}: ${m.content}`) .join('\n'); const prompt = `根据下面的对话历史,把用户的最新问题改写成一个独立、完整、适合检索的问题。 只输出改写后的问题,不要解释。 对话历史: ${historyText} 最新问题:${current} 改写后的问题:`; return (await llm(prompt)).trim(); }

这个改写步骤会增加一次模型调用,带来一点延迟和成本,但在多轮场景里非常值得。单轮场景可以省略,或者只在问题明显有指代时才触发。

7. 我对知识获取管道的一点个人体会

搭了几套 RAG 系统之后,我最大的体会是:别追求一步到位,先跑通再优化。很多人卡在选型阶段,纠结用哪个嵌入模型、哪个向量库,结果迟迟不动手。其实基础版本用最简单的方案就能跑起来,跑起来之后你才知道瓶颈在哪,才知道该优化什么。

我自己的习惯是先搭一个最小可用版本:内存向量库、固定长度切片、单路检索,能回答简单问题就行。然后拿真实问题去测,看哪里不行。往往是切片问题最突出,那就先优化切片;切片好了还不行,再看检索;检索好了还不行,才轮到生成。这个顺序能保证你把精力花在刀刃上。

另外,知识获取管道不是搭完就完事的,它需要持续维护。文档会变、用户问题会变、模型会更新,定期跑评估集、看失败案例,才能让它一直好用。我一般每个月会抽时间看一批失败案例,往往能发现新的优化点。

最后说一句,RAG 的基础部分真的不难,难的是细节和耐心。把切片、向量化、检索这三步的细节抠到位,效果自然就上来了。下一篇我会讲 Agentic RAG,也就是让 Agent 自己决定怎么检索、检索几轮,那是在这个基础之上的进阶玩法。但前提是,你得先把这一篇的基础打牢。

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

Redis大key删除把我坑惨了,分享这个血泪教训

凌晨三点&#xff0c;我盯着监控面板上持续飙高的Redis内存使用率&#xff0c;手心里全是汗——刚刚执行的DEL命令不仅没释放内存&#xff0c;反而让集群的QPS跌到了两位数。这是一次典型的「大key删除」事故&#xff0c;而背后的教训值得每个用过Redis的人警惕。 场景还原&…

作者头像 李华
网站建设 2026/9/28 21:04:38

AgentScope多智能体协作实战:消息传递、记忆机制与工程调优

多智能体系统这两年从论文里的概念一路卷到了工程落地&#xff0c;但真正让我愿意花时间写一篇长文来聊的框架并不多。AgentScope 算一个。第一次接触它是在一个需要把多个角色智能体编排起来完成复杂任务的项目里&#xff0c;当时试过几种方案&#xff0c;要么抽象太重、要么调…

作者头像 李华
网站建设 2026/9/28 21:04:32

告别复制粘贴!这款 Excel 小工具解放双手

做数据整理最烦反复复制拆分表格&#xff0c;今天安利一款 Excel 小助手&#xff0c;八大功能全是刚需。 表格转 TXT&#xff0c;Excel 内容一键转为文本文件&#xff1b; 一簿拆多簿&#xff0c;单个工作簿里的工作表快速拆分成多个独立文件&#xff1b; 批量替换 Word&#x…

作者头像 李华
网站建设 2026/9/28 21:04:23

N76E003开发环境搭建全指南:Keil C51与Nu-Link配置实战

1. 为什么N76E003的开发环境值得单独写一篇搭建指南新唐的N76E003这颗片子&#xff0c;在1T 8051这个圈子里算是个“性价比怪物”。18KB Flash、1KB SRAM、16MHz主频、带12位ADC、带PWM、带双串口&#xff0c;SOP20/TSSOP20的小封装&#xff0c;价格常年压在一块钱出头。很多做…

作者头像 李华
网站建设 2026/9/28 21:03:15

开源推理引擎怎么选:vLLM、SGLang、Ollama、llama.cpp 的机制与部署

开源推理引擎怎么选&#xff1a;vLLM、SGLang、Ollama、llama.cpp 的机制与部署部署大模型&#xff0c;第一个要做的选择不是"用哪个模型"&#xff0c;是"用哪个推理引擎"。 同一张卡、同一个模型&#xff0c;换个引擎&#xff0c;能扛的并发可能差好几倍—…

作者头像 李华