【AI】从文本到向量:理解Embedding的作用

👨‍💻程序员三明治个人主页
🔥 个人专栏 : 《设计模式精解》 《重学数据结构》
《AI探索日志》 《从0带你学深度强化学习》

🤞先做到 再看见!


目录

    • 关键词检索的困境:为什么文本匹配不够用
      • [1. 场景:在线教育课程预约知识库的检索难题](#1. 场景:在线教育课程预约知识库的检索难题)
      • [2. 关键词匹配的三个典型问题](#2. 关键词匹配的三个典型问题)
        • [2.1 同义表达问题](#2.1 同义表达问题)
        • [2.2 一词多义问题](#2.2 一词多义问题)
        • [2.3 上下文理解问题](#2.3 上下文理解问题)
      • [3. RAG 需要的是语义检索,而不是字面匹配](#3. RAG 需要的是语义检索,而不是字面匹配)
    • 向量:让计算机比较语义的一种方式
      • [1. 什么是向量:用坐标表示含义](#1. 什么是向量:用坐标表示含义)
      • [2. 从二维到高维:真实文本向量是什么样](#2. 从二维到高维:真实文本向量是什么样)
      • [3. Embedding 的核心思想](#3. Embedding 的核心思想)
    • [Embedding 模型:文本到向量的转换器](#Embedding 模型:文本到向量的转换器)
      • [1. Embedding 模型做了什么](#1. Embedding 模型做了什么)
      • [2. Embedding 模型选型时看什么](#2. Embedding 模型选型时看什么)
      • [3. 常见 Embedding 模型对比](#3. 常见 Embedding 模型对比)
      • [4. 中文课程知识库如何选型](#4. 中文课程知识库如何选型)
      • [5. 向量维度怎么选](#5. 向量维度怎么选)
    • 相似度计算:如何判断两个向量是否相近
      • [1. 余弦相似度](#1. 余弦相似度)
      • [2. 余弦相似度的计算逻辑](#2. 余弦相似度的计算逻辑)
      • [3. Java 示例:手动计算余弦相似度](#3. Java 示例:手动计算余弦相似度)
      • [4. 相似度分数怎么解读](#4. 相似度分数怎么解读)
      • [5. 检索阈值怎么设](#5. 检索阈值怎么设)
      • [6. 其他相似度度量方式](#6. 其他相似度度量方式)
    • [动手实践:用通用 Embedding API 跑通向量化流程](#动手实践:用通用 Embedding API 跑通向量化流程)
      • [1. 请求格式](#1. 请求格式)
      • [2. 响应格式](#2. 响应格式)
      • [3. Maven 依赖](#3. Maven 依赖)
      • [4. EmbeddingClient 工具类](#4. EmbeddingClient 工具类)
      • [5. 相似度工具类](#5. 相似度工具类)
      • [6. 完整示例:从 chunk 到向量检索](#6. 完整示例:从 chunk 到向量检索)
      • [7. 运行结果分析](#7. 运行结果分析)
    • 实际项目中的关键决策
      • [1. 云端 API vs 本地部署](#1. 云端 API vs 本地部署)
      • [2. 什么时候选择云端 API](#2. 什么时候选择云端 API)
      • [3. 什么时候选择本地部署](#3. 什么时候选择本地部署)
      • [4. 抽象 EmbeddingClient,避免绑定具体平台](#4. 抽象 EmbeddingClient,避免绑定具体平台)
    • 批量向量化的性能优化
      • [1. 分批处理](#1. 分批处理)
      • [2. 并发控制](#2. 并发控制)
      • [3. 错误重试](#3. 错误重试)
    • 向量化和元数据的关系
    • 向量数据库中通常存什么
    • 什么时候需要重新向量化
      • [1. 更换 Embedding 模型](#1. 更换 Embedding 模型)
      • [2. 文档内容更新](#2. 文档内容更新)
      • [3. 分块策略调整](#3. 分块策略调整)
      • [4. 模型版本升级](#4. 模型版本升级)
    • 小结

上一篇文章讨论了元数据管理:如何给每个 chunk 补充来源、权限、课程类型、章节位置等信息,让它不再只是一段孤立文本,而是一段可以被追踪、过滤和管理的知识片段。

到这一步,知识库里的每个 chunk 已经有了清晰的上下文信息。但还有一个核心问题没有解决:这些内容仍然是自然语言,计算机并不能真正理解它们的含义。

比如系统需要比较下面两句话:

  • "体验课开课前 24 小时可免费取消预约"
  • "明天的试听课临时去不了怎么办?"

从字面上看,这两句话重合的词并不多。但任何一个人都能看出来,它们都在讨论"课程预约取消或改期"这件事。

问题是:怎样让计算机也能理解这种语义上的相似性?

答案是把文本转换成一组数字,也就是向量。这个转换过程,就叫向量化,也叫 Embedding。

关键词检索的困境:为什么文本匹配不够用

1. 场景:在线教育课程预约知识库的检索难题

假设一个在线教育平台维护了一套课程预约知识库,其中有这样一条规则:

体验课开课前 24 小时内如需取消或改期,学员需在预约中心提交申请,系统将根据教师档期重新安排上课时间。

现在用户问:

明天的试听课临时去不了怎么办?

如果使用传统关键词检索,例如 Elasticsearch 的全文搜索,系统通常会把用户问题拆成若干关键词:

plain 复制代码
明天 / 试听课 / 临时 / 去不了

然后再去知识库中查找包含这些关键词的文本块。

问题在于,知识库里的标准表达是:

plain 复制代码
体验课 / 开课前 24 小时 / 取消 / 改期 / 教师档期

用户说的是"试听课""去不了",知识库写的是"体验课""取消或改期"。两边语义接近,但字面重合度不高。关键词检索很可能找不到正确规则,或者把它排在较靠后的位置。

但从业务含义上看,这两句话明显讨论的是同一类问题。

2. 关键词匹配的三个典型问题

这个例子暴露的是关键词检索的共性问题。主要可以归纳为三类。

2.1 同义表达问题

用户口语化表达和知识库标准表达往往不同。

用户的说法 知识库的写法 关键词能否稳定匹配
今天课上不了 课程取消与改期流程 不稳定,"上不了"不等于"取消/改期"
想换个老师 教师调整申请规则 不稳定,"换老师"不等于"教师调整"
孩子错过直播怎么办 课程回放观看说明 不稳定,"错过直播"不等于"回放"
还能补课吗 缺勤补课处理规则 不稳定,"补课"可能能匹配,但语义仍需判断

这些表达在用户咨询中非常常见,但纯关键词检索只能看字面,无法理解"上不了课"和"取消预约"之间的语义关系。

2.2 一词多义问题

同一个词在不同语境里可能代表完全不同的含义。

比如用户问:

这个班还能加吗?

这里的"班"可能指:

  • 一个直播班级是否还能报名
  • 某个课程班型是否还有名额
  • 已报名学员是否能加入班级群
  • 老师排课表里是否能插入新的课时

关键词检索只会返回包含"班"的文本,但无法判断用户真正想问的是"报名名额""班级群"还是"排课容量"。

再比如"余额"这个词,在在线教育场景里可能指账户余额、课时余额、优惠券余额,也可能指机构内部的结算余额。关键词检索很难仅凭字面完成语义区分。

2.3 上下文理解问题

有些问题需要理解整句话,而不是拆成几个词。

比如用户问:

孩子今天发烧了,老师那边还能帮忙调整一下吗?

关键词检索可能只看到"孩子""今天""老师""调整",然后匹配到教师介绍、课程安排、班主任服务等内容。

但这句话真正想表达的是:

学员因临时原因无法上课,希望取消或改期。

这类问题如果只靠关键词,系统很容易召回不相关内容。

3. RAG 需要的是语义检索,而不是字面匹配

关键词检索的问题,本质上是它只看文本表面,不理解语义。

一个可用的课程预约问答系统,需要具备语义检索能力:

  • "试听课"和"体验课"意思接近,应该能匹配
  • "去不了"和"取消/改期"语义相关,应该能匹配
  • "课时余额"和"账户余额"虽然都包含"余额",但不能混在一起
  • "孩子发烧了"虽然没出现"取消课程",但应能关联到课程调整规则

要实现这种能力,就需要先把文本转换成计算机可以比较语义的形式,也就是向量。

向量:让计算机比较语义的一种方式

向量听起来像一个数学概念,但在语义检索里,可以先把它理解成"文本在语义空间中的坐标"。

1. 什么是向量:用坐标表示含义

假设用一个二维坐标系来表示在线教育领域中的一些词语:

  • 横轴表示"课程预约相关程度"
  • 纵轴表示"教学服务相关程度"
plain 复制代码
教学服务相关 ↑
         |
    1.0  |          ● 补课申请(0.35, 0.90)
         |        ● 课程改期(0.30, 0.85)
    0.8  |
         |
    0.6  |                    ● 教师调整(0.55, 0.65)
         |
    0.4  |
         |  ● 课件下载(0.65, 0.25)
    0.2  |
         |          ● 新课报名(0.85, 0.15)
    0.0  +-----|-----|-----|-----|---→ 课程预约相关
         0    0.2   0.4   0.6   0.8   1.0

在这个坐标系里:

  • "课程改期"和"补课申请"距离较近,因为它们都和上课安排变化有关
  • "课程改期"和"新课报名"距离较远,因为它们属于不同业务动作
  • "教师调整"处在中间位置,既涉及教学服务,也可能影响预约安排

每个词或句子在坐标系中的位置,就是它的向量。向量本质上就是一组数字,用来表示文本的语义特征。

语义越接近,向量位置越接近;语义越不同,向量位置越远。

2. 从二维到高维:真实文本向量是什么样

上面的二维例子只是为了方便理解。真实语言的含义远比两个维度复杂,实际的 Embedding 模型通常会用几百到几千个维度来表示一段文本。

例如,把下面这句话送入 Embedding 模型:

plain 复制代码
体验课开课前 24 小时可免费取消预约

模型可能输出类似这样的向量:

plain 复制代码
[0.0234, -0.0156, 0.0891, -0.0423, 0.0567, -0.0312, 0.0178, -0.0645,
 0.0923, -0.0089, 0.0456, -0.0234, 0.0712, -0.0567, 0.0345, -0.0198,
 ... 省略若干维度 ...
 0.0123, -0.0456, 0.0789, -0.0234]

如果模型输出 1024 维,那么这段文本就会被表示成 1024 个浮点数。

不需要理解每一个数字具体代表什么。只需要知道:这组数字整体编码了这段文本的语义信息。两段语义相近的文本,它们对应的向量也会更接近。

3. Embedding 的核心思想

Embedding 的核心可以概括为一句话:

把文本映射到一个高维向量空间中,让语义相近的文本在空间中距离更近。

回到前面的例子:

  • "体验课开课前 24 小时可免费取消预约"
  • "明天的试听课临时去不了怎么办?"

这两句话字面不同,但语义接近,所以它们在向量空间中的距离应该比较近。

而下面这句话:

plain 复制代码
课程回放将在直播结束后自动生成

虽然同样属于在线教育业务,但主题是回放,不是取消预约,因此它和"试听课临时去不了"的距离应该更远。

有了向量表示,系统就不再只依赖关键词,而是可以比较语义距离。这正是 RAG 检索阶段的重要基础。

Embedding 模型:文本到向量的转换器

知道了向量是什么,接下来需要解决另一个问题:谁来把文本变成向量?

答案就是 Embedding 模型。

1. Embedding 模型做了什么

Embedding 模型的工作非常明确:

plain 复制代码
输入:一段文本
输出:一组浮点数向量

可以把它理解成一种"语义翻译器"。普通翻译器把中文翻成英文,而 Embedding 模型把自然语言翻译成计算机可以比较的数字表示。

它有几个关键特性。

第一,输入长度有限制。每个模型都有最大输入 token 数,超过限制的文本会被截断或无法处理。这也是 RAG 系统需要分块的原因之一:长文档必须先切成较小的 chunk,再送入模型。

第二,输出维度固定。同一个模型输出的向量维度是固定的。例如某个模型输出 1024 维,那么无论输入是一句话还是一段话,输出都是 1024 个浮点数。

第三,同一模型生成的向量才能直接比较。模型 A 和模型 B 的向量空间通常不兼容。即使两个模型都输出 1024 维,也不能假设它们可以混合计算相似度。

这一点非常重要:

数据入库阶段用什么 Embedding 模型处理 chunk,查询阶段就必须用同一个模型处理用户 query。

换模型通常意味着所有已入库向量都要重新生成。

2. Embedding 模型选型时看什么

实际项目中,Embedding 模型很多,不能只看模型名字。通常需要关注以下指标。

指标 含义 为什么重要
向量维度 输出向量包含多少个浮点数 维度越高,表达能力通常越强,但存储和检索成本也越高
最大输入 token 数 单次可处理的最大文本长度 决定 chunk 最大长度和截断风险
中文效果 对中文语义、口语表达、业务术语的理解能力 中文知识库必须重点关注
调用成本 API 或本地推理成本 大规模向量化时成本差异明显
部署方式 云端 API 或本地部署 影响数据安全、延迟、运维成本
生态兼容性 是否兼容常见向量库和 OpenAI 风格接口 影响工程接入成本

3. 常见 Embedding 模型对比

下面是一些常见模型的选型维度示例。实际项目中还需要结合最新版本、服务稳定性和业务评测结果来决定。

模型 提供方 常见向量维度 常见输入长度 中文效果 部署方式 适用说明
text-embedding-3-small OpenAI 1536 8191 左右 中等 云端 API 成本较低,适合英文或多语言通用场景
text-embedding-3-large OpenAI 3072 8191 左右 中等 云端 API 精度更高,成本也更高
text-embedding-v3 通义系列 1024/768 等 8192 左右 较好 云端 API 中文知识库常见选择
BGE-large-zh BAAI 1024 512 左右 较好 本地/API 中文效果稳定,适合较短文本
BGE-M3 BAAI 1024 8192 左右 较好 本地/API 多语言、多粒度场景常用
Qwen3-Embedding 系列 通义系列 取决于具体版本 较长上下文 较好 本地/API 适合中文和长文本场景
GTE-large-zh 通义系列 1024 8192 左右 较好 本地/API 中文检索任务常见选择

4. 中文课程知识库如何选型

如果系统主要处理中文文本,例如课程预约规则、教师排课说明、课时消耗规则、学员服务手册,可以按下面思路选型。

如果项目处在验证阶段,数据量不大,可以优先选择云端 API。这样可以快速跑通效果,不需要准备 GPU 环境。

如果项目已经进入生产阶段,并且涉及用户咨询记录、学员信息、内部排课规则等敏感数据,则需要优先考虑本地部署或私有化部署。

如果内容以中文为主,建议优先选择中文语义效果较好的模型,并用自己的业务数据做评测。不要只看通用榜单,因为课程预约场景里存在大量业务表达,例如"约课""排课""改期""课消""冻结课时"等,这些词在通用语料中的含义未必和业务语义完全一致。

5. 向量维度怎么选

向量维度可以理解成"描述文本语义时使用了多少个特征"。

维度越高,理论上可以表达更丰富的信息,但存储成本、内存占用和检索计算成本也会增加。

维度范围 适用场景 100 万条向量的粗略存储成本
256~512 文本较短、业务分类较少、精度要求一般 约 1~2 GB
768~1024 大多数中文 RAG 生产场景 约 3~4 GB
1536~4096 对召回精度要求较高、语义差异细微的场景 约 6~16 GB

对于大多数在线教育知识库,768 到 1024 维通常是比较稳妥的选择。它能提供较好的语义区分能力,同时存储和检索成本也相对可控。

如果场景对准确率要求极高,例如合同条款检索、医学教育内容审核、强监管考试题库检索,才更有必要考虑更高维度的模型,并配合重排序模型进一步提升结果质量。

相似度计算:如何判断两个向量是否相近

文本变成向量之后,就可以比较它们之间的相似程度。

在 RAG 检索中,典型流程是:

plain 复制代码
用户问题 → 转成 query 向量 → 与知识库 chunk 向量比较 → 找出最相似的 Top-K chunk

这个"比较"过程,就需要相似度计算。

1. 余弦相似度

Embedding 检索中最常用的度量方式之一是余弦相似度。

可以把每个向量想象成从原点出发的一支箭头。余弦相似度关注的是两支箭头的方向是否接近。

plain 复制代码
方向接近:语义相似度高
方向差异大:语义相似度低
方向相反:语义可能相反或差异很大

理论上,余弦相似度的取值范围是 [-1, 1]

  • 接近 1:方向高度一致,语义高度相似
  • 接近 0:方向接近垂直,语义相关性弱
  • 接近 -1:方向相反,语义差异极大

在实际 Embedding 检索中,很多模型和向量库会对向量做归一化处理,因此常见分数更多集中在 0 到 1 之间。

2. 余弦相似度的计算逻辑

余弦相似度的计算可以拆成三步:

  1. 计算两个向量的点积
  2. 分别计算两个向量的模
  3. 用点积除以两个模的乘积

公式不需要死记,但理解这个过程有助于后面读代码。

3. Java 示例:手动计算余弦相似度

下面保留 Java 语言,但将方法名和变量名改成 snake_case 风格。

java 复制代码
public class CosineSimilarity {

    /**
     * 计算两个向量的余弦相似度
     *
     * @param vector_a 向量 A
     * @param vector_b 向量 B
     * @return 余弦相似度,理论范围为 [-1.0, 1.0]
     */
    public static double calculate_similarity(double[] vector_a, double[] vector_b) {
        if (vector_a.length != vector_b.length) {
            throw new IllegalArgumentException(
                    "两个向量的维度必须相同,vector_a: "
                            + vector_a.length + ", vector_b: " + vector_b.length
            );
        }

        double dot_product = 0.0;
        double norm_a = 0.0;
        double norm_b = 0.0;

        for (int i = 0; i < vector_a.length; i++) {
            dot_product += vector_a[i] * vector_b[i];
            norm_a += vector_a[i] * vector_a[i];
            norm_b += vector_b[i] * vector_b[i];
        }

        norm_a = Math.sqrt(norm_a);
        norm_b = Math.sqrt(norm_b);

        if (norm_a == 0 || norm_b == 0) {
            return 0.0;
        }

        return dot_product / (norm_a * norm_b);
    }

    public static void main(String[] args) {
        // 模拟三个文本的向量。真实项目中维度通常是 768、1024 或更高。
        double[] cancel_rule = {0.8, 0.1, 0.9, 0.2, 0.7};
        double[] cancel_query = {0.75, 0.15, 0.85, 0.25, 0.65};
        double[] material_rule = {0.1, 0.9, 0.2, 0.8, 0.1};

        double sim_1 = calculate_similarity(cancel_rule, cancel_query);
        double sim_2 = calculate_similarity(cancel_rule, material_rule);

        System.out.println("「体验课可取消预约」vs「明天试听课去不了」:"
                + String.format("%.4f", sim_1));
        System.out.println("「体验课可取消预约」vs「课件下载说明」:"
                + String.format("%.4f", sim_2));
    }
}

可能输出:

plain 复制代码
「体验课可取消预约」vs「明天试听课去不了」:0.9972
「体验课可取消预约」vs「课件下载说明」:0.5765

这说明语义接近的两段文本相似度更高,不相关文本的相似度更低。

这里的向量只是为了演示手写的模拟数据。真实系统中,向量由 Embedding 模型生成,但相似度计算逻辑是一致的。

4. 相似度分数怎么解读

相似度分数没有绝对统一的标准,不同模型、不同数据集、不同向量库的分布都可能不同。下面只是经验参考。

相似度范围 可能含义 在线教育场景示例
0.9~1.0 高度相关,几乎同义 "取消体验课"和"试听课去不了"
0.7~0.9 明显相关,主题一致 "课程改期"和"临时换上课时间"
0.5~0.7 有一定关联,但需要进一步判断 "补课规则"和"课程服务说明"
0.3~0.5 关联较弱 "课程改期"和"课程详情页展示"
0.0~0.3 基本无关 "课程预约"和"系统登录验证码"

5. 检索阈值怎么设

在 RAG 系统中,通常有两种常见做法:

第一种是设置阈值,只返回相似度高于某个分数的结果。例如只返回大于 0.6 的 chunk。

第二种是不设固定阈值,只取 Top-K。例如每次返回最相似的前 5 条。

生产中更常见的是组合策略:

plain 复制代码
先取 Top-K,例如 Top-5
再过滤低于阈值的结果,例如过滤掉低于 0.6 的 chunk

这样既能保证召回,又能减少明显不相关内容进入后续 LLM 生成环节。

不过阈值不能照搬。换了模型、换了数据、换了 chunk 策略,分数分布都可能变化。因此阈值需要通过业务样本评测来调。

6. 其他相似度度量方式

除了余弦相似度,还有两种常见方式。

度量方式 核心思想 与余弦相似度的区别 常见使用场景
欧氏距离 计算两个向量之间的直线距离 值越小越相似,受向量长度影响 向量已归一化时可用
点积 对应维度相乘后求和 值越大越相似,同时受方向和长度影响 向量已归一化时常与余弦相似度等价

如果没有明确经验,优先使用余弦相似度通常更稳妥,因为它对向量长度不敏感,适用范围较广。

动手实践:用通用 Embedding API 跑通向量化流程

下面用一个通用的 OpenAI-compatible Embedding API 作为示例。它可以是云端模型服务,也可以是企业内部封装的模型网关。

为了降低业务耦合,代码中不写死具体平台,而是通过环境变量配置:

plain 复制代码
EMBEDDING_API_URL
EMBEDDING_API_KEY
EMBEDDING_MODEL

这样以后从云端 API 切换到本地部署,只需要改配置,不需要重写业务代码。

1. 请求格式

示例请求:

bash 复制代码
curl -X POST "$EMBEDDING_API_URL" \
  -H "Authorization: Bearer $EMBEDDING_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "BAAI/bge-m3",
    "input": ["体验课开课前 24 小时可免费取消预约"],
    "encoding_format": "float"
  }'

关键字段说明:

字段 含义
model 使用哪个 Embedding 模型
input 待向量化文本,可以是字符串,也可以是字符串数组
encoding_format 返回向量格式,float 表示浮点数组

2. 响应格式

响应通常类似:

json 复制代码
{
  "object": "list",
  "data": [
    {
      "object": "embedding",
      "index": 0,
      "embedding": [0.0123, -0.0456, 0.0789]
    }
  ],
  "model": "BAAI/bge-m3",
  "usage": {
    "prompt_tokens": 8,
    "total_tokens": 8
  }
}

data 数组中的每个元素对应 input 中的一段文本。真正需要保存的是 embedding 字段,它是一组浮点数。

3. Maven 依赖

Java 11 以后自带 HttpClient,这里只需要额外引入 Jackson 解析 JSON。

xml 复制代码
<dependencies>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.17.0</version>
    </dependency>
</dependencies>

4. EmbeddingClient 工具类

java 复制代码
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class EmbeddingClient {

    private final String api_url;
    private final String api_key;
    private final String model_name;
    private final HttpClient http_client;
    private final ObjectMapper object_mapper;

    public EmbeddingClient(String api_url, String api_key, String model_name) {
        this.api_url = api_url;
        this.api_key = api_key;
        this.model_name = model_name;
        this.http_client = HttpClient.newHttpClient();
        this.object_mapper = new ObjectMapper();
    }

    /**
     * 将多段文本批量转成向量
     *
     * @param texts 待向量化文本列表
     * @return 每段文本对应的向量
     */
    public List<double[]> embed_texts(List<String> texts) throws Exception {
        Map<String, Object> request_body = new HashMap<>();
        request_body.put("model", model_name);
        request_body.put("input", texts);
        request_body.put("encoding_format", "float");

        String json_body = object_mapper.writeValueAsString(request_body);

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(api_url))
                .header("Authorization", "Bearer " + api_key)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(json_body))
                .build();

        HttpResponse<String> response = http_client.send(
                request,
                HttpResponse.BodyHandlers.ofString()
        );

        if (response.statusCode() != 200) {
            throw new RuntimeException(
                    "Embedding API 调用失败,状态码:" + response.statusCode()
                            + ",响应:" + response.body()
            );
        }

        JsonNode root_node = object_mapper.readTree(response.body());
        JsonNode data_array = root_node.get("data");

        List<double[]> embeddings = new ArrayList<>();

        for (JsonNode item_node : data_array) {
            JsonNode embedding_node = item_node.get("embedding");

            double[] vector = new double[embedding_node.size()];
            for (int i = 0; i < embedding_node.size(); i++) {
                vector[i] = embedding_node.get(i).asDouble();
            }

            embeddings.add(vector);
        }

        return embeddings;
    }

    /**
     * 将单段文本转成向量
     */
    public double[] embed_text(String text) throws Exception {
        return embed_texts(List.of(text)).get(0);
    }
}

这段代码的核心流程是:

plain 复制代码
构造请求体 → 发送 HTTP POST → 解析 JSON → 提取 embedding 数组

5. 相似度工具类

java 复制代码
public class CosineSimilarity {

    public static double calculate_similarity(double[] vector_a, double[] vector_b) {
        if (vector_a.length != vector_b.length) {
            throw new IllegalArgumentException("向量维度不一致");
        }

        double dot_product = 0.0;
        double norm_a = 0.0;
        double norm_b = 0.0;

        for (int i = 0; i < vector_a.length; i++) {
            dot_product += vector_a[i] * vector_b[i];
            norm_a += vector_a[i] * vector_a[i];
            norm_b += vector_b[i] * vector_b[i];
        }

        norm_a = Math.sqrt(norm_a);
        norm_b = Math.sqrt(norm_b);

        if (norm_a == 0 || norm_b == 0) {
            return 0.0;
        }

        return dot_product / (norm_a * norm_b);
    }
}

6. 完整示例:从 chunk 到向量检索

下面构造一个课程预约知识库示例。先对所有 chunk 做向量化,再对用户问题做向量化,最后计算相似度并排序。

java 复制代码
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class EmbeddingSearchDemo {

    public static void main(String[] args) throws Exception {
        String api_url = System.getenv("EMBEDDING_API_URL");
        String api_key = System.getenv("EMBEDDING_API_KEY");
        String model_name = System.getenv("EMBEDDING_MODEL");

        EmbeddingClient client = new EmbeddingClient(api_url, api_key, model_name);

        List<Map<String, Object>> chunks = new ArrayList<>();

        chunks.add(Map.of(
                "content", "体验课开课前 24 小时内如需取消或改期,学员需在预约中心提交申请,系统将根据教师档期重新安排上课时间。",
                "metadata", Map.of("doc_id", "course_policy_001", "title", "体验课预约规则")
        ));

        chunks.add(Map.of(
                "content", "正式课临时请假后,系统会根据班型规则判断是否消耗课时,具体以课程服务协议为准。",
                "metadata", Map.of("doc_id", "course_policy_002", "title", "请假与课时规则")
        ));

        chunks.add(Map.of(
                "content", "直播课结束后,课程回放通常会在 2 小时内生成,学员可在学习中心查看。",
                "metadata", Map.of("doc_id", "learning_guide_001", "title", "课程回放说明")
        ));

        chunks.add(Map.of(
                "content", "优惠券可在报名结算时抵扣部分课程费用,单笔订单仅支持使用一张优惠券。",
                "metadata", Map.of("doc_id", "payment_guide_001", "title", "优惠券使用说明")
        ));

        chunks.add(Map.of(
                "content", "一对一课程支持根据教师可用时间进行改期,但距离开课不足 2 小时的预约可能无法调整。",
                "metadata", Map.of("doc_id", "course_policy_003", "title", "一对一课程改期规则")
        ));

        List<String> chunk_texts = new ArrayList<>();
        for (Map<String, Object> chunk : chunks) {
            chunk_texts.add((String) chunk.get("content"));
        }

        System.out.println("正在向量化 " + chunk_texts.size() + " 个 chunks...");
        List<double[]> chunk_vectors = client.embed_texts(chunk_texts);
        System.out.println("向量化完成,向量维度:" + chunk_vectors.get(0).length);

        String query_text = "明天的试听课临时去不了怎么办?";
        System.out.println("\n用户提问:" + query_text);

        double[] query_vector = client.embed_text(query_text);

        List<Map<String, Object>> results = new ArrayList<>();

        for (int i = 0; i < chunks.size(); i++) {
            double similarity = CosineSimilarity.calculate_similarity(
                    query_vector,
                    chunk_vectors.get(i)
            );

            Map<String, Object> result = new HashMap<>();
            result.put("index", i);
            result.put("content", chunks.get(i).get("content"));
            result.put("metadata", chunks.get(i).get("metadata"));
            result.put("similarity", similarity);

            results.add(result);
        }

        results.sort((a, b) -> Double.compare(
                (double) b.get("similarity"),
                (double) a.get("similarity")
        ));

        System.out.println("\n--- 相似度排名 ---");

        for (int i = 0; i < results.size(); i++) {
            Map<String, Object> result = results.get(i);
            Map<String, Object> metadata = (Map<String, Object>) result.get("metadata");

            System.out.printf(
                    "Top-%d [相似度: %.4f] [来源: %s]%n",
                    i + 1,
                    (double) result.get("similarity"),
                    metadata.get("title")
            );

            System.out.println("  内容: " + result.get("content"));
            System.out.println();
        }
    }
}

7. 运行结果分析

可能输出类似:

plain 复制代码
正在向量化 5 个 chunks...
向量化完成,向量维度:1024

用户提问:明天的试听课临时去不了怎么办?

--- 相似度排名 ---
Top-1 [相似度: 0.7921] [来源: 体验课预约规则]
  内容: 体验课开课前 24 小时内如需取消或改期,学员需在预约中心提交申请,系统将根据教师档期重新安排上课时间。

Top-2 [相似度: 0.7384] [来源: 一对一课程改期规则]
  内容: 一对一课程支持根据教师可用时间进行改期,但距离开课不足 2 小时的预约可能无法调整。

Top-3 [相似度: 0.6815] [来源: 请假与课时规则]
  内容: 正式课临时请假后,系统会根据班型规则判断是否消耗课时,具体以课程服务协议为准。

Top-4 [相似度: 0.4297] [来源: 课程回放说明]
  内容: 直播课结束后,课程回放通常会在 2 小时内生成,学员可在学习中心查看。

Top-5 [相似度: 0.3152] [来源: 优惠券使用说明]
  内容: 优惠券可在报名结算时抵扣部分课程费用,单笔订单仅支持使用一张优惠券。

这个排序符合直觉:

  • 用户问"试听课临时去不了",最相关的是体验课取消或改期规则
  • 一对一课程改期规则也相关,但业务范围更窄,所以排在第二
  • 请假与课时规则和"无法上课"有关,但不一定直接回答体验课问题
  • 回放说明、优惠券说明和问题关系较弱,因此排名靠后

需要注意的是:语义相似不等于最终答案正确。

例如"一对一课程改期规则"可能和用户问题相似,但如果用户问的是"体验课",最终回答仍应优先引用体验课规则。RAG 系统通常会把这些候选 chunk 交给 LLM,再结合上下文生成最终答案。

实际项目中的关键决策

跑通 demo 只是第一步。生产环境中,还需要考虑模型部署方式、批量处理、错误重试、元数据过滤和重新向量化等问题。

1. 云端 API vs 本地部署

对比维度 云端 API 本地部署
部署成本 接入快,基本不需要维护模型服务 需要 GPU 或高性能推理环境
使用成本 按 token 或调用量计费 硬件折旧、电费和运维成本
延迟 受网络和平台稳定性影响 内网调用,延迟更可控
数据安全 文本需要发送到外部服务 数据不出内网,安全性更高
模型维护 平台负责升级和运维 需要团队自行维护
切换灵活性 平台内模型切换方便 需要下载、部署和测试模型

2. 什么时候选择云端 API

适合以下情况:

  • 项目处于早期验证阶段
  • 知识库数据量不大
  • 团队暂时没有 GPU 或模型运维能力
  • 数据安全要求不高,例如内容主要是公开课程介绍

云端 API 的优势是快,适合快速验证"语义检索是否有效"。

3. 什么时候选择本地部署

适合以下情况:

  • 每天需要处理大量课程文档、咨询记录或学习资料
  • 数据包含学员信息、内部排课规则、教师资料等敏感内容
  • 对延迟要求较高
  • 需要长期控制调用成本
  • 企业有模型部署和运维能力

本地部署前期成本更高,但在数据安全和长期成本方面通常更可控。

4. 抽象 EmbeddingClient,避免绑定具体平台

无论使用云端 API 还是本地服务,都建议把向量化能力封装在一个独立的客户端中。

业务代码只依赖:

plain 复制代码
输入文本 → 返回向量

不要让业务层感知具体平台、请求格式和鉴权方式。这样后续从云端切换到本地,或者从模型 A 切换到模型 B,改动范围会更小。

批量向量化的性能优化

demo 中只有 5 个 chunk,可以一次性发送给 API。但真实课程知识库可能有几万、几十万甚至更多 chunk。

这时不能简单地一次性提交全部文本,也不能一个一个串行提交。需要考虑分批、并发和重试。

1. 分批处理

最基本的优化方式是把 chunk 按固定大小分批处理。

java 复制代码
import java.util.ArrayList;
import java.util.List;

public class BatchEmbeddingService {

    private final EmbeddingClient embedding_client;

    public BatchEmbeddingService(EmbeddingClient embedding_client) {
        this.embedding_client = embedding_client;
    }

    /**
     * 分批向量化
     *
     * @param texts 所有待向量化文本
     * @param batch_size 每批大小,建议从 20~50 开始压测
     * @return 所有文本对应的向量
     */
    public List<double[]> embed_in_batches(List<String> texts, int batch_size) throws Exception {
        List<double[]> all_embeddings = new ArrayList<>();

        for (int i = 0; i < texts.size(); i += batch_size) {
            int end_index = Math.min(i + batch_size, texts.size());
            List<String> batch_texts = texts.subList(i, end_index);

            System.out.printf("向量化进度:%d/%d%n", end_index, texts.size());

            List<double[]> batch_embeddings = embedding_client.embed_texts(batch_texts);
            all_embeddings.addAll(batch_embeddings);

            if (end_index < texts.size()) {
                Thread.sleep(200);
            }
        }

        return all_embeddings;
    }
}

分批大小需要结合模型服务限制来调。批次太小,请求次数多;批次太大,可能触发请求体大小限制或超时。

2. 并发控制

如果 API 或本地服务支持并发,可以使用线程池提高吞吐量。

java 复制代码
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class ConcurrentEmbeddingService {

    private final EmbeddingClient embedding_client;

    public ConcurrentEmbeddingService(EmbeddingClient embedding_client) {
        this.embedding_client = embedding_client;
    }

    /**
     * 并发批量向量化
     *
     * @param texts 所有待向量化文本
     * @param batch_size 每批大小
     * @param max_concurrency 最大并发数
     */
    public List<double[]> embed_concurrently(
            List<String> texts,
            int batch_size,
            int max_concurrency
    ) throws Exception {
        ExecutorService executor_service = Executors.newFixedThreadPool(max_concurrency);
        List<Future<List<double[]>>> futures = new ArrayList<>();

        for (int i = 0; i < texts.size(); i += batch_size) {
            int start_index = i;
            int end_index = Math.min(i + batch_size, texts.size());
            List<String> batch_texts = texts.subList(start_index, end_index);

            futures.add(executor_service.submit(() -> embedding_client.embed_texts(batch_texts)));
        }

        List<double[]> all_embeddings = new ArrayList<>();

        for (Future<List<double[]>> future : futures) {
            all_embeddings.addAll(future.get());
        }

        executor_service.shutdown();
        return all_embeddings;
    }
}

并发数不要盲目设置过高。对于外部 API,过高并发容易触发 Rate Limit;对于本地模型服务,过高并发可能导致显存不足或排队延迟升高。

生产环境中建议通过压测确定合理参数,例如:

plain 复制代码
batch_size = 32
max_concurrency = 3

然后逐步调整。

3. 错误重试

向量化过程涉及网络请求或模型推理,失败是正常情况。生产系统需要重试机制。

java 复制代码
import java.util.List;

public class RetryableEmbeddingService {

    private final EmbeddingClient embedding_client;

    public RetryableEmbeddingService(EmbeddingClient embedding_client) {
        this.embedding_client = embedding_client;
    }

    /**
     * 带重试的向量化方法
     */
    public List<double[]> embed_with_retry(
            List<String> texts,
            int max_retries
    ) throws Exception {
        Exception last_exception = null;

        for (int attempt = 1; attempt <= max_retries; attempt++) {
            try {
                return embedding_client.embed_texts(texts);
            } catch (Exception exception) {
                last_exception = exception;

                System.err.printf(
                        "第 %d 次向量化失败:%s,%s%n",
                        attempt,
                        exception.getMessage(),
                        attempt < max_retries ? "准备重试..." : "已达到最大重试次数"
                );

                if (attempt < max_retries) {
                    long sleep_millis = 1000L * (1L << (attempt - 1));
                    Thread.sleep(sleep_millis);
                }
            }
        }

        throw new RuntimeException(
                "向量化失败,已重试 " + max_retries + " 次",
                last_exception
        );
    }
}

这里使用的是指数退避:

plain 复制代码
第 1 次失败后等待 1 秒
第 2 次失败后等待 2 秒
第 3 次失败后等待 4 秒

这样可以避免在服务短暂抖动时持续打满接口。

向量化和元数据的关系

这个问题在 RAG 系统中很容易被混淆。

一句话概括:

默认情况下,元数据不参与向量化,但会和向量一起存储。

例如课程知识库里的一个 chunk 可能长这样:

json 复制代码
{
  "id": "chunk_001",
  "content": "体验课开课前 24 小时内如需取消或改期,学员需在预约中心提交申请。",
  "metadata": {
    "doc_id": "course_policy_001",
    "title": "体验课预约规则",
    "course_type": "trial_course",
    "permission": "student_visible",
    "updated_at": "2026-06-01"
  }
}

通常只把 content 送入 Embedding 模型生成向量,而 metadata 用于后续过滤、展示和追踪。

检索流程可以理解为:

plain 复制代码
用户 query
   ↓
query 向量化
   ↓
向量数据库做相似度检索
   ↓
返回候选 chunks
   ↓
根据 metadata 做二次过滤
   ↓
返回最终结果

例如:

  • 只返回学员有权限查看的内容
  • 只返回某个课程类型下的规则
  • 只返回最近一年更新过的文档
  • 只返回某个校区或业务线适用的内容

需要注意的是,有些系统会把标题、分类等重要元数据拼接进文本一起向量化。例如:

plain 复制代码
标题:体验课预约规则
正文:体验课开课前 24 小时内如需取消或改期,学员需在预约中心提交申请。

这种做法可以增强语义信息,但它已经不再是"元数据不参与向量化"的默认做法,而是显式把部分元数据变成了向量化文本的一部分。

向量数据库中通常存什么

每个 chunk 入库时,通常会保存以下内容。

字段 内容 作用
id chunk 唯一标识 用于更新、删除和追踪
vector Embedding 模型输出的向量 用于相似度检索
text 原始 chunk 文本 用于返回给 LLM 或展示
metadata JSON 格式元数据 用于过滤、权限控制和溯源

示例:

json 复制代码
{
  "id": "chunk_001",
  "vector": [0.0123, -0.0456, 0.0789],
  "text": "体验课开课前 24 小时内如需取消或改期,学员需在预约中心提交申请。",
  "metadata": {
    "doc_id": "course_policy_001",
    "title": "体验课预约规则",
    "course_type": "trial_course",
    "permission": "student_visible",
    "embedding_model": "BAAI/bge-m3",
    "embedding_model_version": "v1"
  }
}

把原始文本也存入向量数据库,可以减少二次查询成本。否则系统检索到向量 id 后,还需要再去 MySQL、PostgreSQL 或对象存储中查原文。

什么时候需要重新向量化

向量化不是一次完成后永远不用管。以下情况都可能需要重新生成向量。

1. 更换 Embedding 模型

不同模型的向量空间通常不兼容。

如果从模型 A 切到模型 B,那么原来的 chunk 向量不能继续和新 query 向量混用。

plain 复制代码
换模型 = 所有 chunk 重新向量化 + 旧向量替换或新旧索引并行灰度

生产系统中可以考虑新旧索引并行:

plain 复制代码
旧索引继续服务线上流量
新索引后台构建
小流量切到新索引验证效果
验证通过后全量切换

2. 文档内容更新

如果课程规则发生变化,原始 chunk 的语义也变了,对应向量必须更新。

例如:

plain 复制代码
原规则:体验课开课前 24 小时可免费取消
新规则:体验课开课前 12 小时可免费取消

这不是简单的元数据变化,而是正文语义变化,因此需要重新向量化。

常见处理流程:

  1. 根据 doc_id 找到旧文档下的所有 chunk
  2. 删除旧 chunk 或标记为失效
  3. 对新文档重新分块
  4. 调用 Embedding 模型生成新向量
  5. 写入向量数据库

这也是为什么元数据里一定要有 doc_id。没有它,就很难知道哪些 chunk 属于同一份文档。

3. 分块策略调整

如果从"每 500 字一个 chunk,重叠 100 字"改成"每 300 字一个 chunk,重叠 50 字",chunk 内容发生了变化,向量也必须重新生成。

分块策略调整通常影响全量数据,因此成本较高。建议在项目早期通过样本评测尽量确定合理策略,避免频繁大规模重跑。

4. 模型版本升级

即使模型名称类似,不同版本生成的向量也可能不兼容。

例如从某个 embedding-v2 升级到 embedding-v3,需要查看模型说明,确认:

  • 输出维度是否变化
  • 向量是否归一化
  • 新旧向量是否兼容
  • 旧索引是否需要重建

一个实用建议是:在元数据中记录模型信息。

json 复制代码
{
  "embedding_model": "BAAI/bge-m3",
  "embedding_model_version": "v1",
  "embedding_dimension": 1024
}

这样后续排查效果问题、做灰度切换或重建索引时,都会更方便。

小结

本文从课程预约知识库的检索问题出发,解释了为什么关键词匹配不足以支撑语义问答。

核心内容包括:

  • 关键词检索只能看字面,难以处理同义表达、一词多义和上下文理解
  • Embedding 可以把文本转换成向量,让语义相近的文本在向量空间中更接近
  • Embedding 模型的选型需要关注中文效果、向量维度、输入长度、成本和部署方式
  • 余弦相似度是常见的向量相似度度量方式,适合大多数语义检索场景
  • Java 可以通过 HttpClient 直接调用通用 Embedding API,完成文本向量化
  • 生产环境中需要考虑分批处理、并发控制、失败重试和限流策略
  • 元数据默认不参与向量化,但会和向量一起存储,用于过滤、权限控制和溯源
  • 换模型、改正文、调分块策略、升级模型版本,都可能需要重新向量化

到这里,RAG 的数据准备链路已经基本完整:

plain 复制代码
原始文档 → 文档分块 → 元数据管理 → 文本向量化

下一步需要解决的是:向量生成之后存到哪里?如何在几十万、几百万甚至更多向量中快速找到最相似的 Top-K?普通关系型数据库能不能承担这个任务?

这些问题就会引出下一部分内容:向量数据库。

如果我的内容对你有帮助,请辛苦动动您的手指为我点赞,评论,收藏。感谢大家!!

相关推荐
lxw18449125141 小时前
2026年国内免费的key ,可接入cc switch 的有哪些?
人工智能·ai编程
嵌入式-老费1 小时前
esp32开发与应用(看门狗测试)
java·开发语言·数据库
开开心心就好1 小时前
自动生成小学数学题库支持导出Word
人工智能·安全·leetcode·贪心算法·ocr·音视频·语音识别
chinesegf1 小时前
模型如何自主判断调用工具
人工智能·自动化
专注搞钱1 小时前
用GPT-4o自动生成SPC报告:省了每月2天重复劳动
人工智能·半导体
Zyangxsir1 小时前
RabbitMQ 核心概念以及Java(Spring Boot)实战用法的整理
java·spring boot·后端·rabbitmq·java-rabbitmq
Marion1581 小时前
【无标题】
java·人工智能·ai
艾莉丝努力练剑1 小时前
【Linux网络】多路转接select
java·linux·运维·服务器·网络·tcp/ip
qingyulee1 小时前
Python自然语言处理:从分词到词向量
人工智能·自然语言处理