笔记 | 【AI】从文本到向量:理解Embedding的作用(整理分享)

最近在做优化的时候涉及到了这块内容,觉得值得写下来,方便以后翻阅。

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

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

🤞先做到 再看见!


目录

3. RAG 要的是语义检索,而不是字面匹配 向量:让计算机比较语义的一种方式 Embedding 模型:文本到向量的转换器 相似度计算:如何判断两个向量是否相近 动手实践:用通用 Embedding API 跑通向量化流程 实际工程中的关键决策 批量向量化的性能优化 向量化和元数据的关系向量数据库中通常存什么什么时候要重新向量化 小结

上一篇文章讨论了元数据管理:如何给每个 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-smallOpenAI15368191 左右中等云端 API成本较低,适合英文或多语言通用场景text-embedding-3-largeOpenAI30728191 左右中等云端 API精度更高,成本也更高text-embedding-v3通义系列1024/768 等8192 左右较好云端 API中文知识库常见选择BGE-large-zhBAAI1024512 左右较好本地/API中文效果稳定,适合较短文本BGE-M3BAAI10248192 左右较好本地/API多语言、多粒度场景常用Qwen3-Embedding 系列通义系列取决于具体版本较长上下文较好本地/API适合中文和长文本场景GTE-large-zh通义系列10248192 左右较好本地/API中文检索任务常见选择

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

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

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

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

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

5. 向量维度怎么选

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

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

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

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

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

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

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

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

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

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

1. 余弦相似度

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

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

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

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

  • 接近 1:方向高度一致,语义高度相似
  • 接近 0:方向接近垂直,语义相关性弱
  • 接近 -1:方向相反,语义差异极大
在实际 Embedding 检索中,很多模型和向量库会对向量做归一化处理,所以常见分数更多集中在 0 到 1 之间。

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

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

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

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

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。


        com.fasterxml.jackson.core
        jackson-databind
        2.17.0

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 embed_texts(List texts) throws Exception {
        Map 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 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 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 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 chunk_texts = new ArrayList();
        for (Map chunk : chunks) {
            chunk_texts.add((String) chunk.get("content"));
        }

        System.out.println("正在向量化 " + chunk_texts.size() + " 个 chunks...");
        List 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 results = new ArrayList();

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

            Map 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 result = results.get(i);
            Map metadata = (Map) result.get("metadata");

            System.out.printf(
                    "Top-%d [相似度: %.4f] [
                    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 embed_in_batches(List texts, int batch_size) throws Exception {
        List 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 batch_texts = texts.subList(i, end_index);

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

            List 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 embed_concurrently(
            List texts,
            int batch_size,
            int max_concurrency
    ) throws Exception {
        ExecutorService executor_service = Executors.newFixedThreadPool(max_concurrency);
        List 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 batch_texts = texts.subList(start_index, end_index);

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

        List all_embeddings = new ArrayList();

        for (Future 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 embed_with_retry(
List texts,
int max_retries
) throws Exception {
Exception last_exception = null;

for (int attempt = 1; attempt


这篇笔记就先到这里,后面用到新的思路或者发现有问题再补充。

评论 (0)

暂无评论