2 Embedding:从文本到可检索的向量

2 Embedding:从文本到可检索的向量

做博客问答时,真正决定Embedding 效果的,不只是模型名字,还有输入边界、向量维度、批量大小,以及入库和查询时是否使用同一套配置。 StarryRAG 使用 OpenAI 兼容接口调用火山引擎的 Embedding 模型,当前模型是 doubao-embedding-vision-251215

做博客问答时,真正决定Embedding 效果的,不只是模型名字,还有输入边界、向量维度、批量大小,以及入库和查询时是否使用同一套配置。

StarryRAG 使用 OpenAI 兼容接口调用火山引擎的 Embedding 模型,当前模型是 doubao-embedding-vision-251215,向量维度为 1024。生成模型和 Embedding 模型分开,前者负责写答案,后者负责把问题和文章放到同一个可比较的空间里。

1. 为什么需要 Embedding

假设文章里写着:

项目使用 Spring Boot、Qdrant 和 SSE 搭建了一个博客问答服务。

用户可能会问:

这个问答系统的后端用了什么技术?

这两个句子没有共享太多关键词。只做字符串匹配,容易漏掉它们之间的关系。Embedding 模型会把两段文字编码成向量,再用余弦相似度比较距离。含义接近的内容,通常会落在更近的位置。

这里的“通常”很重要。向量相似度不是事实判断,也不是搜索引擎的精确匹配。它只能帮助系统找到更可能相关的候选文本,后面仍然需要重排和生成模型来处理。

2. 维度不是越大越好

向量维度是模型接口和向量库之间的契约。

StarryRAG 的配置大致是:

 starry:
   rag:
     llm:
       embedding:
         model-name: EMBEDDING_MODEL
         dimensions: 1024
     qdrant:
       vector-size: 1024

这两个 1024 必须一致。换模型时,如果新模型输出 1536 维,而 Qdrant 集合仍然按 1024 维创建,写入时会失败。即使接口允许某些转换,也不应该把这种不一致留到线上才发现。

换 Embedding 模型还会带来另一个问题:旧向量和新向量不在同一个空间里。修改模型后,正确的流程是:

  1. 停止应用或先禁止新的问答请求。

  2. 删除旧集合中的向量。

  3. 使用新模型重新导入全部文章。

  4. 检查集合维度和点数量。

  5. 再恢复问答流量。

只改环境变量、不重建知识库,结果通常比报错更难排查,因为接口可能还能返回内容,但相似度已经失去意义。

3. 入库时要控制批量

早期版本把所有文本段一次性交给 Embedding 接口。文章数量一上来,就遇到了接口的单次输入数量限制和请求过于频繁的问题(当然这是因为我是用的火山coding plan可能订阅给的配置比较低,具体还是看模型的要求)。

现在的入库逻辑把文本段按 10 条一批处理:

 private static final int EMBEDDING_BATCH_SIZE = 10;
 private static final int EMBEDDING_MAX_ATTEMPTS = 4;
 private static final long EMBEDDING_BATCH_DELAY_MS = 1500L;

每一批的流程是:

 文本段列表
   -> 取 10 条
   -> 调用 embedAll
   -> 写入 Qdrant
   -> 等待 1500ms
   -> 处理下一批

如果接口返回限流错误,程序会进行递增等待,最多重试 4 次。这里没有使用无限重试,因为限流期间无限重试只会让请求堆积,最后更难恢复。

批量大小和等待时间不是固定真理。它们取决于供应商的配额、网络延迟和文章数量。一个可操作的做法是先用小批量跑通,再根据服务商返回的限制调整。

4. 入库和查询必须使用同一模型

入库时,文章被编码成向量:

 List<Embedding> embeddings = embeddingModel.embedAll(batch).content();
 embeddingStore.addAll(ids, embeddings, batch);

查询时,用户问题也必须由同一个 Embedding 模型编码:

 Embedding queryEmbedding = embeddingModel.embed(query).content();
 return embeddingStore.search(
         EmbeddingSearchRequest.builder()
                 .queryEmbedding(queryEmbedding)
                 .maxResults(topK)
                 .build()
 ).matches();

如果文章用模型 A,问题用模型 B,哪怕两个接口都返回 1024 维,向量之间也没有可比性。维度相同不等于语义空间相同。

5. Qdrant 保存的不只是向量

StarryRAG 在写入向量时,同时保存文章片段和元数据:

 Metadata meta = new Metadata();
 meta.put(`source`, url);
 meta.put(`title`, page.getTitle());
 segments.add(TextSegment.from(chunk, meta));

其中 source 很有用。它一方面帮助模型知道这段内容来自哪篇文章,另一方面让接口可以在 done 事件中返回来源 URL。前端再把这些 URL 渲染成“参考文章”。

如果只保存向量,不保存来源,回答虽然可能正确,用户却无法继续阅读原文。对博客问答来说,这个缺口很明显。

6. Embedding 层的排查顺序

遇到搜索结果为空或明显不相关时,我会按这个顺序检查:

  • 当前模型名和 API 地址是否真的注入到容器。

  • Embedding API 返回的维度是否等于 Qdrant 集合维度。

  • 入库和查询是否使用同一个模型。

  • Qdrant 集合是否有点,点数量是否符合预期。

  • 文本切分是否把标题、正文和代码切得过碎。

  • 查询的最小相似度阈值是否过高。

  • 是否因为接口限流,实际只写入了部分批次。

Embedding 解决的是“把内容放到可比较的空间里”。它不会自动解决数据清洗、召回范围和答案引用问题。后面的 RAG 链路仍然需要认真设计。

LICENSED UNDER CC BY-NC-SA 4.0
Comment