做博客问答时,真正决定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 模型还会带来另一个问题:旧向量和新向量不在同一个空间里。修改模型后,正确的流程是:
停止应用或先禁止新的问答请求。
删除旧集合中的向量。
使用新模型重新导入全部文章。
检查集合维度和点数量。
再恢复问答流量。
只改环境变量、不重建知识库,结果通常比报错更难排查,因为接口可能还能返回内容,但相似度已经失去意义。
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 链路仍然需要认真设计。