Java 后端最近一年面试AI 相关的问题肉眼可见地变多了。我帮朋友做模拟面试时最常被问到的一个题就是标题里这个Embedding 和 LLM 输出向量到底啥关系大部分候选人第一反应都是“都是向量差不多吧”。这句话也不能算错但太笼统了。只要面试官再追一句“那你的线上 RAG 系统里为什么不直接拿生成模型的向量做相似度检索”很多人就开始嗯嗯啊啊。这个题表面上考概念实际上考的是你能不能把“文本 → 向量化 → 建索引 → 检索 → 交给 LLM → 再输出”这条完整链路用工程语言讲清楚。这篇文章我打算把它讲透直接用 Java 侧的生产级代码把两条链路串一遍中间会穿插我在真实项目里踩过的坑。1. 面试官揪着这道题不放到底在面什么1.1 为什么 Java 后端面试突然开始问向量以前 Java 后端面试问的是 JVM、并发、Spring、分布式事务这些东西现在还是必考但这两年明显多了一类题模型接入、向量检索、RAG 架构、上下文管理。原因不复杂。AI 应用长在后端系统里而 Java 是后端存量最大的语言。你可以不训练模型但你得写接口去调用模型你可以不研究算法但你得把知识库切片、向量化、存进数据库你可以不调优模型参数但你得处理 token 超限、向量维度不一致、检索返回空结果这类生产问题。所以面试官问 Embedding 和 LLM 输出向量不是单纯考你背概念而是在模拟一个真实工作场景给你一个查询你要决定哪条向量链路可用怎么用怎么落地。这个决策能力才是 Java 后端在 AI 时代的新增量。1.2 拆开题干三个隐藏考点我一般会把这道题拆成三个层次第一个层次是概念理解。你能不能说清楚 Embedding 是什么LLM 输出向量又是指哪几种向量。这里最容易暴露“名词党”话术很熟但不知道底层长什么样。第二个层次是技术选型。面试官真正想听的是为什么相似度检索通常用 Embedding 模型而不是拿生成模型的输出去算。这背后是模型训练目标和向量空间性质之间的因果推断。第三个层次是工程实现。你一旦说“我用过”面试官基本会马上接一句“用 Java 怎么调怎么算余弦怎么放在线上不会把内存干爆”这一关纯粹考实战经验没写过就是没写过。后面两个大节我会按这三个层次展开。先把原理层讲清楚再上代码。2. 原理层面Embedding 与 LLM 输出向量差在哪2.1 一句话分清两种 Embedding“Embedding”这个词在 AI 语境里至少有两种含义面试时如果不先区分后面全部混乱。第一种是Token Embedding也叫词嵌入。Transformer 模型在接收文本时会把每个 token 映射成一个稠密向量。这个向量是从一个巨大的查表操作里拿出来的相当于每个 token 有一行对应的初始化向量训练过程中不断更新。它解决的是把离散 token 变成连续数字的问题是模型的输入不是模型最终的输出。以 GPT 这类模型为例token embedding 的维度通常和模型的 hidden size 一致比如 4096、8192 这样。第二种是文本 Embedding也就是日常说的 Embedding API 做出来的东西。输入一整句话输出一个固定长度的浮点向量。OpenAI 的 text-embedding-3-small 输出 1536 维BGE 系列常见的是 1024 维。它解决的是语义相似度计算问题两句话意思越接近向量在空间里的距离越近夹角越小。大多数面试题里问的“Embedding”指的都是第二种——文本 Embedding 模型的结果。而 LLM 输出向量完全是另一批产物。2.2 LLM 内部到底输出了哪些“向量”LLM 在跑一次推理时内部会产生多种向量面试里最常被问到的有三类。第一类是注意力机制中的 Q、K、V 向量。每个 token 都会生成 Query、Key、Value 三个向量Q 可以理解成“我想找什么”K 是“我是什么内容的索引”V 是“我真正能提供的信息”。这三个向量是模型理解上下文的关键但它们只存在于模型内部业务侧基本拿不到。第二类是每一层 Transformer 输出的 hidden state。你输入一句话进去模型会一层一层变换 token 的表示每一层都会产出一组向量。最后一层的 hidden state 经常被叫做“输出向量”很多人会把它误认为可以通用任何地方。可以这么理解它它是模型在生成下一个 token 之前对当前上下文做的一次完整压缩表达。第三类是输出层的 logits 向量。模型生成下一个 token 时会对词表里每一个词打一个分数这个分数列表就是 logits。通过 softmax 变成概率之后再根据采样策略挑出最终 token。这是 LLM 真正“输出”的东西也和用户感受到的文本一一对应。所以当你拿“LLM 输出向量”这个说法去问不同人有人想到的是 hidden state有人想到的是 logprobs有人想到的是注意力分数。Java 面试里最值得聊的其实是最后一层 hidden state 的值因为它最容易被误用成检索向量。2.3 为什么“直接用 LLM 输出向量做检索”看起来能跑但会翻车有一类问题我遇到太多次既然 LLM 最后一层 hidden state 也是向量长度也和 token embedding 一样那我是不是可以直接拿生成模型的输出向量当 Embedding 用答案是可以跑通但不稳定而且不好维护。关键在于训练目标不同。文本 Embedding 模型训练时目标是拉近相似文本的向量距离、推开不相似文本的向量距离。它优化的是语义空间。而生成式 LLM 训练时目标是最大化下一个 token 的预测概率也就是让输出的文本越流畅、越符合人类语言习惯越好。至于语义空间是不是规整它不在乎。这就导致生成模型的 hidden state 空间存在几个典型问题同义句的表示可能距离很远不同写法的问法向量跑偏句子长度对向量分布的影响很明显。我做过一个简单对比把同一个知识库的问题换成同义表达用专门 Embedding 模型去检索Top5 召回基本稳定用 LLM hidden state 去检索结果经常大变。这就像你要给货物分类你请了一个研究“怎么把话说得更溜”的语言专家让他按语义分类他能干但不是专业对口误分类率自然高。生产系统不能赌这个稳定性。3. 生产级 Java 代码实战打通两条链路原理讲完面试官如果满意接下来一定会把你按到代码这个层面。Java 侧做 AI 应用最核心的工作不是研究模型而是把模型调用封装成稳定、可替换、可测试的服务。3.1 先定接口把 Embedding 服务封装成自己能替换的东西我建议第一步不要直接写 HTTP 调用先定义一个接口。因为线上很容易换模型从 OpenAI 换成国内模型或者从自建 BGE 换到其他向量模型都是常态。只要接口稳定替换成本就只是一份配置。下面这个接口是我在项目里常用的形态public interface EmbeddingService { EmbeddingResult embed(String text); ListEmbeddingResult embedBatch(ListString texts); } public record EmbeddingResult( String text, float[] vector, int totalTokens, String model ) { public double dimension() { return vector.length; } }用record定义返回结构很合适不可变、清晰、天然适合做日志和调试。float[]保存向量是为了内存效率ListFloat会有大量装箱开销一条线上跑几十万文档的时候差距明显。为什么接口里要返回totalTokens和model因为生产环境必须能统计成本。Embedding 是有 token 费用的而且不同模型返回维度不同如果不记录后面排查问题连哪次调用花了多少钱都看不出来。这也是新人最容易漏掉的设计点。3.2 调用 Embedding APIHTTP 客户端、模型参数和 JSON 解析实现类我一般用 Java 自带的HttpClient加 Jackson 做 JSON 序列化和反序列化。不引入额外的 SDK一是减少依赖二是面试的时候你能把底层讲清楚。public class OpenAiEmbeddingClient implements EmbeddingService { private final HttpClient httpClient; private final ObjectMapper mapper; private final String apiKey; private final String endpoint; private final String model; public OpenAiEmbeddingClient(String endpoint, String apiKey, String model, int connectTimeoutMs, int requestTimeoutMs) { this.mapper new ObjectMapper(); this.endpoint endpoint; this.apiKey apiKey; this.model model; this.httpClient HttpClient.newBuilder() .connectTimeout(Duration.ofMillis(connectTimeoutMs)) .executor(Executors.newVirtualThreadPerTaskExecutor()) .build(); } }有几个细节比代码本身更重要。第一HttpClient要复用不要每次调用都 new 一个。连接池、HTTP 连接复用都在这个实例上每次创建连接不光慢还会把连接句柄打满。第二现在 Java 21 之后可以用虚拟线程执行器遇到多个 Embedding 并发调用时可以避免阻塞 Tomcat 的业务线程。这在批量向量化场景很关键。第三超时一定要配置。模型服务偶尔会慢或者 token 输入太长响应可能几十秒都不回来没有超时就是生产事故。单条文本的 Embedding 调用可以这样写public EmbeddingResult embed(String text) { MapString, Object payload new HashMap(); payload.put(model, model); payload.put(input, text); payload.put(encoding_format, float); String requestBody; try { requestBody mapper.writeValueAsString(payload); } catch (JsonProcessingException e) { throw new IllegalArgumentException(请求体序列化失败, e); } HttpRequest request HttpRequest.newBuilder() .uri(URI.create(endpoint /v1/embeddings)) .timeout(Duration.ofSeconds(60)) .header(Authorization, Bearer apiKey) .header(Content-Type, application/json) .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build(); try { HttpResponseString response httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() ! 200) { throw new RuntimeException(Embedding 调用失败, status response.statusCode() , body response.body()); } JsonNode root mapper.readTree(response.body()); JsonNode data root.path(data); if (data.isEmpty()) { throw new IllegalStateException(Embedding 响应中没有 data 字段); } float[] vector mapper.convertValue(data.get(0).get(embedding), float[].class); int totalTokens root.path(usage).path(total_tokens).asInt(); return new EmbeddingResult(text, vector, totalTokens, model); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(Embedding 调用被中断, e); } catch (IOException e) { throw new RuntimeException(Embedding 网络调用失败, e); } }这里我刻意用Map和一个普通 POST。为什么不直接用HttpRequest.BodyPublishers.ofString拼 JSON因为文本内容里可能包含双引号、换行、Unicode 字符手工拼 JSON 很容易破坏结构。宁可多写几行代码也要保证序列化正确。3.3 从 LLM 响应里提取向量相关内容很多面试官会追问“你说你把 LLM 输出向量和 Embedding 区别开那你有没有真的看过 LLM 输出的原始响应”了解过程是有必要的。以最主流的大模型对话接口为例如果你的请求开了logprobs响应里的choices[0].logprobs就会出现每个 token 的出现概率信息{ choices: [{ index: 0, logprobs: { content: [ { token: Java, logprob: -0.132, top_logprobs: [ {token: Java, logprob: -0.132}, {token: JAVA, logprob: -1.204} ] } ] }, text: Java }] }这就是我在 2.2 里说的“LLM 输出向量”的一种。你可以用它来分析模型对某个 token 的置信度、做结构化抽取但拿它去算文本相似度并不合适。如果你实际接的是开源模型服务比如 vLLM、TGI 这类推理框架有些可以配置返回 hidden states响应里就会多一个类似这样的字段{ hidden_states: [ [ [0.012, -0.233, 0.491, ...] ] ] }但大多数商业化 API 并不会默认返回 hidden states因为这会让响应体变得巨大。这里每次生成上千个 token每个向量几千维对带宽和服务端内存压力都很大。所以你可以这样理解Embedding 是专门的 APILLM 输出向量是内部计算过程的附属产物不是刻意对外提供的标准服务。3.4 自实现余弦相似度与向量归一化相似度计算是向量检索的核心。生产里最常用的是余弦相似度。两个向量 a 和 b 的余弦值等于它们夹角的余弦公式是cos(a, b) (a · b) / (||a|| · ||b||)这个值范围在 -1 到 1 之间。1 表示方向完全一致0 表示正交无关-1 表示方向完全相反。千万要写对边界条件。我在代码评审里看到过很多版本漏掉了零向量判断一旦数据库里混入一条全零向量结果直接 NaN排序和落库都会出怪问题。一个稳妥的版本是public final class VectorUtils { private VectorUtils() {} public static double cosine(float[] a, float[] b) { if (a.length ! b.length) { throw new IllegalArgumentException( 向量维度不一致: a.length a.length , b.length b.length); } double dot 0.0; double normA 0.0; double normB 0.0; for (int i 0; i a.length; i) { dot (double) a[i] * b[i]; normA (double) a[i] * a[i]; normB (double) b[i] * b[i]; } if (normA 0.0 || normB 0.0) { return 0.0; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } public static float[] normalize(float[] v) { double norm 0.0; for (float x : v) { norm (double) x * x; } if (norm 0.0) { return v; } norm Math.sqrt(norm); float[] result new float[v.length]; for (int i 0; i v.length; i) { result[i] (float) (v[i] / norm); } return result; } }注意我为什么把a[i] * b[i]转成double再乘。因为float的精度有限向量维度上千时累加误差会被放大。用double做中间计算最后再转回需要的精度这套写法在线上跑了很久没有出现过精度导致的检索错乱。有一个重要的工程习惯建议入库前就把向量归一化。归一化之后余弦相似度和内积就是等价的因为分母变成了 1。这时候再配合某些只支持内积检索的索引结果基本一致还能减少检索时的计算量。这就是生产环境和算法实验不一样的地方多花一次归一化的代价换全链路存储和检索的收益。下面这段代码演示在 Java 应用层做 TopK 相似度排序不依赖数据库适合数据量小于百万级或者做缓存查询的场景public ListScoredText search(float[] queryVector, ListEmbeddingResult candidates, int topK) { return candidates.stream() .map(item - new ScoredText( item.text(), VectorUtils.cosine(queryVector, item.vector()), item.vector().length)) .sorted(Comparator.comparingDouble(ScoredText::score).reversed()) .limit(topK) .toList(); } public record ScoredText(String text, double score, int dimension) {}如果数据量再大就别用纯内存方案了往向量数据库的路子走。下一节就讲这个。4. RAG 落地时的 Embedding 工程细节4.1 RAG 为什么不用 LLM 生成向量做检索RAG 是目前 Java 后端接触最多的 AI 架构。流程不复杂先把你自己的文档切片切出来的每个 chunk 用 Embedding 模型变成向量存进向量数据库用户提问时把问题也变成向量在库里做相似度检索找出最相关的几个文档片段最后把这些片段拼进 Prompt发给 LLM 生成答案。这里的关键问题是检索那一步向量从哪来我在第 2 节已经说过LLM hidden state 所在的向量空间没有针对语义相似度优化。而在 RAG 里用户提问和文档片段往往不是同一个句式用户可能非常口语化文档可能全篇书面语。这种场景恰恰是向量检索最容易出错的对向量空间要求最高。用一个没有专门训练过的向量去做从第一步就埋了雷。所以业界标准做法是**需要一个独立的 Embedding 模型专门做文本向量化负责把文档和 query 映射到同一个语义空间。**文档和 query 都必须在同一方向归一化吗至少要用同一个模型且推荐都做归一化。如果你文档用 A 模型query 用 B 模型两个向量空间分布都不同相似度分数再高也没有意义。4.2 pgvector 接入建表、插入、检索一页纸Java 团队因为本来就有 PostgreSQL 的运维经验很多人会优先选 pgvector而不是引入一套全新系统。这个选择是合理的适用于数据量百万级以内、不想再多维护一套存储的场景。初始化扩展和建表可以这样写CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE IF NOT EXISTS documents ( id BIGSERIAL PRIMARY KEY, content TEXT NOT NULL, source TEXT, token_count INT NOT NULL, embedding VECTOR(1536), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_documents_embedding ON documents USING hnsw (embedding vector_cosine_ops);这里有几个容易踩的细节。VECTOR(1536)的维度必须写死和你 embedding 模型输出维度保持一致。如果模型升级后输出变成 2048 维老表向量字段就得重建很麻烦所以选模型的时候就要想清楚。索引我用的是 HNSW适合在线检索召回速度比 IVFFlat 好代价是构建索引占内存和存储。如果你的数据是离线批量入库写完一条查一条频率不高那用 HNSW 也合适。Java 侧插入一条向量可以这样写String sql INSERT INTO documents(content, source, token_count, embedding) VALUES (?, ?, ?, ?::vector) ; try (PreparedStatement ps connection.prepareStatement(sql)) { ps.setString(1, text); ps.setString(2, source); ps.setInt(3, tokenCount); ps.setString(4, toPgVectorString(vector)); ps.executeUpdate(); }toPgVectorString就是把float[]转成 pgvector 可识别的文本格式比如[0.12,0.34,0.56]。这个字符串里不能带空格、注释或者多余字符否则::vector转换会失败。检索时用 pgvector 的余弦距离操作符SELECT id, content, 1 - (embedding ?::vector) AS similarity FROM documents ORDER BY embedding ?::vector LIMIT 5;注意这里有个容易绕晕的点。embedding ?算的是余弦距离距离越小越相似。如果你想呈现一个“相似度”给用户需要用1 - 距离这样分数越接近 1 越相似。很多人第一次写的时候 忘了这个转换最后发现“相似度越高结果越差”因为是距离排序排反了。Java 侧执行时把 float[] 转成同样的 pgvector 文本字符串作为参数传进去即可。这个方案既保住了检索质量又把向量存储的逻辑留在数据库层内存压力远小于堆内全量计算。4.3 批量导入、缓存与维度治理生产环境一定会有批量导入场景比如第一次把整理好的历史文档全部向量化或者每周增量更新一批新闻。批量导入最容易出两个问题线程池无脑开太大。Embedding API 有速率限制并发 100 个请求可能直接被打回 429。我习惯用一个有限线程池比如 8 个线程每个线程循环请求遇到 429 用指数退避等待最多重试 3 次。没有断点续传。如果导入到第 5000 条崩溃了重跑全部成本高。我会在数据库表里加一个embedding_status字段成功置为done失败置为failed下次启动只处理非done的数据。缓存方面同一个文本片段的 Embedding 一定要缓存。因为用户问的问题可能在系统里被反复用于检索文档片段也可能被多个流程读取。用本地Caffeine键是模型名 文本内容哈希值是 embedding 数组TTL 可以设到 7 天。这样能省很大一部分重复调用费用。维度治理是更长期的事。模型升级、字段调整都会改变维度。我建议在配置中心维护好“当前生效维度”并在启动时做校验加载进来任何向量如果长度和当前模型不一致要么拒绝要么标记重新向量化。否则你会在生产环境看到各种诡异的“向量维度不匹配”查半天还不知道问题在哪。5. 生产环境踩坑实录六个高频问题5.1 维度不匹配一个让人头皮发麻的报错有段时间系统报错日志里全是“vector 维度不匹配”。原因是团队里有人把一批旧文档用旧的 embedding 模型向量化了没重新跑一遍就直接丢进新表。新表字段定义成 1536 维旧向量是 1024 维插入时就炸了。后来我干脆在应用层做一次维度校验对每个向量写入前都检查长度发现不对直接拒绝并打日志。从源头挡住错误数据比在数据库里排查快得多。5.2 用了余弦相似度但没归一化有个项目上线后检索评分浮动特别大用户输入一个短词相似度普遍偏高输入较长问句相似度又普遍偏低。查到最后发现是 query 向量没有做归一化计算出的余弦分数受向量模长影响。虽然理论上余弦相似度不受模长干扰但在实际实现里如果你的向量没有统一处理某些模型返回的向量空间本身就不均匀分数波动就出来了。处理方式很简单入库前和查询前统一走一次VectorUtils.normalize分数稳定很多。5.3 HTTP 调用把 Tomcat 线程池打满最早版本里大家在 Controller 里直接调用httpClient.send这是一个同步阻塞调用。高并发时一个请求等着模型返回Tomcat 线程就卡在那里线程池瞬间被占满后面请求全排队接口 RT 从几十毫秒变成几十秒。后来两个方案组合解决一个是把 Embedding 调用线程改成虚拟线程一个是把批量向量化任务丢到独立线程池 消息队列不让同步请求阻塞主业务。线上 RT 立刻恢复到正常水平。5.4 手工拼 JSON特殊字符把语义搞崩了见过有人用字符串拼接 JSON 请求体大概长这样{\input\:\ text \}如果 text 里包含双引号拼接出来的 JSON 直接坏掉包含换行服务端解析也可能出错。最隐蔽的问题是这类字符不会必然报错而是模型收到了被截断或转义乱了的内容Embedding 结果偏差很大查语义问题却查不到。后来统一改成 Jackson 或者别的 JSON 库序列化这类问题再没出现过。5.5 不知道 embedding 模型有最大输入长度Embedding 模型不是无限能接文本。很多模型对输入 token 数有上限超长部分会被截断。注意是“被截断”不是“报错”所以不容易被察觉。截断之后向量表示的是前 512 个 token 或前 8192 个 token 的语义后面的内容全部丢失。我们有一次知识库召回变差排查到最后发现是某个长文档切片逻辑改了切出来的 chunk 太长大量文档被模型静默截断关键信息全在后面检索自然失败。处理在应用层对文本长度做预估超长就按窗口粒度重新切片或者走服务端的截断策略反正要保证“库里存的向量对应的文本内容”和“用户实际看到的内容”一致。5.6 检索只给 top1没有回退策略RAG 系统上线初期我们把检索结果硬编码成只取 top1结果用户提出的问题稍微偏一点检索就空。后来改成 top5并且加了一个阈值当相似度低于 0.6 时认为检索结果不可靠直接返回“未找到相关资料”的兜底文案而不是硬把低质量片段拼给 LLM。这个回退策略看似简单但对用户体验影响巨大。没有回退LLM 会一本正经地拿不相关内容编答案这是 RAG 最让人头疼的问题之一。6. 这道面试题到底怎么答给你一套参考逻辑6.1 三句话定位考点给出有层次的答案我建议的答题顺序是先区分概念再给工程选型最后补一个自己的例子。你可以这样说“我会先区分两个概念。面试官问的 Embedding一般指文本 Embedding 模型输出的语义向量用来做相似度检索而 LLM 输出向量会更宽泛包括 token embedding、每层 hidden state、logits 三种。在做 RAG 系统时我只会把 Embedding 模型输出的向量存进向量库做召回不会用生成模型的 hidden state因为生成模型优化的是预测下一个 token它的向量空间对语义相似度不友好换一种说法结果就可能飘。”这段话把三个考点都覆盖了你有概念、有选型、有理由而且最后那句“换一种说法结果就可能飘”是从实践中总结出来的面试官一听就知道你真跑过。如果再深入一层你可以加一句“而且我实际遇到过维度不一致导致的线上问题最后是在应用层加维度校验解决的。”这一句就是工程能力的信号比背十句话术管用。6.2 把 Java 工程经验自然放进答案当面试官让你讲一个 AI 项目不要一上来就讲“我用了 Spring AI”这件事不是重点。你可以用三段式结构第一段说数据怎么来。比如“我们的知识文档先做切片切片长度大概控制在 500 token然后用 OpenAI 的 text-embedding-3-small 向量化批量入库到 pgvector”。第二段说检索怎么做。“用户提问时先用同一个 embedding 模型把问题向量化再通过 pgvector 的 HNSW 索引做 top5 召回用余弦距离排序。检索结果拼进 prompt 前会判断相似度阈值低于阈值直接走兜底。”第三段说踩了什么坑。比如“我们之前有一条不稳定的问题查到最后发现是 embedding 输入被模型截断后来改成按 token 窗口切片再检查入库长度解决掉了。”这比单纯背“Embedding 是什么”要强得多因为面试官想要知道的是你能不能把一个 AI 功能真正落地在复杂的 Java 系统里。7. 最后分享我自己的一个习惯我每次接新的 RAG 项目都会花一点时间验证“分块大小对 Embedding 检索的影响”。不要照搬别人的 chunk_size不同模型、不同文档风格最优窗口不一样。我的做法是拿同一批文档分别按 256、512、1024 token 切片各跑一轮检索对比测试集上的召回率和相似度分数最后选相对稳定的那个。这个验证看起来麻烦但它能提前挡住很多线上问题。文本 Embedding 和 LLM 输出向量之间的边界也正是在这种反复调试里形成肌肉记忆的。面试题只是一道线把它答好就过关真正跨过去之后你会发现工程里值得抠的细节永远比题面更多。