欢迎光临
我们一直在努力

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

在这里插入图片描述

👨‍💻程序员三明治:个人主页

🔥 个人专栏: 《设计模式精解》 《重学数据结构》 《AI探索日志》 《从0带你学深度强化学习》

🤞先做到 再看见!


目录

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

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

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

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

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

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

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

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

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

在这里插入图片描述

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

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

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

现在用户问:

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

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

明天 / 试听课 / 临时 / 去不了

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

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

体验课 / 开课前 24 小时 / 取消 / 改期 / 教师档期

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

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

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

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

2.1 同义表达问题

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

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

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

2.2 一词多义问题

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

比如用户问:

这个班还能加吗?

这里的“班”可能指:

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

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

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

2.3 上下文理解问题

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

比如用户问:

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

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

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

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

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

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

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

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

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

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

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

向量听起来像一个数学概念,但在语义检索里,可以先把它理解成“文本在语义空间中的坐标”。 在这里插入图片描述

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

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

  • 横轴表示“课程预约相关程度”
  • 纵轴表示“教学服务相关程度”

教学服务相关 ↑
|
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 模型:

体验课开课前 24 小时可免费取消预约

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

[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 小时可免费取消预约”
  • “明天的试听课临时去不了怎么办?”

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

而下面这句话:

课程回放将在直播结束后自动生成

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

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

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

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

答案就是 Embedding 模型。

1. Embedding 模型做了什么

Embedding 模型的工作非常明确:

输入:一段文本
输出:一组浮点数向量

可以把它理解成一种“语义翻译器”。普通翻译器把中文翻成英文,而 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 检索中,典型流程是:

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

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

1. 余弦相似度

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

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

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

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

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

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

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

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

  • 计算两个向量的点积
  • 分别计算两个向量的模
  • 用点积除以两个模的乘积
  • 公式不需要死记,但理解这个过程有助于后面读代码。

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

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

    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));
    }
    }

    可能输出:

    「体验课可取消预约」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 条。

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

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

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

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

    6. 其他相似度度量方式

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

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

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

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

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

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

    EMBEDDING_API_URL
    EMBEDDING_API_KEY
    EMBEDDING_MODEL

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

    1. 请求格式

    示例请求:

    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. 响应格式

    响应通常类似:

    {
    "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。

    <dependencies>
    <dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.17.0</version>
    </dependency>
    </dependencies>

    4. EmbeddingClient 工具类

    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);
    }
    }

    这段代码的核心流程是:

    构造请求体 → 发送 HTTP POST → 解析 JSON → 提取 embedding 数组

    5. 相似度工具类

    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 做向量化,再对用户问题做向量化,最后计算相似度并排序。

    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. 运行结果分析

    可能输出类似:

    正在向量化 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 还是本地服务,都建议把向量化能力封装在一个独立的客户端中。

    业务代码只依赖:

    输入文本 → 返回向量

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

    批量向量化的性能优化

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

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

    1. 分批处理

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

    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 或本地服务支持并发,可以使用线程池提高吞吐量。

    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;对于本地模型服务,过高并发可能导致显存不足或排队延迟升高。

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

    batch_size = 32
    max_concurrency = 3

    然后逐步调整。

    3. 错误重试

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

    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
    );
    }
    }

    这里使用的是指数退避:

    第 1 次失败后等待 1 秒
    第 2 次失败后等待 2 秒
    第 3 次失败后等待 4 秒

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

    向量化和元数据的关系

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

    一句话概括:

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

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

    {
    "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 用于后续过滤、展示和追踪。

    检索流程可以理解为:

    用户 query

    query 向量化

    向量数据库做相似度检索

    返回候选 chunks

    根据 metadata 做二次过滤

    返回最终结果

    例如:

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

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

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

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

    向量数据库中通常存什么

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

    字段内容作用
    id chunk 唯一标识 用于更新、删除和追踪
    vector Embedding 模型输出的向量 用于相似度检索
    text 原始 chunk 文本 用于返回给 LLM 或展示
    metadata 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 向量混用。

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

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

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

    2. 文档内容更新

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

    例如:

    原规则:体验课开课前 24 小时可免费取消
    新规则:体验课开课前 12 小时可免费取消

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

    常见处理流程:

  • 根据 doc_id 找到旧文档下的所有 chunk
  • 删除旧 chunk 或标记为失效
  • 对新文档重新分块
  • 调用 Embedding 模型生成新向量
  • 写入向量数据库
  • 这也是为什么元数据里一定要有 doc_id。没有它,就很难知道哪些 chunk 属于同一份文档。

    3. 分块策略调整

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

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

    4. 模型版本升级

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

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

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

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

    {
    "embedding_model": "BAAI/bge-m3",
    "embedding_model_version": "v1",
    "embedding_dimension": 1024
    }

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

    小结

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

    核心内容包括:

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

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

    原始文档 → 文档分块 → 元数据管理 → 文本向量化

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

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

    如果我的内容对你有帮助,请辛苦动动您的手指为我点赞,评论,收藏。感谢大家!! 在这里插入图片描述

    赞(0)
    未经允许不得转载:171主机测评 » 【AI】从文本到向量:理解Embedding的作用
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址