前段时间接了一个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了,写起来顺手很多。
那有人会问,为什么不用Python?Python那边调用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"); // 构造请求体 Map<String, 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(); HttpResponse<String> 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 HttpResponse<String> sendWithRetry(HttpRequest request, int maxRetries) throws IOException, InterruptedException { int attempt = 0; while (true) { try { HttpResponse<String> 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接口转向量,归一化后放进ConcurrentHashMap<String, 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.8?0.75?看起来差不多就定了。但阈值直接决定了系统的召回率和误答率,定高了,用户问的明明是知识库里有答案的问题,你可能拒答了;定低了,用户问一个八竿子打不着的问题,系统硬给一个驴唇不对马嘴的答案。
我推荐的做法是,准备一批标注好的测试样本。
- 正样本:从真实用户日志里捞取那些最终确认“确实该命中某条FAQ”的问题,或者编写人员根据FAQ标准问法改写出的同义问法。正样本的目标是确认系统“该答的要答”。
- 负样本:分两类。第一类是跟FAQ完全无关的问题,比如FAQ全是产品售后问题,你拿“今天天气怎么样”这种来测;第二类是“语义有交集但实际答案不同”的问题。第二类是最有价值的,比如FAQ里有“A产品的保修期是多久”,负样本可以写“B产品的保修期是多久”,两者有相似词汇但答案完全不同。
然后对每个样本,用Embeddings API算出它与所有FAQ的最高相似度,记录这个分数。最后把所有正样本和负样本的相似度分值画成两条分布曲线,看分界点在哪里。
我那次测试的数据如下(虚构示例,但规律是真实的):
| 样本类型 | 数量 | 平均相似度 | 最低分 | 最高分 |
|---|---|---|---|---|
| 正样本 | 200 | 0.87 | 0.79 | 0.95 |
| 负样本(完全无关) | 200 | 0.52 | 0.38 | 0.68 |
| 负样本(语义相关但答不对) | 200 | 0.74 | 0.61 | 0.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 401 | API密钥错误或已过期 | 检查环境变量,临时打印密钥前缀验证 | 重新生成密钥,加入密钥轮换机制 |
| 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加余弦相似度加动态阈值的小系统,已经稳定扛住了业务压力,希望这次的拆解对你也有同样的帮助。