向量数据库选型实战指南
买房子要考虑什么?
- 预算多少?(成本)
- 几口人住?(规模)
- 上班远不远?(性能)
- 将来要换大房子吗?(扩展性)
- 物业靠不靠谱?(维护成本)
选向量数据库也是一样的逻辑。
没有"最好的"向量数据库,只有"最适合你场景的"。
向量数据库在RAG中承担两个核心职责:存储(保存文档块的向量表示及原始文本)和检索(根据查询向量快速找出语义最相近的文档块)。它的本质是将暴力全量比较(O(n))优化为近似最近邻搜索(O(log n)),使得百万级向量检索能在毫秒级内完成。
先搞清楚向量数据库是干什么的
普通数据库 vs 向量数据库
普通数据库(MySQL、PostgreSQL)擅长精确匹配:
SELECT * FROM users WHERE name = '张三';
SELECT * FROM products WHERE price < 100;
要么完全匹配,要么不匹配。
向量数据库擅长相似度匹配:
给定向量 [0.23, -0.45, 0.67, ...]
找出数据库中最相似的5个向量
不是完全匹配,是找"最像的"。
在RAG中的作用
回顾一下RAG的流程:
- 文档切块 → 转成向量 → 存进向量数据库
- 用户提问 → 转成向量 → 在向量数据库中检索 → 找到最相似的文档块
- 文档块 + 问题 → 发给大模型 → 生成回答
向量数据库承担了存储和检索两个核心职责。
向量数据库存了什么
以PGVector为例,看一下实际存储的数据:
| 字段 | 内容 | 作用 |
|---|---|---|
| embedding_id | 主键 | 唯一标识 |
| embedding | 高维向量 [0.23, -0.45, ...] | 用于相似度检索 |
| text | 原始文本块 | 检索到后返回给大模型 |
| metadata | 元数据 JSON | 过滤、溯源 |
检索的时候用向量算相似度,返回的时候把原始文本一起带回来。
主流向量数据库介绍
市面上向量数据库有很多,我挑几个最常用的讲。
PGVector:PostgreSQL的扩展
是什么
PGVector不是一个独立的数据库,而是PostgreSQL的一个扩展。装上这个扩展,PostgreSQL就能存储和检索向量了。
优点
- 无需引入新数据库:如果你已经在用PostgreSQL,加个扩展就行
- SQL + 向量混合查询:可以在一条SQL里同时做结构化过滤和向量检索
- 运维简单:继承PostgreSQL的生态,权限、备份、监控都是现成的
- 支持元数据过滤:可以先过滤再检索,或者检索后再过滤
缺点
- 不适合超大规模:百万级数据还行,上亿级性能会下降
- 分布式能力弱:本质上还是单机数据库
适合场景
- 中小型知识库(几十万到几百万条)
- 团队已经在用PostgreSQL
- 想用Navicat直接查数据(开发调试方便)
Milvus:专业的分布式向量数据库
是什么
Milvus是专门为向量检索设计的云原生分布式数据库,完全开源(Apache 2.0协议)。
优点
- 为大规模设计:亿级数据也能扛住
- 天然分布式:水平扩展能力强
- 多种索引类型:HNSW、IVF系列、DISKANN等,可以根据场景灵活选择
- 原生支持混合检索:2.5版本开始支持稀疏+稠密向量组合检索
- Java SDK完善:API设计清晰,对Java技术栈友好
缺点
- 部署复杂:分布式架构,需要一定运维能力
- 资源消耗大:内存需求高,HNSW索引尤其吃内存
适合场景
- 大规模RAG系统(千万级以上)
- 推荐系统、图像检索等高性能场景
- 有专门的运维团队
- Java技术栈项目
Milvus的数据模型——用MySQL的思路来理解
如果你用过MySQL,Milvus的概念体系上手会很快。下面这张对照表能帮你快速建立映射:
| Milvus | MySQL | 一句话说明 |
|---|---|---|
| Collection | Table | 存放同一类向量数据的容器 |
| Schema | CREATE TABLE的列定义 | 规定每条数据有哪些字段、什么类型 |
| Field | Column | 单个字段 |
| Entity | Row | 一条数据记录 |
| 向量索引(HNSW等) | 无直接对应 | 专门加速"找最像的"这件事 |
| 标量索引 | B+树索引 | 加速WHERE条件过滤 |
| Partition | 分区表 | 按业务维度切分数据,缩小检索范围 |
这里面最关键的区别在于字段类型。Milvus的Schema里有两类字段,理解它们的差异非常重要:
- 标量字段:和MySQL的列没什么两样,存字符串、数字、布尔值这些普通数据。你可以对它做等值查询、范围查询、模糊匹配,建的索引也是类似B+树的结构。
- 向量字段:存的是高维浮点数数组(比如1536维的float数组)。它没法做等值查询——两个向量完全相等的概率几乎为零——只能做相似度检索,也就是"找最近的TopK个"。它需要专门的向量索引(HNSW、IVF等),和标量索引是完全不同的东西。
举个例子,一个典型的RAG场景Schema长这样:
| 字段类型 | 字段名 | 数据类型 | 作用 |
|---|---|---|---|
| 主键 | id | Int64 | 唯一标识,可以自增 |
| 向量字段 | embedding | FloatVector(1536) | 存Embedding向量,用于相似度检索 |
| 标量字段 | content | VarChar | 存原始文本,检索到后返回给大模型 |
| 标量字段 | doc_id | VarChar | 标识来源文档,更新时靠它定位 |
| 标量字段 | category | VarChar | 分类标签,用于过滤 |
Partition可以按业务维度把数据分开存储,检索时指定Partition能缩小搜索范围。但它不是必须的——如果你的数据量不大(100万以内),或者分类维度的值特别多(上百个),直接用标量字段的filter过滤就够了,效果差不多,管理起来更简单。只有当某个过滤条件在绝大多数查询中都会用到、且值的种类不超过几十个时,Partition才有明显优势。
Qdrant:性能与易用的平衡
是什么
Qdrant是用Rust写的开源向量数据库,强调性能和易用性。
优点
- 性能优秀:Rust实现,内存管理效率高
- 部署简单:单机Docker一键启动,也支持集群
- 元数据过滤强:支持复杂的过滤条件,性能影响小
- API友好:RESTful API,调试方便
缺点
- 分布式能力不如Milvus:集群方案相对没那么成熟
- 社区规模:比Milvus小一些,但增长很快
适合场景
- 中等规模的RAG系统
- 想兼顾性能和简洁
- 不想搞太复杂的运维
Docker启动
docker run -p 6333:6333 qdrant/qdrant
Chroma:轻量级首选
是什么
Chroma是为AI应用设计的轻量级向量数据库,主打开箱即用。
优点
- 极简:几行代码就能跑起来
- 可以嵌入应用:直接作为内存数据库使用
- 支持持久化:也可以存到磁盘
缺点
- 不适合大规模:本质是轻量级工具
- 分布式能力缺失:只能单机
适合场景
- 快速验证想法
- 个人项目、学习用途
- 小规模应用
代码内嵌使用
import chromadb
client = chromadb.Client()
collection = client.create_collection("my_collection")
Elasticsearch:老牌搜索引擎
是什么
Elasticsearch本来是全文搜索引擎,从8.x版本开始支持向量字段(dense_vector)。
优点
- 混合检索能力强:全文检索+向量检索+结构化查询,三合一
- 企业生态成熟:很多公司已经部署了ES
- 分布式成熟:久经考验的分布式架构
缺点
- 向量检索性能:比专门的向量数据库略差
- 比较重:如果只是为了向量检索,ES有点大材小用
适合场景
- 公司已经有ES集群
- 需要同时做全文搜索和语义搜索
- 想做混合检索
选型决策指南
一张表对比
| 数据库 | 部署复杂度 | 数据规模 | 分布式 | 混合检索 | 元数据过滤 | 适合场景 |
|---|---|---|---|---|---|---|
| PGVector | 低 | 百万级 | 弱 | 需自研 | 支持 | 中小型、已有PG |
| Milvus | 高 | 亿级 | 强 | 原生支持 | 支持 | 大规模、高性能 |
| Qdrant | 中 | 千万级 | 中 | 部分支持 | 强 | 中等规模 |
| Chroma | 极低 | 十万级 | 无 | 不支持 | 简单支持 | 验证、学习 |
| ES | 中偏高 | 亿级 | 强 | 原生支持 | 支持 | 已有ES、需混合检索 |
决策流程图
我的建议
如果你刚开始做RAG
从PGVector开始。原因:
- 部署简单,有个PostgreSQL就行
- 数据可以直接用Navicat查看,方便调试
- 够用,百万级数据撑得住
- 以后要迁移也不难
对于大多数中小型RAG项目,PGVector是最省心的选择。它不需要引入新的基础设施,PostgreSQL的运维工具链完全复用,而且Spring AI对PGVector的支持最完善。只有当数据规模超过千万级或有极高的QPS要求时,才需要考虑专业向量数据库。
如果你在做企业级项目
看团队情况:
- 有ES运维经验 → Elasticsearch
- 追求性能 → Milvus
- 想简单点 → Qdrant
如果只是学习或验证
Chroma,最快上手。
实战:医疗知识库场景
来看一个具体的场景,演示如何选型和使用。
场景描述
某医院要做一个智能问诊助手,知识库包含:
- 疾病百科:5000+疾病
- 用药指南:10000+药品
- 诊疗规范:3000+文档
预计总数据量:50万-100万条文档块。
需求分析
- 数据规模:百万级,中等规模
- 安全要求:医疗数据敏感,必须私有部署
- 检索要求:支持按科室、疾病类型过滤
- 团队情况:已有PostgreSQL,运维人员有限
选型决策
考虑到数据规模在百万级、团队已有PostgreSQL、运维资源有限,选择PGVector。
环境搭建:Docker部署PostgreSQL + PGVector
不需要自己编译安装,官方提供了内置PGVector扩展的Docker镜像。先拉取镜像:
docker pull pgvector/pgvector:pg17
拉取完成后,启动容器:
docker run -d \
--name pgvector \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=medical_kb \
-p 5432:5432 \
pgvector/pgvector:pg17
启动后连接数据库,启用扩展并创建向量表:
-- 创建数据库
CREATE DATABASE medical_kb;
-- 启用pgvector扩展(每个数据库只需执行一次)
CREATE EXTENSION IF NOT EXISTS vector;
-- 验证安装成功
SELECT extversion FROM pg_extension WHERE extname = 'vector';
看到版本号输出就说明装好了。
pgvector/pgvector:pg17 是基于PostgreSQL 17的镜像,PGVector已经预装好了。如果你的项目用的是PG 16,换成 pgvector/pgvector:pg16 就行。不建议用普通的postgres镜像再手动装扩展,没必要折腾。
示例中项目地址
- 项目地址:https://gitee.com/shining-stars-l/super-ai-hub
- 项目模块:
ai-example-spring-ai-rag-pg
Spring AI配置
这个项目用的是硅基流动(SiliconFlow)作为Embedding模型提供方。你可能会问:不是在npmnpm讲PGVector吗,怎么还要配AI的东西?
原因是这样的:PGVector只负责存储向量和检索向量,但文本怎么变成向量,它管不了。你需要一个Embedding模型来做这个转换。当你调用vectorStore.add(doc)时,Spring AI会自动调用配置好的Embedding模型把文本转成4096维的向量,然后再存进PGVector。检索时也一样,你传入一个文本query,Spring AI先把它转成向量,再去PGVector里找最相似的。
所以配置里有两块东西:一块是PG数据源 + PGVector参数,一块是硅基流动的Embedding模型。
硅基流动的API兼容OpenAI协议,所以Spring AI里直接用spring.ai.openai配置,把base-url指向硅基流动就行:
server:
port: 7093
spring:
datasource:
url: jdbc:postgresql://localhost:5432/medical_kb
username: postgres
password: postgres
ai:
openai:
base-url: https://api.siliconflow.cn # 硅基流动的API地址
api-key: ${SILICONFLOW_API_KEY} # 硅基流动的API Key
embedding:
options:
model: Qwen/Qwen3-Embedding-8B # Embedding模型
dimensions: 1024 # 指定输出1024维(PGVector的HNSW索引最多支持2000维)
vectorstore:
pgvector:
index-type: hnsw # 向量索引类型
distance-type: COSINE_DISTANCE # 余弦相似度
dimensions: 1024 # 和Embedding输出维度一致
登录硅基流动平台,在API密钥页面创建一个Key。Qwen/Qwen3-Embedding-8B是通义千问的文本向量模型,输出4096维向量。配置时通过环境变量SILICONFLOW_API_KEY注入,避免把Key硬编码到配置文件里。
Spring AI对PGVector的支持非常完善,配好数据源和向量存储参数后,它会自动帮你建表、建索引,不需要手动执行SQL。
实现代码
1. 入库服务
@Service
public class MedicalKnowledgeService {
@Autowired
private VectorStore vectorStore;
/**
* 导入疾病知识
*/
public void importDiseaseKnowledge(Disease disease) {
// 构建文档内容
String content = String.format(
"疾病名称:%s\n症状:%s\n治疗方案:%s",
disease.getName(),
disease.getSymptoms(),
disease.getTreatment()
);
// 构建元数据(用于后续过滤)
Map<String, Object> metadata = new HashMap<>();
metadata.put("type", "disease");
metadata.put("department", disease.getDepartment()); // 科室
metadata.put("category", disease.getCategory()); // 疾病分类
metadata.put("diseaseId", disease.getId());
Document doc = new Document(content, metadata);
vectorStore.add(List.of(doc));
}
/**
* 导入药品知识
*/
public void importDrugKnowledge(Drug drug) {
String content = String.format(
"药品名称:%s\n适应症:%s\n用法用量:%s\n注意事项:%s",
drug.getName(),
drug.getIndications(),
drug.getDosage(),
drug.getPrecautions()
);
Map<String, Object> metadata = new HashMap<>();
metadata.put("type", "drug");
metadata.put("category", drug.getCategory());
metadata.put("drugId", drug.getId());
Document doc = new Document(content, metadata);
vectorStore.add(List.of(doc));
}
}
4. 检索服务(带元数据过滤)
@Service
public class MedicalSearchService {
@Autowired
private VectorStore vectorStore;
/**
* 通用检索
*/
public List<Document> search(String query, int topK) {
return vectorStore.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(topK)
.similarityThreshold(0.4)
.build()
);
}
/**
* 按科室检索(只搜索特定科室的知识)
*/
public List<Document> searchByDepartment(String query, String department, int topK) {
// 构建过滤表达式
FilterExpressionBuilder builder = new FilterExpressionBuilder();
Expression filter = builder.eq("department", department).build();
return vectorStore.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(topK)
.similarityThreshold(0.4)
.filterExpression(filter)
.build()
);
}
/**
* 只搜索药品知识
*/
public List<Document> searchDrugs(String query, int topK) {
FilterExpressionBuilder builder = new FilterExpressionBuilder();
Expression filter = builder.eq("type", "drug").build();
return vectorStore.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(topK)
.filterExpression(filter)
.build()
);
}
}
启动时自动导入示例数据
为了方便测试,项目启动时会自动导入一批医疗知识数据。通过实现CommandLineRunner接口,在Spring Boot启动完成后自动执行:
@Slf4j
@Component
public class MedicalDataInitializer implements CommandLineRunner {
private final MedicalKnowledgeService knowledgeService;
private final JdbcTemplate jdbcTemplate;
public MedicalDataInitializer(MedicalKnowledgeService knowledgeService, JdbcTemplate jdbcTemplate) {
this.knowledgeService = knowledgeService;
this.jdbcTemplate = jdbcTemplate;
}
@Override
public void run(String... args) {
log.info("开始初始化医疗知识库示例数据...");
try {
// 清空旧数据,避免重复
jdbcTemplate.execute("TRUNCATE TABLE public.vector_store");
log.info("已清空旧数据");
importDiseases();
importDrugs();
log.info("医疗知识库示例数据初始化完成");
} catch (Exception e) {
log.error("初始化医疗知识库数据失败", e);
}
}
private void importDiseases() {
Disease flu = new Disease();
flu.setId("D001");
flu.setName("流行性感冒");
flu.setSymptoms("高热、头痛、全身肌肉酸痛、乏力、鼻塞、流涕、咽痛、咳嗽");
flu.setTreatment("对症治疗为主,发病48小时内可使用奥司他韦等抗病毒药物,注意休息和补充水分");
flu.setDepartment("内科");
flu.setCategory("呼吸系统疾病");
knowledgeService.importDiseaseKnowledge(flu);
Disease hypertension = new Disease();
hypertension.setId("D002");
hypertension.setName("高血压");
hypertension.setSymptoms("头晕、头痛、耳鸣、心悸、眼花、注意力不集中、记忆力减退、手脚麻木");
hypertension.setTreatment("长期规律服用降压药(如氨氯地平、缬沙坦等),低盐低脂饮食,适量运动,戒烟限酒,定期监测血压");
hypertension.setDepartment("内科");
hypertension.setCategory("心血管疾病");
knowledgeService.importDiseaseKnowledge(hypertension);
Disease diabetes = new Disease();
diabetes.setId("D003");
diabetes.setName("2型糖尿病");
diabetes.setSymptoms("多饮、多尿、多食、体重下降、视力模糊、皮肤瘙痒、伤口愈合缓慢");
diabetes.setTreatment("饮食控制和运动为基础,口服降糖药(如二甲双胍)或注射胰岛素,定期监测血糖和糖化血红蛋白");
diabetes.setDepartment("内科");
diabetes.setCategory("内分泌疾病");
knowledgeService.importDiseaseKnowledge(diabetes);
Disease gastritis = new Disease();
gastritis.setId("D004");
gastritis.setName("慢性胃炎");
gastritis.setSymptoms("上腹部隐痛、腹胀、嗳气、食欲不振、恶心、反酸");
gastritis.setTreatment("规律饮食,避免辛辣刺激食物,幽门螺杆菌阳性者需三联或四联疗法根除治疗,可服用奥美拉唑等质子泵抑制剂");
gastritis.setDepartment("消化内科");
gastritis.setCategory("消化系统疾病");
knowledgeService.importDiseaseKnowledge(gastritis);
Disease lumbar = new Disease();
lumbar.setId("D005");
lumbar.setName("腰椎间盘突出症");
lumbar.setSymptoms("腰痛、下肢放射痛、下肢麻木、行走困难、久坐后加重");
lumbar.setTreatment("急性期卧床休息,口服非甾体抗炎药缓解疼痛,配合理疗和康复锻炼,严重者需手术治疗");
lumbar.setDepartment("骨科");
lumbar.setCategory("骨骼肌肉疾病");
knowledgeService.importDiseaseKnowledge(lumbar);
}
private void importDrugs() {
Drug ibuprofen = new Drug();
ibuprofen.setId("M001");
ibuprofen.setName("布洛芬缓释胶囊");
ibuprofen.setIndications("用于缓解轻至中度疼痛,如头痛、关节痛、偏头痛、牙痛、肌肉痛、神经痛、痛经,也用于普通感冒或流行性感冒引起的发热");
ibuprofen.setDosage("口服,成人一次1粒(0.3g),一日2次");
ibuprofen.setPrecautions("消化性溃疡患者禁用,肝肾功能不全者慎用,不宜与其他非甾体抗炎药同时使用");
ibuprofen.setCategory("解热镇痛药");
knowledgeService.importDrugKnowledge(ibuprofen);
Drug metformin = new Drug();
metformin.setId("M002");
metformin.setName("盐酸二甲双胍片");
metformin.setIndications("用于2型糖尿病,特别是肥胖的2型糖尿病患者,可单独使用或与其他降糖药联合使用");
metformin.setDosage("口服,起始剂量一次0.5g,一日2-3次,随餐服用,可根据血糖逐渐增加剂量,最大日剂量2g");
metformin.setPrecautions("肾功能不全(eGFR<30)禁用,做增强CT检查前后48小时需停药,长期使用注意监测维生素B12水平");
metformin.setCategory("降糖药");
knowledgeService.importDrugKnowledge(metformin);
Drug amlodipine = new Drug();
amlodipine.setId("M003");
amlodipine.setName("苯磺酸氨氯地平片");
amlodipine.setIndications("用于高血压和慢性稳定性心绞痛的治疗");
amlodipine.setDosage("口服,起始剂量一次5mg,一日1次,根据血压调整,最大剂量一日10mg");
amlodipine.setPrecautions("严重低血压患者禁用,肝功能不全者需减量,可能引起踝部水肿和头晕");
amlodipine.setCategory("降压药");
knowledgeService.importDrugKnowledge(amlodipine);
Drug omeprazole = new Drug();
omeprazole.setId("M004");
omeprazole.setName("奥美拉唑肠溶胶囊");
omeprazole.setIndications("用于胃溃疡、十二指肠溃疡、反流性食管炎、胃泌素瘤,也用于幽门螺杆菌的联合根除治疗");
omeprazole.setDosage("口服,一次20mg,一日1-2次,晨起空腹服用,疗程通常4-8周");
omeprazole.setPrecautions("长期使用可能增加骨折风险和低镁血症风险,不建议无明确指征的长期使用,服药期间避免与氯吡格雷合用");
omeprazole.setCategory("消化系统用药");
knowledgeService.importDrugKnowledge(omeprazole);
}
}
项目启动后,Spring AI会自动调用DashScope的Embedding模型把这些文本转成向量,存入PGVector。你可以在PostgreSQL里直接查到数据。
接口调用示例
项目启动后(确保PGVector容器已运行、DashScope API Key已配置),可以直接用curl测试:
通用检索:问一个症状相关的问题
curl "http://localhost:7093/medical/search?query=头疼发烧吃什么药&topK=5"
通用检索结果:
[
{
"metadata": {
"type": "disease",
"category": "呼吸系统疾病",
"department": "内科",
"distance": 0.5749157,
"diseaseId": "D001"
},
"content": "疾病名称:流行性感冒\n症状:高热、头痛、全身肌肉酸痛、乏力、鼻塞、流涕、咽痛、咳嗽\n治疗方案:对症治疗为主,发病48小时内可使用奥司他韦等抗病毒药物,注意休息和补充水分"
}
]
按科室检索:只在内科知识库中搜索
curl "http://localhost:7093/medical/search/department?query=高血压怎么治疗&department=内科&topK=5"
按科室检索结果:
[
{
"metadata": {
"type": "disease",
"category": "心血管疾病",
"department": "内科",
"distance": 0.5339663,
"diseaseId": "D002"
},
"content": "疾病名称:高血压\n症状:头晕、头痛、耳鸣、心悸、眼花、注意力不集中、记忆力减退、手脚麻木\n治疗方案:长期规律服用降压药(如氨氯地平、缬沙坦等),低盐低脂饮食,适量运动,戒烟限酒,定期监测血压"
},
{
"metadata": {
"type": "disease",
"category": "呼吸系统疾病",
"department": "内科",
"distance": 0.5671568,
"diseaseId": "D001"
},
"content": "疾病名称:流行性感冒\n症状:高热、头痛、全身肌肉酸痛、乏力、鼻塞、流涕、咽痛、咳嗽\n治疗方案:对症治疗为主,发病48小时内可使用奥司他韦等抗病毒药物,注意休息和补充水分"
}
]
只搜索药品:
curl "http://localhost:7093/medical/search/drug?query=退烧药&topK=5"
只搜索药品结果:
[
{
"metadata": {
"drugId": "M001",
"type": "drug",
"category": "解热镇痛药",
"distance": 0.65363115
},
"content": "药品名称:布洛芬缓释胶囊\n适应症:用于缓解轻至中度疼痛,如头痛、关节痛、偏头痛、牙痛、肌肉痛、神经痛、痛经,也用于普通感冒或流行性感冒引起的发热\n用法用量:口服,成人一次1粒(0.3g),一日2次\n注意事项:消化性溃疡患者禁用,肝肾功能不全者慎用,不宜与其他非甾体抗炎药同时使用"
},
{
"metadata": {
"drugId": "M003",
"type": "drug",
"category": "降压药",
"distance": 0.74444807
},
"content": "药品名称:苯磺酸氨氯地平片\n适应症:用于高血压和慢性稳定性心绞痛的治疗\n用法用量:口服,起始剂量一次5mg,一日1次,根据血压调整,最大剂量一日10mg\n注意事项:严重低血压患者禁用,肝功能不全者需减量,可能引起踝部水肿和头晕"
},
{
"metadata": {
"drugId": "M004",
"type": "drug",
"category": "消化系统用药",
"distance": 0.77292264
},
"content": "药品名称:奥美拉唑肠溶胶囊\n适应症:用于胃溃疡、十二指肠溃疡、反流性食管炎、胃泌素瘤,也用于幽门螺杆菌的联合根除治疗\n用法用量:口服,一次20mg,一日1-2次,晨起空腹服用,疗程通常4-8周\n注意事项:长期使用可能增加骨折风险和低镁血症风险,不建议无明确指征的长期使用,服药期间避免与氯吡格雷合用"
},
{
"metadata": {
"drugId": "M002",
"type": "drug",
"category": "降糖药",
"distance": 0.80041575
},
"content": "药品名称:盐酸二甲双胍片\n适应症:用于2型糖尿病,特别是肥胖的2型糖尿病患者,可单独使用或与其他降糖药联合使用\n用法用量:口服,起始剂量一次0.5g,一日2-3次,随餐服用,可根据血糖逐渐增加剂量,最大日剂量2g\n注意事项:肾功能不全(eGFR<30)禁用,做增强CT检查前后48小时需停药,长期使用注意监测维生素B12水平"
}
]
手动导入一条疾病知识:
curl -X POST http://localhost:7093/medical/disease \
-H "Content-Type: application/json" \
-d '{
"id": "D006",
"name": "过敏性鼻炎",
"symptoms": "打喷嚏、流清涕、鼻塞、鼻痒、眼痒",
"treatment": "避免接触过敏原,口服氯雷他定等抗组胺药,鼻喷糠酸莫米松等糖皮质激素",
"department": "耳鼻喉科",
"category": "变态反应性疾病"
}'
元数据过滤的好处是显而易见的:
- 搜索更精准(缩小范围)
- 速度更快(减少计算量)
- 结果更可控(不会串科室)
Milvus进阶:生产环境绕不开的几个问题
选型决定用Milvus之后,真正上手会碰到一些"demo里不会遇到,但生产环境必须想清楚"的问题。这一节把最常见的几个拎出来聊聊。
相似度度量怎么选:COSINE、IP还是L2
Milvus支持三种距离计算方式,很多人搞不清楚该用哪个。
| 度量方式 | 直觉理解 | 值域 | 越大越相似? | 典型场景 |
|---|---|---|---|---|
| COSINE | 看两个向量的"方向"是否一致,不管长短 | [-1, 1] | 是 | 文本语义检索(最常用) |
| IP(内积) | 同时考虑方向和长度 | (-∞, +∞) | 是 | 向量已归一化时和COSINE等价 |
| L2(欧氏距离) | 两个点在空间中的直线距离 | [0, +∞) | 否,越小越像 | 图像检索、推荐系统 |
怎么选?其实很简单——看你的Embedding模型推荐哪种。
大多数文本Embedding模型(OpenAI的text-embedding-3、Qwen3-Embedding等)输出的向量已经做过归一化处理,这种情况下COSINE和IP的效果完全一样。如果你不确定模型有没有归一化,选COSINE最稳妥,它会自动处理向量长度差异。
L2在文本检索场景用得比较少,更多出现在图像、音频这类领域。
创建索引时选的MetricType,必须和检索时用的一致。如果建索引用了COSINE,检索时传IP,结果会不对。这个不会报错,但返回的排序是乱的,排查起来很头疼。
数据更新怎么做:删旧插新
知识库不是一成不变的——文档会修改、会删除、会新增。向量数据库里的数据得跟着源文档同步。
Milvus目前不支持直接update一条数据的向量字段,所以更新的标准做法是先删后插:
import io.milvus.v2.service.vector.request.DeleteReq;
// 第一步:按doc_id删掉旧数据
DeleteReq deleteReq = DeleteReq.builder()
.collectionName("knowledge_chunks")
.filter("doc_id == \"hr_001\"")
.build();
client.delete(deleteReq);
// 第二步:对修改后的文档重新走一遍 分块 → 向量化 → 插入 的流程
这里doc_id的价值就体现出来了——它是连接源文档和向量数据库的桥梁。一个文档更新了,通过doc_id把它对应的所有chunk全部删掉,然后重新处理插入。
实际项目中一般这么做:
- 源文档变更时,触发一个异步任务
- 任务根据doc_id删除Milvus中该文档的所有旧chunk
- 对新文档重新执行 提取文本 → 分块 → 向量化 → 插入Milvus
- 整个过程对用户透明,检索服务不中断
Milvus的删除是逻辑删除,被删的数据不会立即从磁盘消失。如果频繁删除导致空间膨胀,可以调用compact接口触发物理清理。不过对于大多数RAG场景,文档更新频率不会太高,这个问题不太突出。
分区策略:什么时候该用Partition
前面提到Partition可以按业务维度切分数据,但具体什么时候该用、什么时候不该用,很多人拿不准。
几种常见的分区方式:
| 分区维度 | 举例 | 适合场景 |
|---|---|---|
| 按文档类别 | 入职指南、报销制度、假期政策 | 知识库有清晰的分类体系 |
| 按租户 | tenant_001、tenant_002 | 多租户SaaS系统 |
| 按时间段 | 2026_Q1、2026_Q2 | 数据有明显的时效性 |
但以下情况,用标量字段的filter过滤比Partition更合适:
- 分类维度的值特别多(上百个类别),Partition太多反而增加管理负担
- 查询经常需要跨多个分类
- 数据量不大(100万以内),filter的性能开销可以忽略
一个经验法则:如果某个过滤条件在90%以上的查询中都会用到,而且值的种类不超过几十个,用Partition;否则filter就够了。
性能优化Tips
索引选择
向量数据库通常支持多种索引类型:
| 索引类型 | 特点 | 适合场景 |
|---|---|---|
| FLAT | 暴力搜索,100%准确 | 小数据量(10万以下) |
| IVF_FLAT | 聚类分区+簇内精确搜索 | 中等数据量,内存有限 |
| IVF_SQ8 | 聚类+标量量化,向量压缩到1/4 | 大数据量,愿意牺牲一点精度换内存 |
| HNSW | 多层图索引,速度和精度都好 | 大部分场景的首选 |
| DISKANN | 索引放磁盘 | 超大数据量,内存放不下HNSW |
大部分场景选HNSW就对了。只有数据量极小(10万以下)用FLAT,或者数据量极大(5000万以上)且内存不够时才考虑DISKANN或IVF_PQ。
HNSW(分层可导航小世界图)是绝大多数场景的首选:查询速度快、准确率高、内存消耗合理。只有在极小数据量(少于10万)且对准确率要求100%时才用FLAT,或在超大数据量(超过千万级)且内存受限时考虑DiskANN。
HNSW关键调参
如果你用的是HNSW索引,有三个参数直接影响性能表现:
| 参数 | 干什么的 | 推荐范围 | 调大了会怎样 | 调小了会怎样 |
|---|---|---|---|---|
| M | 每个向量在图中的最大连接数 | 8~32,一般16 | 召回率提高,但内存涨、建索引变慢 | 省内存,但召回率可能下降 |
| efConstruction | 建索引时的搜索宽度 | 128~512,一般256 | 索引质量更好,但建索引更慢 | 建索引快,但质量可能打折 |
| ef | 检索时的搜索宽度 | topK的4~16倍 | 召回率更高,但检索变慢 | 检索快,但可能漏掉相关结果 |
实际调参思路:先用默认值(M=16,efConstruction=256,ef=topK×8)跑起来,然后根据实际的召回率和延迟表现微调。大多数情况下默认值就够用了,不用过度纠结。
IVF关键调参
如果因为内存限制选了IVF系列索引,关注这两个参数:
| 参数 | 干什么的 | 推荐值 | 调大了 | 调小了 |
|---|---|---|---|---|
| nlist | 聚类的簇数量 | 数据量的平方根(100万数据用1024) | 每个簇更小,检索更快,但训练更慢 | 每个簇更大,检索更慢 |
| nprobe | 检索时搜索几个簇 | nlist的5%~10% | 召回率提高,检索变慢 | 检索快,但可能漏结果 |
批量操作
单条插入太慢,改成批量:
// 不推荐:单条插入
for (Document doc : documents) {
vectorStore.add(List.of(doc));
}
// 推荐:批量插入
vectorStore.add(documents); // 一次性插入整个列表
合理设置TopK
TopK不是越大越好:
- 太小:可能漏掉相关内容
- 太大:增加计算量,而且太多内容塞给大模型反而会干扰
一般5-10就够了,最多不超过20。
很多人以为TopK越大结果越好,实际上超过一定值后反而变差。原因:多余的候选内容会作为噪音传给大模型,分散模型的注意力。研究表明大模型对"迷失在中间"的信息关注度低。建议TopK从5开始,配合测试集调整,不要随意设很大。
相似度阈值
设置一个合理的阈值,过滤掉不相关的结果:
.similarityThreshold(0.6) // 相似度低于0.6的不返回
阈值设多少看场景,可以通过测试数据来调。
向量维度与内存估算
向量维度越高,检索越慢、内存占用越大。如果你的Embedding模型支持多种维度输出(比如有些模型支持512~4096),在精度够用的前提下可以选较低的维度,能省不少资源。
粗略估算一下内存占用:100万个1536维向量,用HNSW索引(M=16),大约需要8~10GB内存。如果换成4096维,内存直接翻到20GB以上。服务器内存有限的话,这个数字值得提前算一算。
其他几个通用建议:
- 批量插入比逐条插入快得多,建议每批1000~5000条
- 如果数据量很大但查询QPS不高,可以考虑DISKANN把索引放磁盘
- 定期关注检索延迟和召回率,Milvus的Attu管理界面可以看到基本的性能指标
- 标量字段如果经常用于过滤,记得给它建索引(TRIE适合字符串等值匹配,STL_SORT适合数值范围查询)
Milvus实战:从零跑通完整流程
让我们使用 Milvus 来完成一个完整的流程,涵盖从创建 Collection、插入数据、建立索引到执行检索的全过程。
项目中使用 真正落地的 Spring AI 架构。这样更符合现在 Java 项目的常见写法,也方便你直接把示例搬到自己的 RAG 工程里。
虽然代码风格变了,但流程本质没变,如果不借助 Spring AI 框架,传统的 Milvus 使用,需要以下这四步:
- 创建 Collection 和 Schema
- 插入文档块并向量化
- 创建索引并加载 Collection 到内存
- 执行向量检索和带过滤的混合检索
只不过在 Spring AI 方案里,第 1、3 步很多工作是框架自动帮你做的。
对应项目位置
- 项目地址:https://gitee.com/shining-stars-l/super-ai-hub
- 模块路径:
ai-example/ai-example-rag/ai-example-spring-ai-rag-milvus
部署方式
Milvus Standalone是单机版,适合开发和中小规模场景。它依赖对象存储(存索引文件)和etcd(存元数据)。
# docker-compose.yml
name: milvus-stack
services:
etcd:
container_name: etcd
image: quay.io/coreos/etcd:v3.5.18
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls=http://0.0.0.0:2379 --data-dir=/etcd
volumes:
- etcd-data:/etcd
minio:
container_name: minio
image: minio/minio:RELEASE.2024-01-01T16-36-33Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
command: minio server /data --console-address ":9001"
ports:
- "9001:9001"
volumes:
- minio-data:/data
standalone:
container_name: milvus-standalone
image: milvusdb/milvus:v2.6.6
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- milvus-data:/var/lib/milvus
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- etcd
- minio
attu:
container_name: milvus-attu
image: zilliz/attu:v2.6.3
environment:
MILVUS_URL: milvus-standalone:19530
ports:
- "8000:3000"
depends_on:
- standalone
volumes:
etcd-data:
minio-data:
milvus-data:
执行 docker compose up -d 后,访问 http://localhost:8000 可以打开Attu管理界面。
不需要填写账户和密码,直接登录就行
Maven依赖
<dependencies>
<!-- Spring AI OpenAI(这里通过 SiliconFlow 走 OpenAI 兼容协议) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- Spring AI Milvus VectorStore Starter -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-milvus</artifactId>
</dependency>
<!-- 仅用于控制层入参校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- 其他依赖省略 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
这一节我们直接用 Spring AI + Milvus 来跑通完整流程。并且把真正影响效果和性能的参数配置也讲解清楚,把初始化、入库、检索、过滤这些步骤放回到一个更贴近实际项目的代码结构里。
Spring AI配置
项目中的核心配置如下:
spring:
ai:
openai:
base-url: https://api.siliconflow.cn
api-key: ${SILICONFLOW_API_KEY}
embedding:
options:
model: Qwen/Qwen3-Embedding-8B
dimensions: 4096
vectorstore:
milvus:
initialize-schema: true
database-name: default
collection-name: knowledge_chunks
embedding-dimension: 4096
index-type: HNSW
metric-type: COSINE
index-parameters: '{"M":16,"efConstruction":256}'
auto-id: false
id-field-name: id
content-field-name: content
metadata-field-name: metadata
embedding-field-name: embedding
client:
uri: http://localhost:19530
app:
milvus:
demo:
top-k: 5
similarity-threshold: 0.15
default-ef: 64
关键参数速览
如果你第一次用 Spring AI 接 Milvus,先把下面这 5 个参数看明白,基本就抓住了这个示例最核心的配置思路。
| 参数 | 当前值 | 它控制什么 | 为什么这样配 |
|---|---|---|---|
embedding.options.dimensions | 4096 | Embedding 模型输出的向量维度 | 必须和 Milvus 向量字段维度一致,否则写入报错 |
embedding-dimension | 4096 | Milvus 向量字段的维度定义 | 和模型输出保持一致,当前使用 Qwen3-Embedding-8B |
index-type | HNSW | 向量索引类型 | 检索速度和召回率比较均衡,适合大多数 RAG 场景 |
metric-type | COSINE | 相似度度量方式 | 文本语义检索通常优先使用余弦相似度 |
index-parameters | {"M":16,"efConstruction":256} | HNSW 建索引参数 | 是一个很常见、比较稳妥的默认组合 |
default-ef | 64 | 检索阶段的搜索宽度 | 在召回率和查询延迟之间做平衡 |
关键参数解读
1. embedding.options.dimensions
这个参数控制的是:Embedding 模型把文本转成向量之后,最终会输出多少维的浮点数数组。
可以把它理解成“文本在向量空间里被展开成多长的一串数字”。这个长度不是随便填的,而是由模型本身决定的。当前示例使用的是 Qwen/Qwen3-Embedding-8B,它输出 4096 维,所以这里必须配置成 4096。
这个参数为什么重要?
- 它直接决定了后面写入 Milvus 的向量长度
- 它必须和 Milvus 的向量字段维度一模一样
- 只要两边不一致,写入时就会直接报错,而不是“自动帮你兼容”
最常见的错误就是:模型还是 4096 维,但数据库表结构还是旧的 1024 维。结果应用一启动,前面配置看起来都没问题,一到真正写向量的时候就失败。
2. embedding-dimension
这个参数控制的是:Milvus 中向量字段本身的维度定义。
如果说上面的 embedding.options.dimensions 决定的是“模型会产出什么格式的向量”,那这里决定的就是“Milvus 这张表准备接收什么格式的向量”。两边是一对,必须严格一致。
为什么文档里要把这两个参数分开讲?
- 因为它们看起来很像,实际职责却不一样
- 一个属于模型侧
- 一个属于向量库侧
- 只有模型输出和数据库字段同时对齐,整条链路才是通的
所以在排查维度问题时,不要只盯着模型名看,也一定要检查 Milvus 的这一项配置。
3. index-type
这个参数控制的是:Milvus 用什么索引结构来做向量检索。
当前配置的是 HNSW。如果小伙伴是第一次做 RAG 项目的话,可以把它理解成“默认优先选它,通常不会出大问题”的那种配置。原因主要有三个:
- 检索速度快,适合交互式问答
- 召回率高,通常能把真正相关的 chunk 找回来
- 工程上很成熟,已经被大量文本检索场景验证过
当然,HNSW 也不是没有代价。它的典型代价是内存占用会比较高,尤其是在向量维度高、数据量大的时候更明显。但如果现在是在做一个普通的 RAG 示例,或者是百万级以内的数据量,它依然是很自然的第一选择。
4. metric-type
这个参数控制的是:Milvus 用什么方式衡量两个向量到底有多相似。
这里配置成 COSINE,也就是余弦相似度。文本语义检索里,这通常是最符合直觉的一种选择。
为什么这么说?
- 文本向量更关心“方向是否接近”,也就是语义是否接近
- 余弦相似度天然适合衡量这种“方向上的接近”
- 对大模型生成的文本向量来说,它往往比单纯比较数值大小更稳定
所以如果你的场景是文档问答、知识库检索、FAQ 召回这类典型文本 RAG,COSINE 往往就是最先尝试、也最容易跑通的一种配置。
5. index-parameters
这个参数控制的是:HNSW 在建索引时的关键行为。当前示例里配置的是:
index-parameters: '{"M":16,"efConstruction":256}'
这两个值要分开理解:
M可以理解成“每个向量节点在图里最多保留多少条连接”。这个值越大,图通常越密,检索时更容易找到真正相近的邻居,但索引更占内存。efConstruction可以理解成“建索引时愿意多看多少候选邻居再做决定”。值越大,通常索引质量越高,但建索引时间会更长。
为什么示例里用 M=16、efConstruction=256?
- 这是一个很常见的工程默认组合
- 对文本检索来说,通常已经够稳
- 不会过于保守,也不会激进到一上来就把资源吃得很重
如果小伙伴后面遇到“召回率不够”或者“建索引太慢”的问题,调参通常也是从这两个参数开始。
6. default-ef
这个参数控制的是:HNSW 在真正执行查询时,要不要多看一些候选节点再决定最终结果。
和 efConstruction 不同,efConstruction 是建索引时用的,default-ef 是检索时用的。一个影响“索引怎么建”,一个影响“查询怎么搜”,不要混淆。
这个参数怎么影响结果?
- 值小一些:检索更快,但可能漏掉一些本来应该召回的结果
- 值大一些:召回通常更稳,但查询耗时会增加
工程上一般会把它设置成 topK 的 4~16 倍。当前 demo 默认给的是 64,属于一个比较稳妥、比较适合起步的值。
如果在检索时,发现发现“结果大致对,但偶尔漏召回”,ef 往往就是最值得先试着调大的一个参数。
这里还有一个很关键的工程化选择:我们把 auto-id 设成了 false。
这么做的原因不是“自动主键不好”,而是这个 demo 更贴近真实项目。
如果让 Milvus 自动生成主键,最小示例当然也能跑通,但你后面做文档更新、chunk 删除、数据重建时会比较麻烦。
在真实项目里,通常更希望 chunk 有稳定 ID,这样你后面才能更自然地做这些操作:
- 按 chunkId 删除
- 按 docId 做整篇文档重建
- 做“先删后插”的增量更新
所以这个 Spring AI demo 选择的是显式 chunkId,而不是让 Milvus 自动生成一个只在数据库内部可见的主键值。
创建Collection和Schema
在这个 Spring AI 项目里,只要配置了 initialize-schema: true,框架启动时就会自动完成下面这三件事:
- 创建 Collection
- 创建向量索引
- 加载 Collection 到内存
也就是说,Collection、Schema、索引和加载动作,并不是“没有了”,而是被收口到了 MilvusVectorStore 的初始化阶段。
项目里显式触发这套流程的代码在这里:
public void recreateCollection() {
if (collectionExists()) {
milvusClient.releaseCollection(ReleaseCollectionParam.newBuilder()
.withDatabaseName(vectorStoreProperties.getDatabaseName())
.withCollectionName(vectorStoreProperties.getCollectionName())
.build());
milvusClient.dropCollection(DropCollectionParam.newBuilder()
.withDatabaseName(vectorStoreProperties.getDatabaseName())
.withCollectionName(vectorStoreProperties.getCollectionName())
.build());
}
// 按当前配置重新初始化 Milvus:
// 创建 Collection、创建索引,并把 Collection 加载到内存。
milvusVectorStore.afterPropertiesSet();
}
如果想手动重建 Collection,项目也提供了接口来直接调用:
POST /milvus/demo/recreate
Spring AI帮你生成了什么Schema
虽然这里没有手写 Schema 定义,但底层字段依然是真实存在的。按照当前配置,Spring AI 会帮我们创建出一套适合知识库检索的结构:
id主键字段。当前项目因为auto-id: false,这里使用的就是业务侧传入的 chunkId。content文本字段,用来保存切分后的 chunk 内容,也是最后返回给大模型的上下文原文。metadataJSON 字段,用来保存docId、category、source、scene、department这类业务元数据。embedding向量字段,维度固定为 4096,真正执行相似度搜索的就是这个字段。
这个结构有几个很实用的特点:
- Java 端代码更简洁
- 扩展 metadata 更方便,后续新增业务字段时不需要频繁调整表结构
filterExpression可以直接围绕 metadata 写过滤条件,更适合真实业务里的分类、租户、文档范围过滤
如果你后面通过 /milvus/status 查看 Collection 描述信息,也能看到这些字段已经被真正创建出来,而不是停留在框架抽象层里。
插入数据
在这个示例里,入库的核心对象是 Spring AI 的 Document。当我们调用 vectorStore.add(List<Document>) 时,框架会自动帮我们完成三件事:
- 调用 Embedding 模型,把文本转成 4096 维向量
- 按配置把
id / content / metadata / embedding映射到 Milvus 的字段结构里 - 以批量方式把数据写入 Collection
项目里的入库代码是这样的:
public int importChunks(List<MilvusChunkRequest> requests) {
if (CollectionUtils.isEmpty(requests)) {
return 0;
}
List<Document> documents = requests.stream()
.map(this::toDocument)
.toList();
vectorStore.add(documents);
return documents.size();
}
private Document toDocument(MilvusChunkRequest request) {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("docId", request.docId());
metadata.put("category", request.category());
metadata.put("source", request.source());
return Document.builder()
.id(request.id())
.text(request.content())
.metadata(metadata)
.build();
}
- 这里的“批量”不是语义上的概念,而是真正会把一批
Document一起送入向量化和写入流程,性能会比逐条处理稳定得多。 - 向量字段并不需要你在业务代码里手动拼出来,因为 Spring AI 会先调用 Embedding 模型,再把生成好的向量写进
embedding字段。 - 向量维度依然必须严格一致。如果 Embedding 输出是 4096 维,而 Collection 的
embedding-dimension不是 4096,插入时照样会直接失败。 - 真实项目里也应该尽量保持批量导入思路,比如按文档、按 chunk 批次或者按任务批次统一写入,不建议把每个 chunk 都拆成一次单独调用。
项目里还预置了几条演示数据,启动后会自动导入:
public int importDemoDocuments() {
List<MilvusChunkRequest> demoChunks = List.of(
new MilvusChunkRequest(
"chunk_hr_001_01",
"员工入职需要准备身份证原件、学历证书、离职证明、银行卡复印件和一寸照片...",
"hr_001",
"入职指南",
"员工手册V1",
Map.of("scene", "onboarding", "department", "HR")
),
new MilvusChunkRequest(
"chunk_finance_001_01",
"报销流程是填写报销单后提交部门主管审批,再由财务审核并在每周四统一打款...",
"finance_001",
"报销制度",
"财务制度V3",
Map.of("scene", "expense-reimbursement", "department", "Finance")
)
);
return importChunks(demoChunks);
}
创建索引
spring:
ai:
vectorstore:
milvus:
index-type: HNSW
metric-type: COSINE
index-parameters: '{"M":16,"efConstruction":256}'
HNSW大多数 RAG 场景的默认首选,速度和召回率都比较均衡。COSINE文本向量常用的相似度度量方式。M = 16每个节点的最大连接数。16 是一个非常常见的工程默认值。efConstruction = 256建索引时的搜索宽度。越大索引质量越高,但构建越慢。
这三个配置虽然都写在 application.yaml 里,但它们控制的是 Milvus 真正的索引行为,而不是“只是给 Spring Boot 看看的配置项”。
index-type决定索引用哪种结构来组织向量数据metric-type决定查询时按什么规则衡量向量之间的相似程度index-parameters决定 HNSW 这套索引结构在构建阶段的精细行为
所以当你发现“召回率不够”“构建太慢”“内存占用偏高”时,优先排查和微调的通常就是这三块。
加载Collection到内存(检索前必须加载)
Milvus 有一个非常关键点:Milvus 检索是在内存中完成的,所以 Collection 必须先 load,才能检索。
在这个 Spring AI 项目里,这一步不是省略了,而是被自动纳入初始化流程。
只要添加以下的配置:
spring.ai.vectorstore.milvus.initialize-schema: true
Spring AI 在初始化 MilvusVectorStore 时,就会按顺序执行:
createCollectioncreateIndexloadCollection
项目里这个过程是通过下面这行代码触发的:
milvusVectorStore.afterPropertiesSet();
这一步非常重要,因为只有 Collection 真正完成加载之后,后面的相似度搜索才能稳定执行。
项目中提供了接口,可以让你确认它是否真的已经加载好了:
GET /milvus/status
返回结果:
{
"uri": "http://localhost:19530",
"databaseName": "default",
"collectionName": "knowledge_chunks",
"exists": true,
"loadState": "LoadStateLoaded",
"statistics": {
"row_count": "0"
},
"indexes": [
{
"fieldName": "embedding",
"indexName": "embedding",
"state": "Finished",
"indexedRows": 0,
"totalRows": 0,
"params": {
"metric_type": "COSINE",
"params": "{\"M\":16,\"efConstruction\":256}",
"index_type": "HNSW"
}
}
],
"collectionSummary": "status {\n}\nschema {\n name: \"knowledge_chunks\"\n description: \"Spring AI Vector Store\"\n fields {\n fieldID: 100\n name: \"id\"\n is_primary_key: true\n data_type: VarChar\n type_params {\n key: \"max_length\"\n value: \"36\"\n }\n }\n fields {\n fieldID: 101\n name: \"content\"\n data_type: VarChar\n type_params {\n key: \"max_length\"\n value: \"65535\"\n }\n }\n fields {\n fieldID: 102\n name: \"metadata\"\n data_type: JSON\n }\n fields {\n fieldID: 103\n name: \"embedding\"\n data_type: FloatVector\n type_params {\n key: \"dim\"\n value: \"4096\"\n }\n }\n properties {\n key: \"timezone\"\n value: \"UTC\"\n }\n}\ncollectionID: 465013017088040253\nvirtual_channel_names: \"by-dev-rootcoord-dml_0_465013017088040253v0\"\nvirtual_channel_names: \"by-dev-rootcoord-dml_1_465013017088040253v1\"\nphysical_channel_names: \"by-dev-rootcoord-dml_0\"\nphysical_channel_names: \"by-dev-rootcoord-dml_1\"\ncreated_timestamp: 465015588871733269\ncreated_utc_timestamp: 1773893695342\nshards_num: 2\ncollection_name: \"knowledge_chunks\"\nproperties {\n key: \"timezone\"\n value: \"UTC\"\n}\ndb_name: \"default\"\nnum_partitions: 1\nupdate_timestamp: 465015588871733269\n19: \"465015588871733269\"\n"
}
- Collection 是否存在
loadState- 当前索引状态
- Collection 统计信息
执行向量检索
现在看真正的检索代码。Spring AI 版本的搜索实现如下:
public List<MilvusSearchResult> search(String query,
Integer topK,
String category,
String docId,
Double similarityThreshold,
Integer ef) {
MilvusSearchRequest.MilvusBuilder builder = MilvusSearchRequest.milvusBuilder()
.query(query)
.topK(normalizeTopK(topK))
.similarityThreshold(normalizeSimilarityThreshold(similarityThreshold))
.searchParamsJson("{\"ef\":" + normalizeEf(ef) + "}");
Filter.Expression filterExpression = buildFilterExpression(category, docId);
if (filterExpression != null) {
builder.filterExpression(filterExpression);
}
return vectorStore.similaritySearch(builder.build())
.stream()
.map(this::toSearchResult)
.toList();
}
检索参数作用
-
topK表示这次查询最多返回多少条最相似结果。RAG 里一般取 3~10。当前项目里的代码是:
.topK(normalizeTopK(topK))如果接口里没有显式传
topK,就会回退到配置里的默认值:app:
milvus:
demo:
top-k: 5这个参数本质上决定的是:召回阶段到底拿回多少个候选 chunk,留给后面的生成链路继续使用。取太少容易漏掉关键信息,取太多又会挤占大模型上下文。
-
searchParamsJson("{\"ef\":64}")这里传入的是 Milvus 检索阶段的附加参数,当前最关键的就是 HNSW 的ef。当前项目里的代码是:
.searchParamsJson("{\"ef\":" + normalizeEf(ef) + "}")ef控制的是“检索时愿意额外展开多大的候选范围”。它越大,通常召回率越高,但查询耗时也会增加。 -
similarityThreshold用来截断过低相关度的结果。它的作用不是“帮你找到更多结果”,而是“帮你挡掉明显不相关的结果”。比如当前默认值是
0.15,当某些候选 chunk 与查询语义关联非常弱时,它们会在结果阶段被过滤掉,避免把噪声内容继续传给后面的生成模型。 -
query(query)这里传的是自然语言文本,不是手工构造的向量。Spring AI 会自动完成“查询文本向量化 -> Milvus 检索 -> 返回 Document”的整条链路。
关于 ef,这里也保留一下原文里最关键的经验值:
ef越大,召回率越高,但检索会更慢- 工程上通常设为
topK的 4~16 倍 - 当前 demo 默认给的是
64,是一个偏稳妥的值
检索结果里会返回什么
在这个里,一次 similaritySearch(...) 返回的并不是“只有分数的裸结果”,而是完整的 Document 对象。框架会把检索到的核心信息一起带回来:
- 文档主键
id - 文本内容
content - 元数据
metadata
然后项目代码再把它整理成更适合接口返回的结构。当前项目里的处理代码是:
private MilvusSearchResult toSearchResult(Document document) {
// Spring AI 会把文档主键、文本内容、相似度分数和 metadata 一起返回,
// 我们再从 metadata 中拆出 docId/category,整理成更适合接口返回的结构。
Map<String, Object> metadata = new LinkedHashMap<>(document.getMetadata());
return new MilvusSearchResult(
document.getId(),
document.getText(),
document.getScore(),
valueOf(metadata.get("docId")),
valueOf(metadata.get("category")),
metadata
);
}
最终接口层返回的是:
idcontentscoredocIdcategorymetadata
这样做的好处是,业务接口既能直接拿到最关键的字段,也保留了完整 metadata,后面如果要扩展 source、department、scene 之类的返回信息,不需要重写检索主流程。
embedding-field-name 控制的是什么
在当前 Spring AI 项目里,真正执行向量相似度检索的字段名,是通过配置统一指定的:
spring:
ai:
vectorstore:
milvus:
embedding-field-name: embedding
它的含义是:
- 当前 Collection 中真正执行相似度检索的向量字段名叫
embedding - Spring AI 在执行
vectorStore.similaritySearch(...)时,会自动使用这个字段
这个参数看起来不显眼,但它决定了“查询向量到底落在哪个字段上比对”。如果你的 Collection 里后面扩展出了多个向量字段,比如标题向量、正文向量、多模态向量,这个字段名就必须和真正要检索的那个向量列保持一致。
举个例子,直接调用接口:
GET /milvus/search?query=报销流程怎么走&topK=3&ef=64
返回结果:
[
{
"id": "chunk_finance_001_01",
"content": "报销流程是填写报销单后提交部门主管审批,再由财务审核并在每周四统一打款,发票抬头必须与公司名称完全一致。",
"score": 0.7760472297668457,
"docId": "finance_001",
"category": "报销制度",
"metadata": {
"source": "财务制度V3",
"category": "报销制度",
"department": "Finance",
"distance": 0.2239527702331543,
"docId": "finance_001",
"scene": "expense-reimbursement"
}
},
{
"id": "chunk_hr_001_01",
"content": "员工入职需要准备身份证原件、学历证书、离职证明、银行卡复印件和一寸照片,所有材料需在报到当天提交人力资源部。",
"score": 0.492956280708313,
"docId": "hr_001",
"category": "入职指南",
"metadata": {
"source": "员工手册V1",
"category": "入职指南",
"department": "HR",
"distance": 0.507043719291687,
"docId": "hr_001",
"scene": "onboarding"
}
},
{
"id": "chunk_hr_001_02",
"content": "年假按工龄计算:1到10年每年5天,10到20年每年10天,20年以上每年15天,未休年假按公司制度统一结转。",
"score": 0.4834533631801605,
"docId": "hr_001",
"category": "假期政策",
"metadata": {
"source": "员工手册V1",
"category": "假期政策",
"department": "HR",
"distance": 0.5165466070175171,
"docId": "hr_001",
"scene": "leave-policy"
}
}
]
结合元数据过滤的混合检索
这里的“混合检索”指的不是稀疏向量 + 稠密向量融合,而是向量相似度检索 + 标量过滤条件一起使用。
这在 RAG 场景里特别常见。比如:
- 只查某个分类下的知识
- 只查某个文档下的 chunk
- 查某个部门、某个租户、某个业务线的数据
项目里的过滤逻辑是这样写的:
private Filter.Expression buildFilterExpression(String category, String docId) {
FilterExpressionBuilder builder = new FilterExpressionBuilder();
FilterExpressionBuilder.Op filter = null;
if (StringUtils.hasText(category)) {
filter = builder.eq("category", category);
}
if (StringUtils.hasText(docId)) {
FilterExpressionBuilder.Op docFilter = builder.eq("docId", docId);
filter = (filter == null) ? docFilter : builder.and(filter, docFilter);
}
return filter == null ? null : filter.build();
}
这段代码最后会被 Spring AI 转换成 Milvus 可执行的 filter 表达式,大致等价于:
metadata["category"] == "报销制度"metadata["docId"] == "finance_001"metadata["category"] == "入职指南" && metadata["docId"] == "hr_001"
直接调用接口可以这样写:
GET /milvus/search?query=报销单怎么提&category=报销制度
GET /milvus/search?query=年假怎么算&docId=hr_001
GET /milvus/search?query=入职材料有哪些&category=入职指南&docId=hr_001
这部分的核心价值就在于:
- 纯向量检索擅长语义理解
- 标量过滤擅长业务范围收缩
- 两者结合起来,检索结果通常比“只做向量 TopK”更贴近实际业务需求
以这个接口为例:
GET /milvus/search?query=报销单怎么提&category=报销制度
返回结果:
[
{
"id": "chunk_finance_001_01",
"content": "报销流程是填写报销单后提交部门主管审批,再由财务审核并在每周四统一打款,发票抬头必须与公司名称完全一致。",
"score": 0.765618085861206,
"docId": "finance_001",
"category": "报销制度",
"metadata": {
"source": "财务制度V3",
"category": "报销制度",
"department": "Finance",
"distance": 0.23438191413879395,
"docId": "finance_001",
"scene": "expense-reimbursement"
}
}
]
一个完整的启动和验证顺序
如果按项目代码真正跑起来,可以按这个顺序走:
- 启动 Milvus:
docker compose -f src/main/resources/docker-compose.yml up -d - 配置环境变量:
export SILICONFLOW_API_KEY=你的Key - 启动项目模块:
启动类:
org.javaup.ai.ExampleSpringAiRagMilvusApplication - 查看 Collection 是否创建成功:
GET /milvus/status - 如需重建并重新导入演示数据:
POST /milvus/demo/reset-and-load - 执行检索:
GET /milvus/search?query=报销流程怎么走
这一版真正要掌握的几个入口
如果你打算把这套示例迁移到自己的知识库项目里,真正需要优先掌握的是下面这几个入口:
- Embedding 模型配置
- Milvus Collection 和索引配置
- 文档块结构设计
- metadata 过滤逻辑
- 重建、导入、检索这些运维和调试接口
把这几个入口真正吃透之后,就已经能够独立完成一套 Spring AI + Milvus 的 RAG 基础链路了。
后面再往上叠加重排、混合检索、权限隔离、多租户或者检索评估,都是在这条主链路之上继续演进的。
小结
这篇文章讲了向量数据库的选型:
- PGVector:最适合已有PostgreSQL的中小型项目
- Milvus:大规模、高性能场景的首选,Java生态友好
- Qdrant:性能与易用的平衡之选
- Chroma:快速验证、学习用途
- Elasticsearch:已有ES、需要混合检索
选型的核心原则:根据数据规模、团队能力、已有技术栈来决定,没有银弹。