拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

企业技术支持Agent实战:RAG架构优化与Token管理

企业技术支持Agent实战:RAG架构优化与Token管理

企业技术支持这个场景,做 Agent 的人都知道有多难伺候。用户问的问题五花八门,有的是"登录报 403 怎么办",有的是"这个接口返回的 token 过期了怎么续",还有的是"你们这个功能到底支不支持批量导入"。前两类问题有标准答案,后一类问题得翻产品文档。更麻烦的是,同一个问题,不同版本的答案还不一样。我接手这个项目的时候,团队已经在用纯 LLM 硬扛了三个月,效果嘛,能用,但每次回答都像在开盲盒——有时候精准得让人惊喜,有时候胡编得让人想砸键盘。

后来我们上了 RAG,情况变了,但没完全变。因为 RAG 本身不是银弹,它只是把"模型不知道"变成了"模型可以查",查得准不准、查得快不快、查不到的时候怎么办,全是坑。这篇文章我想把整个实战过程拆开讲,包括为什么选这个架构、Token 在哪些环节卡过我们、Embedding 模型怎么挑、检索命中率怎么从 60% 拉到 90% 以上,以及那些文档里不会写的踩坑经验。如果你正在做企业级技术支持 Agent,或者任何需要"基于私有知识回答用户问题"的系统,这篇应该能帮你省下不少试错时间。

1. 为什么纯 LLM 撑不住企业技术支持场景

1.1 技术支持问题的三个特殊性

企业技术支持跟通用问答有本质区别。通用问答可以容忍"我不确定",但技术支持不行。用户问"我的 token 为什么失效了",你回一句"可能是过期了,建议重新登录",用户会直接投诉。他要的是:你的 token 有效期是 2 小时,失效是因为超过了这个时间窗口,重新调用刷新接口用 refresh_token 换新的 access_token 就行,如果 refresh_token 也过期了,那就得重新走登录流程。

这个场景有三个硬性要求。第一,答案必须准确,不能有模糊空间。第二,答案必须有时效性,产品迭代后旧答案要能自动淘汰。第三,答案必须可追溯,用户追问"你凭什么这么说"的时候,你得能指出这是哪份文档第几页写的。

纯 LLM 方案在这三点上全部翻车。模型训练数据有截止日期,新产品功能它根本不知道;模型会幻觉,编造不存在的接口参数;模型没有引用来源,你没法验证它说的是真是假。

1.2 Token 消耗的账得算清楚

我们最开始用纯 LLM 的时候,没太在意 Token 消耗。直到月底账单出来,发现光是技术支持这一个场景,一个月烧掉了差不多 2000 万 Token。什么概念呢?平均每个用户会话消耗 8000 Token 左右,因为每次都要把完整的系统提示词、历史对话、用户问题全部塞进去。

这里有个关键认知:Token 不是免费的,但也不是越省越好。纯 LLM 方案下,你为了让它回答准确,得把大量上下文塞进 prompt,Token 消耗自然高。上了 RAG 之后,你只需要把检索到的相关片段塞进去,上下文长度可能从 8000 降到 2000,但检索本身也要消耗 Token——Embedding 要 Token,重排序要 Token,如果做查询改写还要 Token。

所以"没 Token 能用,有 Token 更聪明"这句话的真正含义是:RAG 让你在 Token 预算有限的情况下,把每一分钱花在刀刃上。不是不用 Token,而是用得更有策略。

1.3 从"模型知道什么"到"模型能查到什么"

这个思维转变很关键。纯 LLM 的思路是"我要训练一个什么都懂的模型",RAG 的思路是"我要建一个能快速找到正确信息的检索系统,让模型基于找到的信息来回答"。

打个比方。纯 LLM 像是一个博学但记性不太好的老教授,你问他什么他都能聊两句,但细节经常记错。RAG 像是一个配备了图书馆检索系统的研究员,他可能不是最聪明的,但他知道去哪本书里找答案,而且找出来的答案有页码可查。

企业技术支持场景下,后者的可靠性远高于前者。因为技术支持要的不是"聊得好",而是"答得对"。

2. RAG 架构选型:为什么是 LangChain4j + 本地 Embedding

2.1 框架选型的三个约束条件

选框架的时候我们列了三个硬约束。第一,必须能私有化部署,因为技术支持文档涉及内部产品细节,不能传到外部 API。第二,必须支持 Java 生态,我们后端全是 Java 服务,引入 Python 服务会增加运维复杂度。第三,必须能灵活替换组件,Embedding 模型、向量库、LLM 都要能独立替换,不能绑死。

基于这三点,LangChain4j 成了最合理的选择。它是 LangChain 的 Java 移植版,API 设计跟 Python 版基本一致,但跑在 JVM 上,可以直接嵌入现有服务。更重要的是,它的组件抽象做得比较干净,EmbeddingModel、EmbeddingStore、ChatLanguageModel都是接口,换实现只需要改配置。

提示:LangChain4j 的版本迭代比较快,建议锁定一个稳定版本,不要盲目追新。我们用的是 0.31.0,实测下来 API 比较稳定,社区文档也全。

2.2 Embedding 模型:本地跑还是调 API

这是第一个真正需要做取舍的地方。调 API 的好处是省事,OpenAI 的 text-embedding-3-small 效果确实好,但有两个问题:一是数据要出内网,二是按 Token 计费,文档量大之后成本不低。

本地跑的话,选择就多了。我们对比了几个主流方案:

模型维度中文效果推理速度部署难度
text-embedding-3-small1536优秀依赖网络低
BGE-M31024优秀中等中
m3e-base768良好快低
text2vec-large-chinese1024良好中等中

最后选了 BGE-M3。原因有三个:中文效果在 MTEB 榜单上排名靠前,支持多语言(我们有些文档是英文的),而且 1024 维在效果和存储成本之间比较平衡。

这里有个坑要注意:Embedding 模型的维度直接决定了向量库的存储成本和检索速度。1536 维比 768 维的存储开销大一倍,检索时的计算量也大一倍。如果你的文档量在十万级以下,768 维其实够用;如果上百万级,维度的影响就很明显了。

2.3 向量库:为什么没用 Pinecone

向量库的选择上,我们试过 Pinecone、Milvus 和 PostgreSQL 的 pgvector 扩展。Pinecone 是托管服务,开箱即用,但数据要出内网,直接排除。Milvus 功能强大,但部署一套完整的 Milvus 集群至少需要三个节点,运维成本太高。

最后选了 pgvector。理由很简单:我们已经有 PostgreSQL 了。pgvector 作为扩展装上就能用,不需要额外维护一套数据库。虽然它在超大规模向量检索上不如专用向量库,但我们的文档量在五十万条左右,pgvector 完全扛得住。

实测下来,在 50 万条 1024 维向量的数据集上,pgvector 的 HNSW 索引查询延迟在 20ms 左右,召回率 95% 以上。这个性能对技术支持场景来说绰绰有余。

2.4 LLM 的选择:本地模型还是云端

LLM 这块我们走了弯路。最开始想用本地部署的模型,试了 Ollama 跑 Llama 3 和 Qwen,效果嘛,能用,但跟 GPT-4 比差距明显。特别是在理解复杂技术问题的时候,本地模型经常抓不住重点。

后来改成混合方案:检索和 Embedding 全本地,LLM 调用走内部网关。这样既保证了数据安全(文档不出内网),又保证了回答质量。内部网关做了 Token 配额管理,每个服务有独立的预算,超了会自动降级到本地模型。

这个方案的关键在于:Embedding 和 LLM 可以解耦。Embedding 负责"找得准",LLM 负责"说得好"。找得准这件事本地模型完全能胜任,说得好这件事还是得靠大模型。

3. 文档处理流水线:从 PDF 到可检索片段的完整链路

3.1 文档解析:PDF 是最难啃的骨头

技术支持文档的来源很杂,有 Confluence 导出的 PDF,有 Markdown 写的 API 文档,还有从工单系统导出的 CSV。其中 PDF 是最难处理的,因为 PDF 本质上是一种排版格式,不是内容格式。

我们试过几个 PDF 解析库。PDFBox 是 Java 生态里最成熟的,但对复杂排版的表格支持不好。后来换成了 Apache Tika,它底层也是 PDFBox,但做了更多封装,对表格和列表的识别更准。

这里有个经验:不要指望自动解析能 100% 准确。我们的做法是,自动解析之后加一道人工校验环节,把解析结果展示给文档维护人员确认。虽然增加了工作量,但避免了"垃圾进垃圾出"的问题。

3.2 分块策略:固定长度还是语义分块

分块是 RAG 里最容易被低估的环节。分得太碎,检索到的片段缺乏上下文;分得太大,检索精度下降,而且浪费 Token。

我们最开始用固定长度分块,每 500 个字符一块,重叠 50 个字符。效果一般,因为经常把一段完整的话从中间切断。后来改成语义分块,用 Embedding 模型计算相邻句子的相似度,相似度低于阈值就切分。

语义分块的效果明显更好,但计算成本高。我们的折中方案是:先用固定长度粗分,再用语义相似度做二次合并。具体来说,先按 300 字符切分,然后计算相邻块的 Embedding 相似度,如果相似度高于 0.85 就合并。这样既控制了计算量,又保证了语义完整性。

3.3 元数据设计:让检索能按版本过滤

技术支持文档有个特殊性:同一个问题,不同产品版本的答案不一样。用户问"这个接口怎么调",你得先知道他用的是哪个版本。

所以我们在每个文档块上都附加了元数据:

{ "source": "api-docs-v2.3.pdf", "product": "payment-gateway", "version": "2.3", "section": "token-refresh", "last_updated": "2024-11-15", "doc_type": "api_reference" }

检索的时候,先按product和version做过滤,再做向量相似度搜索。这样即使用户问的是老版本的问题,也不会检索到新版本的答案。

注意:元数据的字段设计要在文档入库前就确定好,后期补元数据非常痛苦。我们最开始只存了 source,后来要加 version 的时候,不得不把五十万条数据全部重新处理了一遍。

3.4 增量更新:怎么处理文档变更

文档不是一成不变的。产品每次发版,API 文档就要更新。如果每次更新都全量重建索引,五十万条数据的 Embedding 计算要跑好几个小时。

我们的方案是基于内容哈希的增量更新。每个文档块计算一个 SHA-256 哈希,更新时对比新旧哈希,只对变化的块重新计算 Embedding。实测下来,一次常规版本更新的变更率在 5% 左右,增量更新只需要几分钟。

这里有个细节:删除的文档块要及时清理。我们最开始只做了新增和更新,忘了删除。结果用户问一个新功能的问题,检索出来的却是旧版本的答案,因为旧块还在索引里。后来加了一个定期清理任务,把源文档里已经不存在的块标记为失效。

4. 检索优化:把命中率从 60% 拉到 90% 的实操记录

4.1 第一版检索为什么只有 60% 命中率

第一版上线后,我们做了一个人工评估:从真实用户问题里随机抽 200 个,看检索结果里有没有包含正确答案。结果命中率只有 60% 左右。也就是说,10 个问题里有 4 个,检索系统根本没找到正确的文档块。

分析失败案例后发现三个主要原因。第一,用户问法和文档写法不一致。用户问"token 过期了怎么办",文档里写的是"访问令牌失效处理流程"。第二,一个问题需要多个文档块才能回答,但检索只返回了最相似的 top-3,漏掉了关键信息。第三,短查询的 Embedding 质量差,比如用户只输入"403",Embedding 模型很难理解这是什么意思。

4.2 查询改写:让用户问题更接近文档语言

针对第一个问题,我们加了查询改写环节。具体做法是:用户输入问题后,先用 LLM 把它改写成更接近文档语言的表述,再去检索。

比如用户问"token 过期了怎么办",改写后变成"访问令牌失效处理流程 刷新令牌 重新认证"。这样检索时就能匹配到正确的文档块。

查询改写的 prompt 是这样的:

你是一个技术支持查询改写助手。请将用户的问题改写成更适合文档检索的形式。 要求: 1. 保留原始问题的核心意图 2. 使用技术文档中常见的术语 3. 补充可能相关的关键词 4. 不要改变问题的原意 用户问题:{query} 改写结果:

实测下来,查询改写把命中率从 60% 提升到了 75% 左右。但要注意,改写会增加一次 LLM 调用,Token 消耗和延迟都会增加。我们的做法是只对短查询(少于 10 个字)做改写,长查询直接检索。

4.3 混合检索:向量搜索 + 关键词搜索

向量搜索擅长语义匹配,但对精确关键词不敏感。比如用户搜"ERR_4032",向量搜索可能返回一堆关于"错误处理"的文档,但真正包含这个错误码的文档可能排在后面。

所以我们加了关键词搜索作为补充。具体做法是:同时执行向量搜索和 BM25 关键词搜索,然后用 RRF(Reciprocal Rank Fusion)算法合并结果。

RRF 的公式很简单:

score = sum(1 / (k + rank_i))

其中 k 是一个常数(通常取 60),rank_i 是文档在第 i 个检索结果里的排名。这个算法的好处是不需要归一化不同检索器的分数,直接基于排名融合。

混合检索把命中率从 75% 提升到了 85% 左右。而且它对精确匹配场景特别有效,比如用户搜具体的错误码、接口名、配置项名称。

4.4 重排序:最后一道精度保障

前两步做完,检索结果的相关性已经不错了,但 top-3 里还是经常混入一些不太相关的块。这时候就需要重排序。

重排序用的是 Cross-Encoder 模型,它跟 Embedding 模型不同,不是分别编码查询和文档,而是把查询和文档拼在一起输入模型,直接输出相关性分数。这样精度更高,但计算量也更大,所以只对 top-20 的结果做重排序,选出 top-3 返回。

我们用的是 BGE-Reranker-Large,实测下来,重排序把最终 top-3 的准确率从 85% 提升到了 92% 左右。

优化阶段命中率主要手段
基线60%纯向量检索
加查询改写75%LLM 改写查询
加混合检索85%向量 + BM25 + RRF
加重排序92%Cross-Encoder 重排

4.5 检索不到的时候怎么办

即使做到 92% 命中率,还是有 8% 的问题检索不到。这时候如果硬让 LLM 回答,它就会开始编。

我们的策略是:检索置信度低于阈值时,直接告诉用户"我没找到相关文档",并给出人工支持入口。这比编一个错误答案要好得多。

置信度怎么算?我们用重排序模型的分数作为置信度。如果 top-1 的分数低于 0.5,就认为检索失败。这个阈值是调出来的,太高会导致很多能回答的问题被拒,太低会放进来太多噪声。

提示:拒答率是个需要持续监控的指标。如果拒答率突然升高,可能是文档更新了但索引没更新,也可能是用户问的问题类型变了。我们每周会看一次拒答日志,从中发现文档缺口。

5. Token 管理的那些坑:从超支到精细控制

5.1 Token 都花在哪了

上了 RAG 之后,Token 消耗的结构变了。我们统计了一下,大概的分布是这样的:

  • Embedding 计算:15%
  • 查询改写:10%
  • 重排序:20%
  • LLM 生成回答:55%

可以看到,LLM 生成回答还是大头,但检索相关的 Token 消耗加起来也有 45%。所以"省 Token"不能只盯着 LLM,检索链路上的每一环都要算账。

5.2 缓存:最有效的省钱手段

我们加了两级缓存。第一级是查询缓存,完全相同的用户问题直接返回缓存结果,不触发任何检索和 LLM 调用。第二级是Embedding 缓存,相同的文本块不重复计算 Embedding。

查询缓存的命中率在 20% 左右,因为技术支持问题重复率很高。Embedding 缓存的命中率更高,因为文档更新时大部分块是不变的。

缓存用 Redis 实现,key 是查询文本的哈希,value 是完整的回答结果。过期时间设了 24 小时,因为文档可能随时更新。

5.3 上下文压缩:只放最相关的片段

LLM 生成回答的时候,我们只把重排序后的 top-3 片段放进 prompt,而不是把所有检索结果都塞进去。每个片段限制在 500 Token 以内,总共不超过 1500 Token。

这里有个技巧:在片段前面加上来源标记,让 LLM 知道每段话出自哪里。这样它回答的时候可以引用来源,用户也能验证。

[来源1: api-docs-v2.3.pdf, 第12页] 访问令牌的有效期为2小时。过期后需要使用refresh_token换取新的access_token。 [来源2: faq.md, 第5节] 如果refresh_token也过期了,用户需要重新登录。

5.4 降级策略:Token 不够的时候怎么办

我们设了三级降级策略。第一级,Token 预算充足时,用 GPT-4 生成回答。第二级,预算用到 80% 时,切换到 GPT-3.5-turbo。第三级,预算用完时,只返回检索到的原始文档片段,不做 LLM 生成。

这个策略保证了服务不会因为 Token 超支而完全不可用。虽然降级后的体验会差一些,但至少用户能拿到原始文档。

6. 上线后的真实数据和踩坑复盘

6.1 效果数据

上线三个月后,我们统计了一组数据:

指标上线前(纯 LLM)上线后(RAG)
回答准确率62%89%
平均响应时间3.2s4.1s
单次会话 Token 消耗80003200
用户满意度3.1/54.3/5
人工转接率45%18%

准确率提升明显,Token 消耗降了一半多。但响应时间增加了,因为多了检索和重排序的环节。不过用户对 4.1 秒的容忍度还可以,毕竟比转人工等几分钟要好。

6.2 踩过的三个大坑

第一个坑:文档更新不同步。产品发版后文档更新了,但索引没及时重建,导致用户问新功能的问题,检索到的还是旧答案。后来加了一个 webhook,文档系统更新后自动触发索引重建。

第二个坑:Embedding 模型换了但没重建索引。我们中途把 Embedding 模型从 m3e-base 换成了 BGE-M3,但忘了重建索引。结果查询用的新模型 Embedding,文档存的是旧模型 Embedding,两者维度都不一样,检索结果完全是乱的。这个坑让我们明白:Embedding 模型和索引必须版本绑定,换模型必须全量重建。

第三个坑:长文档截断。有些 API 文档特别长,超过 Embedding 模型的最大输入长度(512 Token)后被截断了。截断后的 Embedding 只包含了文档开头的信息,后面的内容完全丢失。后来我们在分块阶段就控制了每块的长度,确保不超过模型限制。

6.3 还在优化中的问题

目前还有两个问题没完全解决。一是多轮对话的上下文管理,用户追问的时候,怎么把历史对话和当前问题结合起来检索,我们还在试不同的方案。二是表格和图片的处理,有些技术文档里的配置表格,解析出来是乱的,Embedding 效果很差。

表格这块我们试过把表格转成 Markdown 再 Embedding,效果比直接解析 PDF 好一些,但复杂的合并单元格还是有问题。图片就更麻烦了,目前只能靠 OCR 提取文字,但技术架构图里的信息基本提取不出来。

7. 一些可以直接抄的配置和经验

7.1 分块参数

经过多次调整,我们最终用的分块参数是:

  • 初始分块大小:300 字符
  • 合并阈值:相邻块 Embedding 相似度 > 0.85
  • 最大块大小:800 字符
  • 块间重叠:50 字符

这个配置在技术文档上效果比较好。如果是法律或医疗文档,可能需要更大的块,因为那些文档的上下文依赖性更强。

7.2 检索参数

  • 向量检索 top-k:20
  • 关键词检索 top-k:20
  • RRF 融合后取 top-10
  • 重排序后取 top-3
  • 置信度阈值:0.5

top-k 的设置需要根据文档量调整。文档量大的时候,top-k 可以设大一些,让重排序有更多候选。文档量小的时候,top-k 设太大反而会引入噪声。

7.3 监控指标

上线后一定要监控这几个指标:

  • 检索命中率:每周人工抽检 100 个问题
  • 拒答率:突然升高说明有问题
  • Token 消耗:按服务、按用户维度统计
  • 响应时间 P99:检索链路变慢会影响整体体验
  • 缓存命中率:太低说明缓存策略有问题

7.4 一个容易被忽略的细节

最后分享一个我们踩过的坑:用户输入的问题里可能包含敏感信息。比如用户会把 token、密码、内部 IP 直接贴在问题里。这些信息如果被送到 LLM,可能会有安全风险。

我们的做法是在查询改写之前加一道脱敏处理,用正则匹配常见的敏感信息模式(如 JWT、API Key、IP 地址),替换成占位符。这样既保护了用户隐私,也避免了敏感信息被记录到日志里。

这个脱敏环节看起来简单,但实际做的时候要考虑很多边界情况。比如用户贴的 token 可能被换行符截断,正则要能处理多行匹配。还有用户可能用自然语言描述敏感信息,比如"我的密码是 abc123",这种就得靠 NER 模型来识别了。

整体做下来,最大的体会是:RAG 不是把文档扔进向量库就完事了,它是一个需要持续调优的系统。检索质量、Token 消耗、响应速度,这三个指标互相制约,你得根据业务场景找到平衡点。技术支持场景下,准确率优先,所以我们在重排序和拒答策略上投入比较多。如果是创意生成场景,可能就更看重响应速度和多样性了。

返回列表