做电商客服系统的朋友应该都有同感,客户问的问题高度集中在物流时效、退货规则、保修政策、产品规格这几类,而且每天反复被问上百遍。即使知识库再全,传统的关键词检索也很难真正理解"我昨天买的风扇能换货吗"这种带上下文的问题。这个项目要解决的就是这个痛点:用Spring AI + RAG搭建智能客服,把商品手册、售后政策这些PDF文档交给系统自动解析,再让大模型基于解析出的内容做检索增强生成,实现从文档入库到精准问答的全链路闭环。文章会按项目的真实推进顺序,把PDF解析、文本切块、向量化、检索、提示词设计这部分底层的细节一个个拆开来说,适合正在用Java技术栈想落地客服AI能力的团队参考。
1. 项目概述与核心需求分析
1.1 电商智能客服要解决的真实痛点
我接手做这个项目之前,客户那边的客服团队每天要手动回复大量重复问题。后台有几百份PDF文档,包括产品说明书、售后政策、价格调整通知、常见问题手册,分散在各个业务部门手里。客服人员遇到不确定的问题,得先去翻文档找到对应章节,再组织语言回复,单次处理时长经常超过两分钟。更麻烦的是,文档更新非常频繁,比如大促期间的退换货政策每周都会调整,客服如果没及时同步信息,回复就会出错,紧接着就是投诉。
当时的需求很明确:一个能自动学习文档内容、支持持续更新知识、回答用户问题时带出处和依据的智能客服系统。这就要求系统能理解自然语言问题,而不是只做关键词匹配;同时要能快速响应知识变化,不能每次政策调整都重新训练一轮模型。综合来看,RAG检索增强生成是最适合自己的方案。
1.2 为什么选Spring AI加RAG
选Spring AI这个组合,核心原因是它直接对齐了Java后端团队的技术栈。团队里所有成员的日常开发都是Spring Boot生态,如果让客服系统用Pythond的框架搭一套独立服务,数据层、接口层、部署链路全都要另起炉灶,维护成本直接翻倍。Spring AI最大的价值在于它把对大模型的调用抽象成了统一的接口,嵌入模型、对话模型、向量存储都有一致的接口封装,团队不需要去学习各家模型的差异就能跑通全流程。
RAG方案本身的选择逻辑也很直接。备选方案是微调大模型,但电商客服的知识库更新非常频繁,微调一次模型时间长、成本高,无法跟上政策变化。RAG的思路是把知识先存进向量库,回答问题前先从库里检索相关片段,把检索结果拼到提示词里给大模型做参考,这样即使文档更新,只需要重新灌入新的向量数据,几秒钟之内就能生效。RAG的重心在检索质量和内容组装,这正是工程上可以持续优化、可控性更强的方向。
2. 整体架构设计与技术选型解析
2.1 全链路流程梳理
这个项目从文档输入到答案输出,完整链路是这样走的:先把各种格式的PDF文档接进来统一解析,从二进制里抽取可用的正文文本;接着做清洗和结构还原,把页眉页脚、乱码换行这类噪音去掉;然后按语义边界切分为合适长度的文本块,再把每个文本块通过嵌入模型转换成一串向量坐标;这些向量连同原文、标题、页码一起写入向量数据库形成知识索引。用户发起问题时,系统先把问题做同样的向量化处理,用向量相似度检索出最相关的若干文本块,最后把这些文本块作为背景资料组装进提示词模板,交给大模型生成带依据的回答。
这一步一步拆开来看,每个环节都不复杂,但把它们串起来之后,很多细节就会暴露问题。比如PDF解析出来的表格如果直接当作纯文本处理,检索到的信息会丢失行列关系;切块长度定得太大,检索回来的内容可能包含大量无关段落,挤占大模型的上下文窗口。所以在做每个环节时,都要回到"最终问答效果"这个目标来做取舍。
2.2 核心组件选型逻辑
针对这个项目的需求,组件选型有几个关键的考虑点。一是PDF解析库的选择,Java生态里可选方案不少,我实际对比后选择了PDFBox作为主解析引擎,格式兼容性好,能处理扫描版PDF的文本层提取,同时通过扩展支持OCR后续接旁路。选型时定的原则是:只选成熟稳定的库,不做过度封装,这样出问题时能快速定位到库层。
二是嵌入模型的选择。中文场景下,嵌入模型的效果直接决定检索质量。我之前测试过不同的text-embedding模型,最终选了在中文语义、短文本匹配上表现更稳定的模型,向量维度 768维,单条文本向量的处理速度在十几毫秒左右。嵌入模型不需要本地部署,直接调用API,在成本和延迟之间都比较平衡。
三是向量数据库。项目初期数据量不大,文档几百份,切块后大约几万条向量,所以选择了一个相对轻量的方案:在原有PostgreSQL实例加上向量检索插件,数据表可以直接复用业务库,不需要额外引入独立的向量数据库组件。如果后续数据量扩大到百万级以上,再考虑迁到独立的向量数据库也不迟。这种渐进式的方案能显著降低初期架构复杂度。
2.3 数据流与接口设计
系统内部的数据流分两条线。一条是离线知识导入线:管理员上传PDF文档,系统异步解析并切块、向量化,写入知识库,全过程通过一个简单的状态表跟踪进度;另一条是在线问答线:用户提问进来,系统先做向量检索,再把检索结果和大模型组装成一次对话,通过SSE流式返回给前端,提高首字响应速度。
接口设计上,我把它拆成了三个核心服务:解析服务负责文档处理,检索服务负责向量查询和重排,问答服务负责组装提示词和调用大模型。三个服务之间通过数据层解耦,不互相直接调用,后续任何一环要替换或升级,影响面都被限制在局部。例如想换一个更好的嵌入模型,只需要改检索服务里的模型调用,解析和问答都不受影响。
3. PDF文档解析环节落地
3.1 PDF解析的难点与工具选择
PDF解析是RAG链路里第一个坑,也是很多教程讲了但实际做起来最容易翻车的地方。PDF本质上是布局描述语言,里面记录的是"这段文字在这个坐标,那个表格在那个坐标",而不是像HTML或者Markdown那样有清晰的语义层级。一个简单的PDF文件,内部可能包含多种字体编码、嵌套表格、页眉页脚、图片型文字,提取出的文本经常是乱的。
我实测过的处理方式里,比较稳妥的是先用PDFBox从PDF中提取带位置信息的文本块,包括每个文本块的坐标、字体大小和页码;拿到这些信息之后,再根据坐标值做了一些规则过滤,把页眉页脚、页码这类无关内容剔掉。对于真正扫描版或者图片型PDF,纯文本提取完全拿不到内容,只能走OCR旁路,这条路在启动阶段不用做,但要留好扩展点。
3.2 解析流程与文本清洗
解析流程我分了四步。第一步是探测文本层,判断这个PDF是有真实文本还是纯图片的扫描件,有文本层的直接走文本提取,没有文本层的返回需要OCR的标记。第二步是坐标级文本提取,PDFBox提取出来的文本块通常会丢失原始顺序,我先按页码排序,再按y坐标从上到下、x坐标从左到右重建阅读顺序。第三步是规则清洗,去掉页眉页脚、页码、孤立的目录条目,把连字符断行拼接回去。第四步是表格结构识别,我用了坐标对齐的方式判断哪些文本块同属一个表格区域,然后按行列结构重新组织成表格文本,这一步对商品参数表尤其重要,不然"电压 220V"这种键值对会散落成两行无关联的文本。
一个清洗规则示例,用正则替换掉固定的页眉页脚,在项目里用工具类实现:
// 去掉页眉页脚规则示例 private static final List<Pattern> NOISE_PATTERNS = List.of( Pattern.compile("^第\\s*\\d+\\s*页(\\s*共\\s*\\d+\\s*页)?$"), Pattern.compile("^\\d{1,3}\\s*$"), // 孤立页码 Pattern.compile("^内部资料\\s*·\\s*仅供学习$") ); public static String cleanText(String text) { for (Pattern p : NOISE_PATTERNS) { text = p.matcher(text) .replaceAll("") .replaceAll("\n+", "\n"); } return text.trim(); }清洗这一步如果做得不干净,后面整个向量化环节都会受影响。我曾经把一份带页脚的文档直接切块灌进知识库,结果有两成的检索结果返回的是"第25页 共30页"这样的无意义文本,大模型就会一本正经地把页码当成答案的一部分输出,非常尴尬。
3.3 文本切块策略对比
文档清洗完成后,下一步就是切块。切块直接决定了检索的最小粒度:切得太小,一段完整的逻辑被截断,比如商品保修政策的条款可能最终结论在下一段;切得太大,一个块里混合了多个主题,检索召回的内容噪声多,还容易超出大模型的上下文窗口。我前后尝试了三种策略。
固定大小切块,比如每512个字符一块、重叠80个字符,实现简单,但很容易把同一个句子的语义从中间切断。按章节标题切块,先提取PDF里的标题层级,从每个标题开始切到下一个标题前,能保留章节完整性,但商品手册里有的章节特别长,比如"安全注意事项"可能连续好几页,切出来一个块就一两万字,依然不适合直接检索。最后我采用了二级标题优先、超长块内部再按句子边界二次切分的混合策略:正常情况下按二级标题分块,如果一个块超过1000个字符,就在句子边界位置继续切,并保留一定的跨块重叠。
切块代码的核心逻辑可以概括为:先用正则识别标题行,再用列表维护当前标题下累积的内容,最后对超长块做子切分。这个策略跑下来,单块平均长度控制在600到800个字符之间,既保留了语义完整性,又不会把检索单元撑得太大。
注意:切块策略没有绝对的最优解,它和你用的嵌入模型以及大模型上下文长度强相关。我选的嵌入模型对短文本匹配效果更好,所以偏向控制单块长度;如果你用的是长文本优化的embedding模型,块长可以适当放宽。切块参数最好在后期通过检索测试来调,不要一开始就在这个问题上过度纠结。
4. 向量化与检索质量提升
4.1 向量化与嵌入模型选择
切块的产物还是自然语言文本,要让计算机在语义层面上做相似度比较,得先把文本变成固定长度的浮点数组,也就是向量。这段转换过程叫向量化,做转换的模型叫嵌入模型。嵌入模型的核心能力是把语义相近的文本映射到向量空间中相近的位置。比如"怎么退货"和"退换货流程"的向量距离会非常近,而"退货"和"充电"的向量距离就会比较远。
嵌入模型的调用封装在Spring AI的EmbeddingModel接口下,项目里只需要注入接口就可以随时切换实现,非常方便。配置如下:
@Configuration public class EmbeddingConfig { @Bean public EmbeddingModel embeddingModel() { // 通过Spring AI的自动配置注入,实际调用远程嵌入服务 return new OpenAiEmbeddingModel(embeddingClient); } }选嵌入模型时我会重点看三个指标:中文语义效果、输出向量维度、单次调用的耗时与成本。向量维度和存储密切相关,768维的向量虽然存储占用多一些,但检索效果往往优于低维向量。一个不太起眼但很重要的经验是:同一个模型的名字可能对应不同的版本,版本更新后向量分布会变化,旧库里已存储的向量和新模型算出来的向量就不能直接比较相似度了。中途如果换了嵌入模型,知识库里的向量必须重新生成,这个坑在项目里务必注意。
4.2 向量相似度检索与Top-K调优
用户发起查询后,系统把问题文本向量化,然后用这个向量在向量库里做相似度搜索,常用的是余弦相似度或内积距离。在Spring AI里,向量库的抽象封装了save和similaritySearch两个核心方法,业务代码里一般只关心这两个接口。
实际检索代码可以参考下面的写法:
public List<Document> searchKnowledgeBase(String question) { // 计算问题向量 float[] questionEmbedding = embeddingModel.embed(question); // 执行相似度检索,返回前topK个文档块 SearchRequest request = SearchRequest.builder() .query(question) .topK(6) .similarityThreshold(0.68) .build(); return vectorStore.similaritySearch(request); }Top-K参数直接决定了送入大模型的知识量。K值太小,可能漏掉关键片段,答非所问;K值太大,有用的信息沉在一堆低相关文本里,大模型会被噪声干扰。我在实际测试里发现,对电商客服这种垂直场景,K取5到8比较合适。同时相似度阈值也很有用,低于阈值的检索结果宁可不返回,让系统直接回答"这个问题需要转人工",也不要硬给用户一个胡编乱造的结果。
4.3 重排序与上下文组装
单纯靠向量相似度检索出来的结果,排序可能不是最优的。原因是向量检索擅长找"语义相似"的内容,但未必擅长判断"哪个片段最能直接回答这个问题"。打个比方,用户问"风扇漏水怎么办",向量检索可能召回"风扇结构说明"和"漏水故障排查"两个片段,实际上后者才是真正能解决问题的。要提升这种场景下的排序质量,标准做法是加一个重排序环节,用一个轻量级的rerank模型对候选片段按相关性重新打分。
引入重排序之后,系统会先向量检索召回大概20个候选块,再用rerank模型对候选块打分,最后只取得分最高的3到5个块作为大模型的上下文。重排序模型输出的是更贴近"这个块对回答问题有多大帮助"的评分,排序准确率明显高于纯向量相似度。
有同行问过:重排序模型需要额外部署服务,成本会不会太高?实际跑下来,一个rerank请求的耗时大概在几十毫秒量级,对整个问答端到端延迟的影响是可以接受的。如果你的项目预算紧张,目前可以先用向量相似度检索加阈值过滤的简化方案,等上线后通过用户反馈发现检索质量问题,再引入重排序也不迟。
4.4 上下文组装与提示词设计
检索阶段拿到的文本块,最终要拼接成一个完整的上下文放进提示词里。这块并不是把几个文本块简单串起来就行,需要考虑三个原则:一是标注信息源,每个文本块前加上文档标题和页码,让大模型知道内容出处,也便于溯源;二是控制总长度,选定的文本块加起来最好不要超过模型上下文的一半,给大模型的回答留足生成空间;三是明确指令边界,告诉模型知识库中没有的内容不要编造。
我实际使用的提示词模板如下:
你是一名电商智能客服助手,回答用户问题时请遵循以下要求: 1. 优先依据下面的知识库内容回答,不得编造知识库中不存在的信息; 2. 回答要简洁清晰,控制在200字以内; 3. 如果知识库内容不足以回答问题,请回复:抱歉,这个问题我需要转人工处理; 4. 引用知识库内容时,在回答末尾标明信息来源文档。 ### 知识库内容: {context} ### 用户问题: {question}提示词里最值得注意的其实是"如果知识库内容不足,就转人工"这条。很多客服AI翻车的根源就是大模型硬答,明明知识库里没有相关文档,它也能生成一个看似合理的答案。在关键场景里,模型主动承认能力边界比胡说八道重要得多。
5. 基于Spring AI的精准问答实现
5.1 核心依赖与启动配置
项目的基础框架是Spring Boot 3.x,引入Spring AI相关依赖后,很多样板代码都可以省掉。最核心的依赖有三个:spring-ai-starter(自动配置通道)、spring-ai-vector-store(向量存储操作)、以及PDF解析和向量库驱动。依赖管理里版本号需要对齐Spring AI的BOM,否则容易出现类冲突。构建配置这样写:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-pdf-document-reader</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-pgvector-store</artifactId> </dependency> </dependencies>Spring Boot的自动配置会在application.yml里根据参数完成大部分初始化工作。比如向量存储的数据源配置、嵌入模型的服务地址和密钥。这些参数集中管理,部署到不同环境时不需要改代码。
5.2 问答核心接口开发
问答服务是项目里最核心的一块业务代码,负责把检索和生成两个环节串起来。这里不直接调用大模型的原生接口,而是使用Spring AI的ChatClient,它把提示词模板、参数、回调都封装在一套流式接口里,比较干净。以下是问答接口的服务层代码:
@Service public class CustomerService { private final VectorStore vectorStore; private final ChatClient chatClient; public CustomerService(VectorStore vectorStore, ChatClient.Builder builder) { this.vectorStore = vectorStore; this.chatClient = builder.build(); } public String answer(String userQuestion) { // 1. 向量检索 List<Document> docs = vectorStore.similaritySearch( SearchRequest.builder() .query(userQuestion) .topK(6) .build() ); // 2. 拼接上下文 String context = docs.stream() .map(d -> "【来源:" + d.getMetadata().get("source") + "】\n" + d.getContent()) .collect(Collectors.joining("\n\n")); // 3. 组装提示词并调用大模型 PromptTemplate template = new PromptTemplate(""" 你是电商智能客服,请严格基于知识库内容回答。 知识库内容: {context} 用户问题: {question} 如果知识库没有相关内容,请回答转人工。 """); Message message = template.createMessage(Map.of( "context", context, "question", userQuestion )); return chatClient.call(message).getContent(); } }这段代码里,第1步到第3步是整个RAG问答的主干逻辑。可能在跑通之后会想:这个实现是不是太简洁了?会不会有问题?其实核心逻辑就是这样,真正的复杂度都在"检索到的内容够不够准"和"提示词约束够不够强"这两件看似不起眼的事情上。
5.3 流式SSE输出体验优化
客服场景里,用户面对的是聊天窗口,如果等大模型把整个回答都生成完再一次性返回,等待时间会很明显,尤其当回答比较长时,可能用户已经觉得系统卡死了。我接入了SSE流式输出,让大模型逐字生成的同时前端实时接收,第一句话往往在一两秒内就能出现,体验上会和打字机一样顺畅。Spring AI对ChatClient提供了.stream()方法,配合WebFlux的Flux返回即可。
@PostMapping(value = "/api/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> chatStream(@RequestBody ChatRequest request) { return Flux.defer(() -> { List<Document> docs = vectorStore.similaritySearch( SearchRequest.builder().query(request.question()).topK(6).build() ); String context = buildContext(docs); return chatClient.stream() .prompt(buildPrompt(context, request.question())) .stream() .content(); }); }需要注意一个细节:流式接口在传给前端之前,最好对返回内容做一次过滤,因为大模型在流式生成时可能会把提示词里的某些内容原样带出来。尽管这个概率不高,但一旦出现就很影响体验。可以在Flux链路上加一个简单的字符串过滤。
5.4 会话管理与多轮追问处理
客服系统不可能只回答单轮问题,用户往往会追问"那运费谁来承担"这种依赖上文的问题。如果每一轮都把整个对话历史发给大模型,成本高、上下文也容易超限。我采用了一种轻量级的多轮处理方案:对话历史滚动窗口保留最近6轮,在组装提示词时把历史记录一并塞进去;同时检索时只使用当前问题,不做意图改写,保持检索的及时性。
这个方案的优点是实现简单,缺点是遇到"它"这类代词指向不明确的多轮问题时,检索质量会下降。比如用户先问"空气炸锅的保修期",再追问"那它噪音大吗",向量检索会把"它"也纳入语义计算,结果可能不准。要解决这个问题,后续可以考虑引入一个问题改写模型,先把"它"替换为"空气炸锅"再检索。目前这个项目先以单轮为主,多轮能力作为下一个迭代目标。
6. 实测效果展示与调优建议
6.1 三类问题的问答效果复盘
系统跑通后,我用一份模拟电商知识库做了完整的验证测试,知识库里包含商品说明书、退换货政策、常见问题FAQ三类文档。测试问题也分成对应的三类,下面是几组有代表性的结果。
问题一:"7天无理由退货的起算时间是什么时候?"系统检索到退换货政策文档中关于起算时间的段落,回答"从签收次日起算,7天内可以申请无理由退货,运费由买家承担"。这个答案准确且带来源,是符合预期的。
问题二:"这款电饭煲的内胆是什么涂层?"因为知识库里有详细规格表的PDF,而且表格结构被正确还原成键值对,系统能直接给出"聚四氟乙烯涂层,容量4L"这样的精确答案。这个场景验证了表格解析的价值。
问题三:"你们这个杯子能微波炉加热吗?"知识库里有一段FAQ提到"杯身材质为PP聚丙烯,可耐120摄氏度高温,不建议微波炉加热"。系统回答"根据文档说明,不建议微波炉加热",但在后续追问中暴露了"为什么"时,回答对材质解释不够清楚。这说明检索的片段确实命中了,但语义理解还有提升空间。
反面的例子也有:当用户问"你们公司有没有实体店",而知识库完全没有相关内容时,系统正确返回了"抱歉,这个问题我需要转人工处理",说明提示词中的兜底约束生效了,没有硬编造一个答案出来。
6.2 参数调优经验小结
整个联调过程里,我最大的体会是RAG系统的表现是多个环节参数叠加的结果,调优时不能只看单个参数。我整理了一张参数对照表,记录了几个关键参数的初始值和调整后的效果:
| 参数 | 初始值 | 调整后 | 调整原因 |
|---|---|---|---|
| 单块长度 | 512字符 | 600-800字符 | 块太短导致语义不完整 |
| 重叠长度 | 50字符 | 80字符 | 减少边界截断导致的漏检 |
| Top-K | 4 | 6 | 召回不足导致答非所问 |
| 相似度阈值 | 0.75 | 0.68 | 部分正确答案向量距离偏大 |
| 温度 | 默认 | 0.2 | 降低生成随机性,减少幻觉 |
有一个参数组合的坑值得单独说明:如果把相似度阈值调得太低,比如0.5,检索系统会把大量低相关文本也塞进上下文,大模型的回答会变得空洞且容易跑偏;如果阈值太高,比如0.8,又会错过很多表述不同但语义一致的正确答案。0.6到0.7这个区间是大多数中文电商知识库比较可靠的起点,具体落地时还要用一批真实客服问题做验证集来回归。
6.3 知识更新与增量导入
文档更新是客服系统绕不开的需求。我实现了一套简单的增量导入流程:上传PDF时先计算文件哈希值,和库里已有的记录对比,只有哈希值变化时才触发重新解析和向量化。文档更新后,旧向量要删除,新向量要插入,这个操作如果放在一个事务里完成,可以避免查询时出现新旧数据混合的问题。
增量导入的代码片段如下:
public void ingestPdf(String filePath) { String fileHash = DigestUtils.sha256Hex(new FileInputStream(filePath)); // 检查是否已入库 Integer existId = jdbcTemplate.queryForObject( "SELECT id FROM document WHERE file_hash = ?", (rs, i) -> rs.getInt("id"), fileHash ); if (existId != null) { log.info("document unchanged, skip ingesting"); return; } List<Document> chunks = parseAndSplitPdf(filePath); // 先按来源删除旧向量,再写入新向量 vectorStore.delete(SearchRequest.builder() .filterExpression("source = '" + filePath + "'") .build()); vectorStore.add(chunks); // 记录新的文件哈希 jdbcTemplate.update( "INSERT INTO document(file_name, file_hash, create_time) VALUES (?,?,now())", ... ); }这个设计里,哈希比对把重复解析的开销压到了最低,尤其是客服每周上传修改过的政策文档,哈希机制能保证系统不会一遍遍处理一样的文件。
7. 常见问题与排查技巧实录
7.1 PDF解析阶段的典型问题
PDF解析遇到的坑最多,我把常见的几个问题整理成了一张速查表。
| 症状 | 直接原因 | 解决方案 |
|---|---|---|
| 提取不到任何文本 | PDF是扫描图片,没有文本层 | 接入OCR组件,或先走文档数字化预处理 |
| 提取文本乱序 | PDF内部文本块排列无规律 | 用坐标重建阅读顺序,按y和x排序 |
| 中文双引号变成乱码 | PDF内嵌字体编码不规范 | 用系统字体替换,或指定UTF-8字符集 |
| 表格数据散成一行 | 表格被转成多个独立文本块 | 按坐标聚类识别表格区域,重建行列结构 |
排查时有个比较实用的技巧:解析完成后,可以先把文本输出到一个临时文件人工抽查几页,重点看不规则字符的比例和段落顺序。这个检查虽然机械,但能提前暴露大部分问题,避免整个文档的向量数据全部灌入后再返工。
7.2 检索结果不理想的排查思路
如果发现问答效果不好,先不要急着调大模型提示词,大部分问题都出在检索环节。可以用一个最简单的方法定位:把用户问题单独跑一次检索,肉眼看看返回的文本块是不是真的和问题相关。如果返回的文本块都不相关,那就是检索环节的问题;如果文本块相关但大模型回答不对,再排查提示词环节。
检索不理想的原因有几种:一是文本切块把关键信息切成了两半,比如"保修期"和"12个月"被分到两个块中;二是停用词和同义词干扰,比如用户说"充电"和文档里的"电源"对应不上;三是嵌入模型对某些领域术语理解不足。针对第二类问题,我在提示词里加了一步流程:先做一次查询词改写,把口语化说法转换为知识库里的标准术语,效果提升明显。
一段已经接入查询改写后的检索逻辑:
public String rewriteQuery(String rawQuestion) { String prompt = """ 请把以下用户问题改写为知识库检索用的标准查询词, 保留关键实体,去除口语冗余,直接输出改写结果。 用户问题:%s """.formatted(rawQuestion); return chatClient.call(prompt).getContent(); }7.3 大模型幻觉的压制经验
客服场景最不能接受的就是模型编造不存在的政策条款。除了在提示词里强调"只能依据知识库内容"之外,我还加了一道硬校验:在返回答案之前,先把大模型生成的回答和检索到的知识库片段做一次相关性比对,如果回答中的关键数字、日期在检索片段中完全找不到依据,就拦截掉这条答案改成转人工。这个规则在代码里就是一个简单的字符串包含检查。
public boolean verifyAnswer(String answer, List<Document> docs) { String combined = docs.stream() .map(Document::getContent) .collect(Collectors.joining()); // 提取答案中的数字信息进行比对 List<String> numbers = extractNumbers(answer); long matched = numbers.stream() .filter(num -> combined.contains(num)) .count(); return numbers.isEmpty() || matched * 1.0 / numbers.size() >= 0.7; }这个方案不完美,但它能从机制上把"硬编造日期和价格"这类严重幻觉拦截在用户面前。上线运行一段时间后,我把被拦截的问题日志收集起来做人工复审,发现大多数拦截场景确实是知识库缺数据,转人工的判断是正确的。只有少数场景是把同一份文档中的两个数字混淆了,比如"7日退货期"和"30日保修期",这种需要靠更好的检索排序去解决。
7.4 并发性能与资源消耗
客服系统一上线就会面对并发请求,大模型接口的耗时长、吞吐量相对低,如果不做控制,很容易被打满。我主要做了三层处理:第一层是接口入口按用户维度做并发限制,同一个用户不允许同时发起多个问答请求;第二层是给大模型调用加了信号量限流,超过阈值直接排队返回提示;第三层是流式输出本身降低了首字延迟,即便在排队场景下,用户的等待感知也会好很多。
向量检索的并发开销其实不高,一次相似度查询在数据量几万条时的耗时在几十毫秒内,瓶颈主要在大模型推理延迟。如果后续要支撑更大的流量,可以考虑把大模型的回答结果按高频问题做一层缓存,命中的问题直接从缓存返回,不重复走大模型。电商客服的重复咨询率非常高,缓存策略往往能省掉一半以上的大模型调用成本。
我在这个项目里绕了不少弯路,也积累了一些比较实在的经验。最想说的一点是:RAG系统的核心问题,大概率不在大模型,而在知识处理的工程细节上。PDF解析干不干净、切块合不合理、检索准不准,每一个环节都会对最终问答质量产生实质影响,做的时候别指望一步到位,先用最小闭环跑通,再拿真实客服问题来做回归调优,效果会比你预期的稳定得多。后面如果再扩展,我打算先补上图文的混合理解能力,让客服在回答操作类问题时能给出对应示意图;再往下可以接用户历史订单信息,让"我的订单到哪了"这类问题不再依赖文档,而是直接查实时业务数据。这个项目从零到上线的一套完整流程,希望这份记录能帮后来的人少踩几个坑。