ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java 17调用Embeddings实现FAQ语义匹配与拒答阈值调优

Java 17调用Embeddings实现FAQ语义匹配与拒答阈值调优 前段时间接了一个FAQ智能问答的小项目需求很直接用户在前台输入问题系统自动匹配知识库里最相近的FAQ条目并给出答案如果用户问的东西知识库里根本没有那就别硬答必须老老实实拒答或者转人工。听起来不难但真正做起来卡在最核心的一个点上怎么判断“用户的问题”和“知识库里的问题”到底像不像以及像到什么程度才敢把答案抛出去。当时定的技术方案就是标题里这套组合Java 17调用Embeddings API把文本转成向量再用余弦相似度算语义距离最后设定一个拒答阈值来控制答还是拒。整套链路做完个人觉得这套思路在中小规模的FAQ场景里非常实用特意把中间的设计决策、代码细节、调参过程、踩坑记录都整理出来给后面要做类似需求的朋友一个完整的参考。1. 项目整体设计为什么是Java 17加Embeddings这套组合1.1 核心需求拆解FAQ问答里的“答”与“拒”先把这个项目的真实痛点说清楚。以前做FAQ问答很多人用的是关键词匹配或者干脆用数据库LIKE模糊查询。问题很明显用户问“钱怎么退”知识库里写的是“退款流程”关键词对不上匹配就挂了。后来有人用倒排索引加TF-IDF稍微好一点但语义层面依然无能为力。用户说“我想解约”库里写的是“终止合同”字面上八竿子打不着实际却是同一个意思。所以这个项目的第一需求就是把匹配从“字面匹配”升级到“语义匹配”。Embeddings API能把一段文本映射成一个几百维的浮点向量语义相近的句子向量在空间里的方向也相近。这是整个方案的基石。第二个需求同样关键要敢于拒答。很多问答系统的问题在于它总想回答匹配度明明很低还要硬凑一个答案出来用户一看就是答非所问体验极差。所以我们要给系统设定一条“底线”——相似度低于某个阈值就不给答案。这条线怎么定就是标题里说的“FAQ拒答阈值”。第三个需求技术栈要用Java。这可能是很多团队的真实情况核心业务系统是Java技术栈不可能为了一个FAQ匹配功能单独引一套Python服务。所以我选Java 17一方面是团队现有技术栈另一方面Java 17的某些特性确实让写这种HTTP调用和数值计算的代码舒服了不少。1.2 技术选型分析Java 17的实用特性与方案取舍Java 17是LTS版本这点很关键。很多团队还在Java 8上但Java 17不仅性能有提升代码表达力也强很多。这个项目里我实际用到了几个特性。第一个是var关键字局部变量类型推断。写HttpClient的请求体、解析JSON的时候代码能短一截又不影响可读性。第二个是HttpClient。Java 11开始内置的HttpClient已经相当成熟支持HTTP/2、异步发送、连接超时配置不用再引一堆第三方HTTP库。调用Embeddings API这种简单的REST接口完全够用。第三个是record。定义FAQ条目、相似度结果这类不可变数据对象一行代码就搞定省掉大量Lombok注解或者手写getter/setter。还有个细节Java 17的Stream.toList()终于不用再collect了写起来顺手很多。那有人会问为什么不用PythonPython那边调用Embeddings确实生态更全比如某些知名的向量计算库但问题是要把FAQ服务嵌进一个Java后台里。为了一个匹配模块再搞一个Python微服务运维成本、链路延迟都上去了。除非你有十万级以上的FAQ要做向量检索否则纯Java方案完全够用毫秒级延迟部署也简单。1.3 整体架构一次FAQ查询的完整链路先整体看一遍数据流后面再拆细节。用户输入一个问题文本系统先做基础预处理去空白、统一大小写中文场景其实主要是去掉多余空格和换行、检查是否为空。然后调用Embeddings API把问题文本转成向量。知识库里的FAQ条目提前已经全部转好向量缓存在内存里。接着把用户向量和每一条FAQ向量做余弦相似度找出最高分。如果最高分大于等于拒答阈值就返回这条FAQ的答案如果低于阈值就返回拒答话术。这条链路看起来简单但每个环节都有坑。Embeddings API怎么接、超时怎么处理、余弦相似度怎么算快一点、阈值到底定多少这些都是标题里说的核心细节。下面逐一展开。2. Embeddings API接入Java 17下的调用封装2.1 Embeddings到底做了什么一个生活化类比先给还没接触过Embeddings的朋友打个底。Embeddings本质上是把一段文本变成一串数字。比如“如何退款”和“退款流程怎么走”这两句话字面不同但经过模型转换后生成的数字序列在空间里的方向非常接近。你可以把每个维度想象成文本的某种特征比如某个维度可能捕捉了“动作”的成分另一个维度捕捉了“对象”的成分。模型在训练时把这些特征编码进了向量空间。一个常用的类比是把每个词或句子想象成地图上的一个点。“苹果”和“水果”离得近“苹果”和“汽车”离得远。Embeddings就是把语言变成了可计算的地理坐标。余弦相似度算的就是两个向量之间的夹角余弦值夹角越小方向越一致语义越接近。这个值范围在-1到1之间因为Embeddings模型的输出通常经过归一化处理实际应用中大多数相似度都落在0到1之间。值得注意的是Embeddings API传入的是整段文本不是单个词。模型会综合考虑上下文所以比传统的词向量比如Word2Vec把词向量直接平均效果好得多。“苹果”在不同语境里的向量是有差异的这靠的是模型的深层语义编码能力。2.2 HttpClient封装环境变量、超时与重试我选的是Java内置的java.net.http.HttpClient重点说几个容易踩坑的地方。API密钥不要硬编码进代码里用好环境变量。启动服务时通过环境变量注入或者用配置中心拉取总之别把密钥提交到仓库。读取环境变量的方式很简单String apiKey System.getenv(EMBEDDING_API_KEY); if (apiKey null || apiKey.isBlank()) { throw new IllegalStateException(缺少 EMBEDDING_API_KEY 环境变量); }HttpClient本身要配置超时时间。Embeddings服务是外部依赖网络抖动、服务端过载都可能发生。我设置的是连接超时3秒、请求超时10秒超过了就按失败处理。HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .build();然后封装一个getEmbedding方法入参是文本出参是float[]向量。核心步骤如下public float[] getEmbedding(String text) throws IOException, InterruptedException { String apiKey System.getenv(EMBEDDING_API_KEY); // 构造请求体 MapString, Object payload new HashMap(); payload.put(input, text); payload.put(model, embeddingModel); String body objectMapper.writeValueAsString(payload); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(embeddingEndpoint)) .header(Content-Type, application/json) .header(Authorization, Bearer apiKey) .timeout(Duration.ofSeconds(10)) .POST(BodyPublishers.ofString(body, StandardCharsets.UTF_8)) .build(); HttpResponseString response client.send(request, BodyHandlers.ofString()); if (response.statusCode() 200) { JsonNode root objectMapper.readTree(response.body()); JsonNode embeddingNode root.path(data).get(0).path(embedding); double[] vector objectMapper.convertValue(embeddingNode, double[].class); return toFloatArray(vector); } else { throw new RuntimeException(Embeddings调用失败状态码 response.statusCode()); } }有几个细节特别提醒字符编码请求体里面如果有中文一定要指定StandardCharsets.UTF_8否则服务端可能收到乱码。响应结构不同Embeddings服务的响应结构有差异但基本都是嵌套的JSON对象用Jackson的JsonNode逐层取别直接整个反序列化成一个DTO容易因为字段不匹配炸掉。模型参数有的服务支持通过参数控制向量维度但我不建议随便降维保持默认输出维度兼容性最好。2.3 重试策略指数退避的正确姿势外部API调用一定会遇到失败尤其要注意限流。Embeddings服务通常有每分钟调用次数限制不同厂商不同。很多人在项目初期调试没问题一上线并发稍高就收到HTTP 429状态码。我加了一个简单的重试机制遇到429或者5xx状态码就等待一段时间重试。等待时间采用指数退避避免重试风暴把服务端打挂。private HttpResponseString sendWithRetry(HttpRequest request, int maxRetries) throws IOException, InterruptedException { int attempt 0; while (true) { try { HttpResponseString response client.send(request, BodyHandlers.ofString()); if (response.statusCode() 500 response.statusCode() ! 429) { return response; } } catch (IOException e) { // 网络异常继续重试 } attempt; if (attempt maxRetries) { throw new RuntimeException(Embeddings调用多次重试后仍然失败); } long waitMillis (long) Math.pow(2, attempt) * 200; Thread.sleep(waitMillis); } }这个重试逻辑本身不难难的是重试时的消息语义。因为Embeddings是幂等的同样一段文本重复调用返回的向量基本一致所以可以放心重试。但如果你后面接入的是对话生成类接口重试要小心重复扣费或者生成内容不一致那时候最好结合业务去做去重。还有一个容易忽略的点批量Embeddings接口。我当时做的是逐条调用因为FAQ预计算是离线任务用户查询是单条文本批量意义不大。但如果你的场景是一次性给几千条FAQ做向量化务必看看API是否支持批量输入。批量调用不仅能降低请求次数还能利用服务端的并行计算速度能快好几倍。3. 余弦相似度的计算与FAQ匹配实现3.1 余弦相似度的Java实现从公式到代码余弦相似度的公式很简单初中数学水平就能看懂cos(A, B) (A向量点乘B向量) / (A向量长度 x B向量长度)下面是我实际用的Java实现public static double cosineSimilarity(float[] vectorA, float[] vectorB) { if (vectorA.length ! vectorB.length) { throw new IllegalArgumentException(向量维度不一致A vectorA.length , B vectorB.length); } double dotProduct 0.0; double normA 0.0; double normB 0.0; for (int i 0; i vectorA.length; i) { dotProduct (double) vectorA[i] * vectorB[i]; normA (double) vectorA[i] * vectorA[i]; normB (double) vectorB[i] * vectorB[i]; } // 防止零向量 if (normA 0.0 || normB 0.0) { return 0.0; } return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); }这里面有个性能优化的点如果你把每个向量预先归一化也就是让每个向量的长度为1那么余弦相似度就直接退化为点积。点积运算在CPU上是非常快的而且省去每次计算平方根的开销。// 预归一化在缓存FAQ向量时执行一次 private static float[] normalize(float[] vector) { double norm 0.0; for (float v : vector) { norm (double) v * v; } norm Math.sqrt(norm); if (norm 0.0) return vector; float[] result new float[vector.length]; for (int i 0; i vector.length; i) { result[i] (float) (vector[i] / norm); } return result; } // 匹配时直接算点积 public static double dotProduct(float[] normalizedA, float[] normalizedB) { double result 0.0; for (int i 0; i normalizedA.length; i) { result (double) normalizedA[i] * normalizedB[i]; } return result; }实测下来归一化后计算速度比每次现算余弦相似度快了接近一倍。FAQ条数多的时候这个优化还是值得做的。这里还要提醒一个浮点精度问题。Embeddings接口返回的向量很多模型输出的float值本身有精度问题比如显示0.8147323和0.8147324这样的差别。在做排序比较时这个精度完全够用不需要额外处理。但如果你做的是严格相等判断那就会出问题。比如缓存判断某个向量是否已经算过不要直接Arrays.equals而是比较内容的哈希值或者干脆存一下文本的哈希值做唯一键。3.2 FAQ预计算与内存缓存离线向量化知识库里的FAQ条目需要提前转换成向量。这个过程我做成一个独立的初始化步骤启动时加载或者通过一个管理接口手动触发。每条FAQ在知识库里有以下字段id、question标准问法、answer答案内容和status是否启用。我把向量化后的结果存放在一个FaqEntry记录里public record FaqEntry( String id, String question, String answer, float[] questionVector ) {}启动时的加载流程是这样的先从数据库查全部启用的FAQ然后逐条调用Embeddings接口转向量归一化后放进ConcurrentHashMapString, FaqEntry。键是FAQ的id值是包含了向量和原文本的记录。这里有一个工程上的取舍如果FAQ有几十万条逐条在服务启动时调用API是不现实的光网络请求就要很久。所以离线任务先把向量算好存到数据库或者本地文件里服务启动时直接读缓存不走API。等到新增FAQ时再单独触发向量化写回缓存。3.3 多候选匹配策略最高分之外还要看什么最简单的FAQ匹配就是取相似度最高的那一条。但实际操作中我会多留一个心眼只看最高分不够还要看最高分和次高分之间的差距。举个例子。知识库里有“如何申请发票”和“发票抬头怎么填”两条FAQ。用户问“发票怎么申请”这两条FAQ的相似度可能分别是0.86和0.83差距很小。这时候如果只返回最高分那条用户拿到答案可能发现不完全对。所以我在匹配逻辑里加了一个差异度系数最高分和次高分的差值如果小于某个阈值比如0.03就判定为“候选区分度不足”走拒答流程或者返回一个包含多条候选的引导话术。public FaqMatchResult match(String userQuestion) { float[] userVector normalize(getEmbedding(userQuestion)); double bestScore -1.0; double secondBestScore -1.0; String bestFaqId null; for (FaqEntry entry : faqCache.values()) { double score dotProduct(userVector, entry.questionVector()); if (score bestScore) { secondBestScore bestScore; bestScore score; bestFaqId entry.id(); } else if (score secondBestScore) { secondBestScore score; } } if (bestScore rejectThreshold) { return FaqMatchResult.rejected(抱歉目前知识库中没有找到匹配的答案请尝试换个说法或联系人工客服。); } if (bestScore - secondBestScore ambiguityThreshold) { return FaqMatchResult.ambiguous(您的问题可能涉及多个方向请明确一下...); } return FaqMatchResult.accepted(bestFaqId, bestScore); }这个策略上线后明显减少了“答非所问”的投诉。用户问题正好落在两个FAQ之间的时候与其猜一个不如让用户进一步明确意图。多候选匹配还有一个作用辅助阈值标定。通过统计最高分和次高分的分布你能发现哪些FAQ之间的语义间隔太密需要补充标准问法。这个后面讲调优的时候细说。4. 拒答阈值的设定与调优4.1 阈值不是拍脑袋基于正负样本的标定方法这是我写这篇博文最想重点强调的部分。很多项目在阈值这里完全是拍脑袋定的0.80.75看起来差不多就定了。但阈值直接决定了系统的召回率和误答率定高了用户问的明明是知识库里有答案的问题你可能拒答了定低了用户问一个八竿子打不着的问题系统硬给一个驴唇不对马嘴的答案。我推荐的做法是准备一批标注好的测试样本。正样本从真实用户日志里捞取那些最终确认“确实该命中某条FAQ”的问题或者编写人员根据FAQ标准问法改写出的同义问法。正样本的目标是确认系统“该答的要答”。负样本分两类。第一类是跟FAQ完全无关的问题比如FAQ全是产品售后问题你拿“今天天气怎么样”这种来测第二类是“语义有交集但实际答案不同”的问题。第二类是最有价值的比如FAQ里有“A产品的保修期是多久”负样本可以写“B产品的保修期是多久”两者有相似词汇但答案完全不同。然后对每个样本用Embeddings API算出它与所有FAQ的最高相似度记录这个分数。最后把所有正样本和负样本的相似度分值画成两条分布曲线看分界点在哪里。我那次测试的数据如下虚构示例但规律是真实的样本类型数量平均相似度最低分最高分正样本2000.870.790.95负样本完全无关2000.520.380.68负样本语义相关但答不对2000.740.610.82完全无关的负样本很容易分开难的是“语义相关但答不对”这一类它和正样本的分数区间有重叠。比如某些正样本的最低分到了0.79而语义相关负样本的最高分能到0.82。你没法靠一个固定阈值完美分割两者。这种情况下我设定的标准是优先保证已命中的答案足够可靠允许一小部分该答的问题被拒。因为对用户来说被拒答后他可以换个说法再问损失相对可控但如果答错了用户会对整个系统失去信任流失风险更大。所以我把初始阈值定在了0.82并在这个基础上做了动态调整。4.2 动态阈值不是每条FAQ都用一个阈值如果你有几十条FAQ每条只有一个标准问法固定阈值问题不大。但当FAQ数量多起来之后你会发现不同FAQ在向量空间里的分布密度不一样。有的FAQ标准问法写得比较精确同类问题的相似度普遍偏高有的FAQ标准问法写得比较笼统正常命中的问题相似度也不太稳定。所以我在固定阈值的基础上给每条FAQ单独维护了一个浮动阈值。具体做法是FAQ初始化时先用正样本测试一遍这条FAQ的典型相似度分布取一个该FAQ自己的“基准线”通常比全局阈值低0.03到0.05。匹配时如果最高分大于全局阈值直接接受如果最高分在“该FAQ基准线”附近但低于全局阈值还需要附加条件它与次高分的差距要足够大且与最佳候选FAQ的相似度在另一个可接受下限之上。这样做的效果是对表述精确、语义特征明显的FAQ系统稍微宽容一些对表述模糊、容易与其他FAQ混淆的FAQ系统更严格一些。整体拒答率比用单一阈值时下降了约6个百分点同时误答率没有上升。这里有个操作细节动态阈值的调整一定基于数据反馈不要靠感觉调。我把每次匹配的相似度分值、命中的FAQ id、用户是否最后点了“解决了”的反馈都记录下来每两周重新跑一次标定流程动态刷新阈值。4.3 拒答之后怎么办兜底话术与降级路径阈值再合理拒答永远是不可避免的。关键是拒答之后系统不能让用户觉得“这机器人就是个废物”。我做了几件小事体验提升非常明显。第一拒答话术里引导用户补充信息或者换个关键词。不是说“我不懂”就完了而是说“没有找到完全匹配的答案您可以尝试输入更具体的关键词比如产品名称加问题类型”。第二提供“转人工客服”的入口。这个业务上比较敏感但技术上讲就是FAQ匹配不上时弹出一个转人工按钮点击后直接接入工单系统。第三也是我比较得意的一个设计相似度低时展示知识库里的热门问题列表。既然猜不准用户想问什么就把用户可能感兴趣的几条热门FAQ展示出来让用户自己点。实测这个路径的点击率在拒答场景中占了约20%算是把“答不上来”变成了“帮用户找答案”。5. 实测过程中的坑与排查实录5.1 高频问题速查表每条都是实际踩过现象可能原因排查方式解决建议HTTP 401API密钥错误或已过期检查环境变量临时打印密钥前缀验证重新生成密钥加入密钥轮换机制HTTP 429触发限流看响应头Retry-After字段指数退避重试或申请提高限额HTTP 500服务端异常检查请求体是否超长或非法字符对输入做截断重新提交解析JSON失败响应结构变化打印原始响应体用JsonNode逐层取新增兼容分支相似度全部为0向量维度不匹配或零向量打印向量长度和范数统一模型排除空文本中文乱码缺少UTF-8编码声明检查请求体的Content-Type用StandardCharsets.UTF_8构造BodyPublisher并发调用频繁超时线程数不足或连接池满观察线程栈和连接数HttpClient复用单例必要时用异步调用这里多讲一个细节HttpClient一定要复用单例。如果你在每次调用时都new HttpClient()等于每次请求都建立新的连接池线程开销和TCP握手开销都很大。我建议把HttpClient定义成static字段全局复用。实际测试里复用单例后QPS提升了将近一半。另外一个容易踩的坑是线程饥饿。我用的是同步调用FAQ数量大时每个请求占一个线程做IO等待。如果你们的Java服务用的是传统线程池并发一高线程数会被打满。Java 17本身没有虚拟线程那是Java 21的但你可以用Tomcat的异步Servlet或者把HttpClient的sendAsync用起来。我当时没那么大压力就继续用同步调用配合Tomcat的线程池压测下来300并发没问题能满足业务需求。如果你预判并发很高建议提前用sendAsync做异步化。5.2 三类典型误判案例阈值调优的现场记录第一类误判该答未答。一次用户问“发货后几天能到”知识库里有对应的物流时效FAQ但当时阈值0.82这条问题的相似度只有0.78被拒了。分析发现FAQ标准问法写的是“物流配送时间多长”用户的口语化表达和书面语差异大Embeddings虽然抓到了语义但分数还是差一点。解决方法是给这条FAQ增加同义问法比如“发货后多久能到”“物流几天到货”作为扩展问题再重新计算向量。加完扩展问法后最高相似度升到0.86。这也是一个很重要的经验阈值不是唯一工具优化FAQ标准问法和同义问法同样重要。第二类误判答非所问。用户问“会员积分可以换什么”知识库里有“积分兑换流程”和“积分获取规则”两条FAQ两条相似度都超过0.75最高0.77。当时阈值0.82系统拒答了理论上没问题但用户确实想了解“换什么”知识库里这条信息其实是有的只是藏在“积分兑换流程”的答案里问题覆盖不到。这就暴露出一个知识库设计问题答案内容和问题的匹配度不够精确。我们把“积分兑换流程”的答案里拆出一句“积分可兑换的商品清单”单独建了一条FAQ。所以阈值调优不只是数值问题更是对知识库质量的检验。第三类误判置信度虚高。负样本里有一条“电池不耐用怎么办”跟FAQ“电池保修政策”的最高相似度达到了0.84超过了阈值系统给了保修政策的答案。细看原因两个问题都含有“电池”向量模型给了较高权重但实际用户问的是性能问题不是保修问题。解决方案有两层一是给这条FAQ加限定的关键词规则当问题包含“怎么办”“如何解决”时降低它的优先权重二是调低这条FAQ的动态阈值让它更谨慎。这种“词面重合、语义偏离”的情况是Embeddings模型的典型弱点纯粹的余弦相似度解决不了需要叠加少量规则做辅助。5.3 上线后的监控与迭代不要一劳永逸FAQ匹配系统跟其他系统一样上线只是开始。我建议至少监控三个指标。拒答率每天拒答的问题占比。拒答率突然飙升可能是用户提出了新类型的问题也可能是某条FAQ被下线了。拒答率太低也不是好事可能意味着阈值过于宽松误答在悄悄变多。相似度分值分布统计所有问答请求的最高相似度的分桶分布。这个数据比平均分更直观。如果大量请求集中在0.75到0.80之间说明用户的真实提问方式和知识库的标准表达存在系统性差异要么补同义问法要么考虑调整阈值。转人工率这个指标最能反映用户对问答的满意度。拒答之后转人工的比例高说明拒答本身不可怕用户需要的是帮助但如果是匹配成功后还转人工就说明答案质量有问题要重点检查那条FAQ的内容。我当时还写了一个小的定时任务每天捞取被拒答的用户问题去重后发给业务人员让他们判断这些问题是否该进知识库。两周跑下来新增了几十条高频FAQ整个系统的命中率往上走了一个台阶。这套闭环一旦跑起来方案的价值会越来越大。个人体会与一个小技巧整套方案做完我最大的体会是Embeddings加余弦相似度解决的是“语义匹配”这层问题但工程落地真正难点在“阈值怎么定”和“知识库怎么维护”。很多博客只写到调用API、算相似度就结束了但实际项目里你还要处理重试、缓存、并发、误判、监控这一大堆事情。最后分享一个小技巧没事别去调Embeddings API的某些高级参数。默认配置通常是经过最多场景验证的组合。我一开始自己改温度、改截断上限结果生成出的向量在匹配任务上反而变差了后来全部恢复默认值。这个方案后续还能扩展的方向很多比如把FAQ聚类做成自动分类用向量近邻搜索替代全量扫描或者把用户反馈引入在线学习。但那些都是后话当前这套Java 17加Embeddings加余弦相似度加动态阈值的小系统已经稳定扛住了业务压力希望这次的拆解对你也有同样的帮助。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进