【AI】向量数据库的原理与选型详解

AI3天前发布 beixibaobao
10 0 0

在这里插入图片描述

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

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

🤞先做到 再看见!


目录

    • 向量存到哪里:为什么普通数据库不够用
      • 1. 最直觉的方案:用 MySQL 存向量
      • 2. 暴力搜索的性能瓶颈
      • 3. 近似最近邻搜索
    • 向量检索的核心算法:如何避免逐个比较
      • 1. 先从一个直观类比开始
      • 2. IVF:先分区,再在局部搜索
        • 2.1 IVF 的工作原理
        • 2.2 IVF 的优缺点
      • 3. HNSW:当前最主流的索引方案
        • 3.1 HNSW 的核心思想
        • 3.2 一个简化示例
        • 3.3 为什么 HNSW 很快
        • 3.4 HNSW 的代价:内存占用更高
      • 4. 索引算法怎么选
    • 主流向量数据库对比与选型
      • 1. 两大类方案
        • 1.1 专用向量数据库
        • 1.2 传统数据库的向量扩展
      • 2. 什么时候选哪一类
      • 3. 主流方案对比
      • 4. 为什么本文选择 Milvus
    • Milvus 核心概念:借助传统数据库做类比
      • 1. Collection = 表
      • 2. Schema = 表结构
        • 2.1 向量字段与标量字段的区别
      • 3. Index = 索引
      • 4. Partition = 分区
      • 5. 概念对照表
    • 动手实践:用 Docker 启动 Milvus 并跑通完整流程
      • 1. 用 Docker 启动 Milvus Standalone
      • 2. Maven 依赖配置
      • 3. 创建 collection 和 schema
      • 4. 插入向量数据
      • 5. 创建索引
      • 6. 执行向量检索
      • 7. 结合元数据过滤的混合检索
      • 8. 运行结果应该如何理解
    • 实际项目中的关键决策
      • 1. 索引类型怎么选
      • 2. 相似度度量怎么选
      • 3. 分区策略怎么设计
      • 4. 数据更新策略
      • 5. 性能调优的关键参数
        • 5.1 HNSW 参数
        • 5.2 IVF 参数
        • 5.3 通用建议
    • 小结

上一篇已经把文本 chunk 转成了一组浮点数向量,并通过标准的 Embedding API 跑通了一个完整的向量化检索 demo。接下来真正会落到工程实践里的问题是:向量生成之后应该存到哪里?

在前面的示例中,所有向量都只是放在一个 List<float[]> 里,查询时遍历整个列表,逐个计算余弦相似度,再取最相近的几个结果。这个做法在 demo 阶段没有问题,因为数据量很小。

但在真实场景里,情况完全不同。假设这是一个在线教育平台的课程咨询知识库,里面包含课程介绍、试听规则、预约流程、退款说明、讲师 FAQ、学习路径说明等内容。文档完成分块后,很容易就会形成几十万甚至上百万个 chunk,每个 chunk 再对应一个 4096 维向量。此时如果每次用户提问,都让查询向量和这几十万个向量逐一比较,系统延迟会迅速失控。

这正是向量数据库存在的原因。

本文围绕两个核心问题展开:

  • 向量应该存在哪里
  • 如何在海量向量中完成高效检索

向量存到哪里:为什么普通数据库不够用

1. 最直觉的方案:用 MySQL 存向量

向量本质上就是一组浮点数,因此最容易想到的方式,就是把它直接存进 MySQL。

做法很直接:在表中增加一个 TEXTJSON 字段,把向量序列化之后写进去;检索时再把所有向量读出,在应用层计算余弦相似度,最后排序取 Top-K。

CREATE TABLE chunk_vectors (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    chunk_text TEXT NOT NULL,
    vector JSON NOT NULL,
    doc_id VARCHAR(64),
    category VARCHAR(32),
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

这个方案能不能跑通?当然可以。对于原型阶段或者样本数据很少的场景,它完全能工作。

问题出在检索阶段。只要每次查询都要把所有向量读出来,并在内存中逐个计算相似度,本质上就是暴力搜索(Brute-Force Search)。

2. 暴力搜索的性能瓶颈

假设知识库中有 100 万个 chunk,每个向量是 4096 维。一次用户查询通常需要经历下面几个步骤:

  1. 把用户问题向量化,得到一个 4096 维查询向量。
  2. 从数据库读出 100 万个候选向量。
  3. 逐个计算查询向量与候选向量之间的余弦相似度。
  4. 排序并返回最相似的 Top-K 结果。

瓶颈显然出现在第 3 步。一次余弦相似度计算本身就包含大量乘加运算,而当候选集扩大到 100 万条时,整体计算量会非常可观。

【AI】向量数据库的原理与选型详解

在普通服务器上,单核 CPU 对 100 万个 4096 维向量做一次暴力检索,耗时大约会落在 2 到 5 秒之间。这个数字单看似乎还勉强可接受,但真正进入生产环境后,问题会迅速放大:

  • 这只是单次查询时延,并发一上来,系统会立即吃紧。
  • 数据规模不会停留在 100 万,继续增长到 500 万、1000 万是很常见的。
  • RAG 链路里不只有检索,还包括重写、过滤、重排、生成等步骤,检索一旦太慢,整体体验就会明显变差。
  • 每次都从磁盘读出全部向量,I/O 成本同样不可忽略。

因此,暴力搜索只适合小规模数据集。一旦数据量和并发都上来,它就不再是一个可以长期使用的方案。

3. 近似最近邻搜索

既然逐个比较太慢,一个自然的问题就是:能不能不比较所有向量,只比较其中一部分,但依然找到足够接近的结果?

答案是可以,这就是 ANN(Approximate Nearest Neighbor,近似最近邻搜索)的基本思想。

这里最关键的词是“近似”。ANN 不保证每次都找到全局最优解,但能在非常短的时间内找到与最优解非常接近的结果。

可以把它理解成一种“先缩小范围,再精确比较”的策略。相比于全量遍历,ANN 会先通过索引结构快速定位一小部分高概率相关区域,再在这个较小范围内做更细致的搜索。这样做可能会漏掉极少数边界样本,但换来的检索速度提升往往是数量级的。

典型对比如下:

指标 暴力搜索 ANN 检索
100 万向量查询耗时 2 到 5 秒 1 到 10 ms
召回率(Recall) 100% 95% 到 99%
是否需要专门索引
适用数据量 10 万以下更合适 百万到亿级

对于大多数 RAG 场景来说,召回率从 100% 降到 95% 到 99%,通常是可以接受的。因为系统最终并不是只依赖单条结果,而是会拿 Top-K 结果继续参与后续推理或重排。

所以,向量数据库的核心价值并不只是“存向量”,而是“通过 ANN 索引在海量向量中高效找到最相似的结果”。

一句话概括就是:

向量数据库 = 向量存储 + ANN 索引 + 高效检索。

向量检索的核心算法:如何避免逐个比较

知道了 ANN 的目标之后,接下来的问题是:它到底是怎么做到的?

这一节聚焦两类最常见的索引思路:IVF 和 HNSW。重点不是数学推导,而是理解它们的工程思想和取舍逻辑。

1. 先从一个直观类比开始

想象一本 10 万页的词典,要查一个单词。如果没有目录和索引,只能从第一页翻到最后一页,这相当于暴力搜索。

真正高效的查找方式一定是:

  1. 先找到首字母所在的大范围。
  2. 再根据更细的特征缩小区间。
  3. 最后只在一小段范围内做精确定位。

ANN 的做法与此类似。区别只在于:词典中的顺序结构变成了向量空间中的距离结构。

2. IVF:先分区,再在局部搜索

IVF(Inverted File Index,倒排文件索引)的核心思想很容易理解:先把向量空间划分成多个区域,查询时只搜索最可能相关的几个区域。

2.1 IVF 的工作原理

IVF 一般分为两个阶段。

建索引阶段:

  1. 使用聚类算法(通常是 K-Means)把所有向量划分为 nlist 个簇。
  2. 每个簇都有一个中心点,用来代表这批向量的大致位置。
  3. 每个向量都会被分配到距离自己最近的簇中。

检索阶段:

  1. 对查询向量先计算它与各个簇中心的距离。
  2. 选出最近的 nprobe 个簇。
  3. 只在这几个簇内部做更精确的相似度搜索。

如果把它类比到在线教育平台的知识库,可以理解为先把所有内容按语义大致分区,例如课程介绍、班主任规则、退款说明、试听安排、讲师答疑等。用户问“试听课怎么预约”时,系统并不会遍历所有内容,而是优先进入与试听和预约更接近的几个语义区域。

假设 nlist = 100,查询时设置 nprobe = 10,意味着系统只需要在大约 10% 的候选向量中继续搜索,计算量会大幅下降。

2.2 IVF 的优缺点
优点 缺点
原理直观,调优相对容易 需要先做聚类训练
内存占用相对较低 聚类边界附近的向量可能被漏召回
适合非常大的数据集 nlistnprobe 需要调参
支持增量插入 数据分布不均时效果可能下降

常见变体包括:

  • IVF_FLAT:簇内做精确搜索。
  • IVF_SQ8:簇内向量做标量量化压缩,降低内存。
  • IVF_PQ:使用乘积量化进一步压缩。

【AI】向量数据库的原理与选型详解

3. HNSW:当前最主流的索引方案

HNSW(Hierarchical Navigable Small World Graph,分层可导航小世界图)是当前工程实践里最常见的高性能 ANN 索引算法之一,很多向量数据库都会把它作为默认或推荐选项。

3.1 HNSW 的核心思想

HNSW 可以理解成一种分层图结构。

  • 最底层包含全部向量,并保存局部邻接关系。
  • 越往上的层,节点越少,但连接跨度越大。
  • 顶层节点很少,却能快速帮助搜索跳到目标的大致区域。

查询时,搜索从高层开始,先做粗粒度跳转,再逐层下降到更细的局部区域,最后在底层完成精确定位。

如果放在课程咨询场景里,可以类比成一个逐层缩小范围的问路过程:

  1. 先判断问题更像属于课程体系、预约流程还是退款政策。
  2. 再缩小到某个具体问题族,比如试听预约、正式报名、改期规则。
  3. 最后定位到最相关的知识片段。

这样做的核心优势是:每一步都只探索少量候选节点,但方向上不断逼近目标。

3.2 一个简化示例

假设向量库里有 8 个向量,HNSW 建了 3 层图。现在要查询与向量 Q 最接近的结果。

  • 在顶层,系统只会在少数几个代表节点之间比较,快速找到更接近 Q 的区域。
  • 进入中间层后,从上一步的结果继续沿着邻居扩展。
  • 到底层后,再在局部邻域内做更精细的比较。

整个过程只访问少量节点,而不是遍历全部候选项。当数据量从 8 扩展到 100 万时,这种差别就会变得非常明显。

3.3 为什么 HNSW 很快

HNSW 的速度优势主要来自两个方面。

第一,分层结构让搜索具备“先粗后细”的能力。高层负责快速跳转,底层负责局部精修。

第二,小世界图的连通性使得系统通常只需要经过较少的跳转,就能逼近目标向量。这意味着即使数据规模很大,也不需要做全量遍历。

3.4 HNSW 的代价:内存占用更高

HNSW 的缺点同样很明确:它需要把图结构维护在内存里,因此除了存原始向量,还要存各层节点之间的连接关系。

它的几个关键参数如下:

参数 含义 调大后的效果 调小后的效果
M 每个向量在每层的最大连接数 召回率更高,但内存更大,建索引更慢 内存更省,但召回率可能下降
ef_construction 建索引时的搜索宽度 索引质量更高,但构建更慢 构建更快,但索引质量可能下降
ef 查询时的搜索宽度 召回率更高,但查询更慢 查询更快,但召回率下降

一个粗略经验值是:100 万个 4096 维向量,如果使用 HNSW 且 M = 16,内存开销可能达到 16 GB 到 20 GB。资源紧张时,就需要认真评估 IVF 系列或磁盘型索引。

【AI】向量数据库的原理与选型详解

4. 索引算法怎么选

以下是常见索引类型的工程对比:

索引类型 核心思想 检索速度 召回率 内存占用 适用数据量 适用场景
FLAT 暴力搜索,不建索引 最慢 100% 10 万以下 小数据集且极度看重精确性
IVF_FLAT 聚类分区 + 簇内精确搜索 95% 到 99% 较低 百万到千万 数据量大,内存有限
IVF_SQ8 聚类分区 + 标量量化压缩 93% 到 97% 千万到亿级 需要压缩内存
HNSW 分层图结构 很快 97% 到 99.5% 百万到千万 兼顾速度和召回率
DISKANN 基于磁盘的图索引 较快 95% 到 98% 亿级 数据量极大,内存受限

如果只给一个实用决策路径,可以这样理解:

  • 数据量小于 10 万:直接用 FLAT
  • 数据量在 10 万到 500 万:内存够就优先 HNSW,内存紧张就考虑 IVF_FLAT
  • 数据量在 500 万到 5000 万:优先判断 HNSW 是否放得下,放不下就转 IVF_SQ8
  • 数据量继续扩大:优先考虑 DISKANNIVF_PQ

对绝大多数课程知识库或企业级 RAG 项目来说,百万级数据规模下,HNSW 通常会是默认首选。

主流向量数据库对比与选型

理解了索引算法之后,接下来的问题就是:到底选哪个向量数据库?

1. 两大类方案

1.1 专用向量数据库

这类数据库从设计之初就把向量检索当作核心能力,例如 Milvus、Qdrant、Weaviate、Pinecone、Chroma。

它们的共同特点是:

  • 原生支持多种 ANN 索引。
  • 针对向量检索做了专项优化。
  • 通常支持标量过滤,便于做“向量检索 + 元数据过滤”的混合查询。
1.2 传统数据库的向量扩展

另一类方案是在现有数据库基础上增加向量能力,例如 PostgreSQL 的 pgvector,以及部分支持 kNN 搜索的检索引擎。

这类方案最大的优势是基础设施更少,尤其适合原本已经深度使用 PostgreSQL 的团队。它的代价通常是:当数据规模继续扩大时,索引能力、检索性能和专项优化空间往往不如专用向量数据库。

2. 什么时候选哪一类

可以用一个简单判断标准:

  • 如果向量数据量小于 50 万,而且系统本来就在使用 PostgreSQL,那么 pgvector 往往已经够用。
  • 如果数据量超过 50 万,或者对检索性能有更高要求,更适合使用专用向量数据库。
  • 如果当前处于学习、验证或原型期,轻量方案和一体化体验通常比极限性能更重要。

3. 主流方案对比

数据库 类型 部署方式 适用数据量 SDK 生态 索引类型 标量过滤 开源 适用场景
Milvus 专用 自部署或云托管 百万到十亿级 Java、Python、Go、Node.js HNSW、IVF、DISKANN 等 支持 大规模生产环境
Qdrant 专用 自部署或云托管 百万到亿级 Python、Rust、Go、Java HNSW 支持 高性能单机或中等规模集群
Weaviate 专用 自部署或云托管 百万到千万级 Python、Go、Java、JS HNSW 支持 偏一体化、偏平台化方案
Pinecone 专用 云托管 百万到亿级 Python、Node.js 自研 支持 不希望自行运维
Chroma 专用 嵌入式或 Docker 百万以下更合适 Python、JS HNSW 支持 原型验证和轻量场景
pgvector 扩展 随 PostgreSQL 部署 百万以下更合适 所有支持 PG 的语言 HNSW、IVF_FLAT 支持 已有 PG 体系且数据量不大

4. 为什么本文选择 Milvus

本文后续示例选用 Milvus,主要基于以下几个工程考虑:

  • 开源成熟,社区活跃,资料完整。
  • Java SDK 较完善,适合 Java 技术栈做演示。
  • 支持的数据规模跨度大,从单机验证到集群生产都有路径。
  • 索引类型丰富,便于根据不同数据规模切换策略。
  • 原生支持标量过滤,适合知识库类场景。
  • 本地启动成本低,便于教学和实验。

需要强调的是,这并不意味着 Milvus 在所有项目里都是唯一答案。技术选型没有银弹,最优解始终取决于数据规模、资源条件和团队现状。

Milvus 核心概念:借助传统数据库做类比

为了降低理解成本,可以把 Milvus 的核心概念与关系型数据库做一个映射。

【AI】向量数据库的原理与选型详解

1. Collection = 表

Collection 是 Milvus 中数据组织的基本单位,作用类似 MySQL 中的表。

在课程咨询知识库场景中,可以创建一个名为 course_advisory_chunks 的 collection,用来存放所有课程、试听、报名和退款相关内容的 chunk 向量。

如果系统中还有其他语义检索场景,比如课程推荐、教研资料搜索、学习内容召回,通常也会为每个场景建立独立 collection。

2. Schema = 表结构

Schema 定义 collection 中每条数据有哪些字段,作用类似 CREATE TABLE 时定义的列。

一个典型知识库 schema 往往包含三类字段:

字段类型 示例 说明
主键字段 id 每条记录的唯一标识
向量字段 vector 存放 embedding 向量,需要指定维度
标量字段 chunk_textdoc_idcategory 存放元数据,用于过滤和展示
2.1 向量字段与标量字段的区别

标量字段保存的是字符串、数字、布尔值等普通数据,可以做等值、范围或组合过滤。

向量字段保存的是高维浮点数组,它通常不适合做传统意义上的精确匹配,而是依赖相似度检索来寻找“最接近”的结果。因此它需要使用专门的向量索引,而不是传统数据库里的 B+ 树索引。

3. Index = 索引

Milvus 中通常有两类索引:

  • 向量索引:用于加速向量相似度检索,例如 HNSWIVF_FLAT
  • 标量索引:用于提升元数据过滤性能。

在知识库场景中,这两类索引通常会同时存在。向量索引负责找语义相近内容,标量索引负责筛选课程类型、可见范围、租户信息或文档状态。

4. Partition = 分区

Partition 是 collection 内部的逻辑分区,可以理解成按某个业务维度把数据再拆成更小的子集合。

例如课程知识库可以按 course_policytrial_bookingrefund_ruleteacher_faq 等类别拆分分区。查询时如果已经明确只查某个领域,可以缩小搜索范围。

但要注意,分区并不是必须的。如果数据量不大,或者分类维度太多,直接用标量过滤通常更简单。

5. 概念对照表

Milvus 概念 类比到 MySQL 说明
Collection Table 基本数据组织单位
Schema 表结构定义 字段与类型定义
Field Column 单个字段
Partition 分区表中的 Partition 按业务维度拆分数据
向量索引 无直接等价物 专门用于 ANN 检索
标量索引 B+ 树索引 加速元数据过滤
Entity Row 一条记录

动手实践:用 Docker 启动 Milvus 并跑通完整流程

接下来通过一个完整示例,把“建库、写入、建索引、检索、过滤检索”这条链路串起来。

场景保持为在线教育课程咨询知识库。

1. 用 Docker 启动 Milvus Standalone

Milvus Standalone 适合本地开发和中小规模实验环境。它依赖对象存储和 etcd 来保存索引文件、日志和元数据。

下面给出一个可直接运行的 docker-compose.yml 示例。这里使用通用的 S3 兼容对象存储实现,而不是任何内部组件。

name: milvus_stack
services:
  object_store:
    container_name: object_store
    image: minio/minio:RELEASE.2026-01-01T00-00-00Z
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    ports:
      - "9000:9000"
      - "9001:9001"
    volumes:
      - object_store_data:/data
  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
      ETCD_SNAPSHOT_COUNT: 50000
    command: >
      etcd
      -advertise-client-urls=http://etcd:2379
      -listen-client-urls http://0.0.0.0:2379
      --data-dir /etcd
    volumes:
      - etcd_data:/etcd
  standalone:
    container_name: milvus_standalone
    image: milvusdb/milvus:v2.6.6
    command: ["milvus", "run", "standalone"]
    security_opt:
      - seccomp:unconfined
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: object_store:9000
      MINIO_ACCESS_KEY_ID: minioadmin
      MINIO_SECRET_ACCESS_KEY: minioadmin
    volumes:
      - milvus_data:/var/lib/milvus
    ports:
      - "19530:19530"
      - "9091:9091"
    depends_on:
      - etcd
      - object_store
  attu:
    container_name: milvus_attu
    image: zilliz/attu:v2.6.3
    environment:
      MILVUS_URL: milvus_standalone:19530
    ports:
      - "8000:3000"
    depends_on:
      - standalone
volumes:
  object_store_data:
  etcd_data:
  milvus_data:
networks:
  default:
    name: milvus_net

启动命令如下:

docker compose up -d

组件职责如下:

组件 作用 端口
object_store 存储索引文件和日志 9000、9001
etcd 存储元数据 2379
standalone Milvus 单机服务 19530、9091
attu Milvus 可视化管理界面 8000

2. Maven 依赖配置

pom.xml 中加入 Milvus Java SDK、HTTP 客户端和 JSON 依赖:

<dependencies>
    <dependency>
        <groupId>io.milvus</groupId>
        <artifactId>milvus-sdk-java</artifactId>
        <version>2.6.6</version>
    </dependency>
    <dependency>
        <groupId>com.squareup.okhttp3</groupId>
        <artifactId>okhttp</artifactId>
        <version>4.12.0</version>
    </dependency>
    <dependency>
        <groupId>com.google.code.gson</groupId>
        <artifactId>gson</artifactId>
        <version>2.13.1</version>
    </dependency>
</dependencies>

本文示例统一使用 Milvus Java SDK 的 v2 API。

3. 创建 collection 和 schema

下面先建立一个用于存放课程咨询知识库的 collection。

代码语言保持为 Java,但示例命名风格统一改成 snake_case

import io.milvus.v2.client.ConnectConfig;
import io.milvus.v2.client.MilvusClientV2;
import io.milvus.v2.common.DataType;
import io.milvus.v2.service.collection.request.AddFieldReq;
import io.milvus.v2.service.collection.request.CreateCollectionReq;
public class milvus_demo {
    private static final int VECTOR_DIM = 4096;
    private static final String COLLECTION_NAME = "course_advisory_chunks";
    public static void main(String[] args) {
        ConnectConfig connect_config = ConnectConfig.builder()
                .uri("http://localhost:19530")
                .build();
        MilvusClientV2 client = new MilvusClientV2(connect_config);
        System.out.println("已连接到 Milvus");
        CreateCollectionReq.CollectionSchema schema = client.createSchema();
        schema.addField(AddFieldReq.builder()
                .fieldName("id")
                .dataType(DataType.Int64)
                .isPrimaryKey(true)
                .autoID(true)
                .build());
        schema.addField(AddFieldReq.builder()
                .fieldName("vector")
                .dataType(DataType.FloatVector)
                .dimension(VECTOR_DIM)
                .build());
        schema.addField(AddFieldReq.builder()
                .fieldName("chunk_text")
                .dataType(DataType.VarChar)
                .maxLength(8192)
                .build());
        schema.addField(AddFieldReq.builder()
                .fieldName("doc_id")
                .dataType(DataType.VarChar)
                .maxLength(64)
                .build());
        schema.addField(AddFieldReq.builder()
                .fieldName("category")
                .dataType(DataType.VarChar)
                .maxLength(32)
                .build());
        CreateCollectionReq create_collection_req = CreateCollectionReq.builder()
                .collectionName(COLLECTION_NAME)
                .collectionSchema(schema)
                .build();
        client.createCollection(create_collection_req);
        System.out.println("collection 创建成功:" + COLLECTION_NAME);
    }
}

这里有几个关键点:

  • 向量字段维度必须和所选 embedding 模型的输出维度一致。
  • VarChar 类型需要显式指定 maxLength
  • 开启 autoID(true) 后,插入数据时可以不手动提供主键。

4. 插入向量数据

在真实系统里,数据链路通常是:

原始文档 -> 文本提取 -> 分块 -> 向量化 -> 写入 Milvus

为了让示例完整,这里直接模拟几条课程咨询知识片段,并通过通用 Embedding API 生成真实向量。

import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.milvus.v2.client.MilvusClientV2;
import io.milvus.v2.service.vector.request.InsertReq;
import io.milvus.v2.service.vector.response.InsertResp;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public class milvus_insert_demo {
    private static final String EMBEDDING_API_KEY = "your_embedding_api_key";
    private static final String EMBEDDING_URL = "https://api.example.com/v1/embeddings";
    private static final String EMBEDDING_MODEL = "general-text-embedding-4096";
    private static final Gson GSON = new Gson();
    private static final OkHttpClient HTTP_CLIENT = new OkHttpClient();
    public static void main(String[] args) throws IOException {
        MilvusClientV2 client = connect_milvus();
        List<String> chunk_texts = List.of(
                "试听规则:新学员可预约 1 次免费线下或线上试听,试听申请提交后 24 小时内会有课程顾问确认时间。",
                "预约流程:课程预约成功后,系统会通过短信和站内信发送上课时间、教室链接和注意事项。",
                "改期说明:如需调整试听时间,请至少提前 12 小时提交改期申请;超过时限可能需要重新排期。",
                "退款规则:正式报名后 7 天内且未解锁超过 20% 课程内容,可按协议申请退款。",
                "讲师答疑:直播课结束后支持 48 小时内回看,班主任会统一收集问题并安排讲师答疑。"
        );
        List<String> doc_ids = List.of(
                "doc_trial_001",
                "doc_booking_001",
                "doc_booking_001",
                "doc_refund_001",
                "doc_teaching_001"
        );
        List<String> categories = List.of(
                "trial_rule",
                "booking_process",
                "booking_process",
                "refund_rule",
                "teacher_faq"
        );
        List<List<Float>> vectors = get_embeddings(chunk_texts);
        List<JsonObject> rows = new ArrayList<>();
        for (int i = 0; i < chunk_texts.size(); i++) {
            JsonObject row = new JsonObject();
            row.addProperty("chunk_text", chunk_texts.get(i));
            row.addProperty("doc_id", doc_ids.get(i));
            row.addProperty("category", categories.get(i));
            row.add("vector", GSON.toJsonTree(vectors.get(i)));
            rows.add(row);
        }
        InsertReq insert_req = InsertReq.builder()
                .collectionName("course_advisory_chunks")
                .data(rows)
                .build();
        InsertResp insert_resp = client.insert(insert_req);
        System.out.println("插入成功,数量:" + insert_resp.getInsertCnt());
    }
    private static List<List<Float>> get_embeddings(List<String> texts) throws IOException {
        JsonObject request_body = new JsonObject();
        request_body.addProperty("model", EMBEDDING_MODEL);
        request_body.add("input", GSON.toJsonTree(texts));
        Request request = new Request.Builder()
                .url(EMBEDDING_URL)
                .addHeader("Authorization", "Bearer " + EMBEDDING_API_KEY)
                .addHeader("Content-Type", "application/json")
                .post(RequestBody.create(
                        GSON.toJson(request_body),
                        MediaType.parse("application/json")))
                .build();
        try (Response response = HTTP_CLIENT.newCall(request).execute()) {
            String body = response.body().string();
            JsonObject json = GSON.fromJson(body, JsonObject.class);
            JsonArray data_array = json.getAsJsonArray("data");
            List<List<Float>> vectors = new ArrayList<>();
            for (int i = 0; i < data_array.size(); i++) {
                JsonArray embedding_array = data_array.get(i)
                        .getAsJsonObject()
                        .getAsJsonArray("embedding");
                List<Float> vector = new ArrayList<>();
                for (int j = 0; j < embedding_array.size(); j++) {
                    vector.add(embedding_array.get(j).getAsFloat());
                }
                vectors.add(vector);
            }
            return vectors;
        }
    }
}

这一步有几个工程细节值得注意:

  • Milvus v2 API 中,插入数据通常使用 List<JsonObject> 表达。
  • 向量维度必须与 schema 严格一致,否则插入会报错。
  • 实际项目中建议批量写入,而不是逐条写入。

5. 创建索引

插入数据之后,还需要为向量字段创建索引。否则系统依然只能回退到暴力搜索。

import io.milvus.v2.common.IndexParam;
import io.milvus.v2.service.index.request.CreateIndexReq;
IndexParam vector_index = IndexParam.builder()
        .fieldName("vector")
        .indexType(IndexParam.IndexType.HNSW)
        .metricType(IndexParam.MetricType.COSINE)
        .extraParams(java.util.Map.of(
                "M", 16,
                "efConstruction", 256
        ))
        .build();
IndexParam category_index = IndexParam.builder()
        .fieldName("category")
        .indexType(IndexParam.IndexType.TRIE)
        .build();
CreateIndexReq create_index_req = CreateIndexReq.builder()
        .collectionName("course_advisory_chunks")
        .indexParams(java.util.List.of(vector_index, category_index))
        .build();
client.createIndex(create_index_req);
System.out.println("索引创建成功");

这里的工程选择是:

  • 向量字段使用 HNSW,适合百万级知识库。
  • 相似度度量使用 COSINE,符合文本语义检索的常见设定。
  • M = 16efConstruction = 256 是比较稳妥的起点参数。

创建索引之后,还要加载 collection:

import io.milvus.v2.service.collection.request.LoadCollectionReq;
client.loadCollection(LoadCollectionReq.builder()
        .collectionName("course_advisory_chunks")
        .build());
System.out.println("collection 已加载到内存");

Milvus 的检索是在内存中执行的,因此检索前需要显式加载。

6. 执行向量检索

假设现在用户发起的问题是:

“试听课预约成功后会怎么通知我?”

可以这样发起检索:

import io.milvus.v2.service.vector.request.SearchReq;
import io.milvus.v2.service.vector.response.SearchResp;
String query = "试听课预约成功后会怎么通知我?";
List<List<Float>> query_vectors = get_embeddings(List.of(query));
List<BaseVector> milvus_query_vectors = query_vectors.stream()
        .map(FloatVec::new)
        .collect(java.util.stream.Collectors.toList());
SearchReq search_req = SearchReq.builder()
        .collectionName("course_advisory_chunks")
        .data(milvus_query_vectors)
        .topK(3)
        .outputFields(List.of("chunk_text", "doc_id", "category"))
        .annsField("vector")
        .searchParams(java.util.Map.of("ef", 128))
        .build();
SearchResp search_resp = client.search(search_req);
List<List<SearchResp.SearchResult>> results = search_resp.getSearchResults();
for (List<SearchResp.SearchResult> result_list : results) {
    System.out.println("=== 检索结果 ===");
    for (int i = 0; i < result_list.size(); i++) {
        SearchResp.SearchResult result = result_list.get(i);
        System.out.println("Top-" + (i + 1) + ":");
        System.out.println("  相似度分数:" + result.getScore());
        System.out.println("  分类:" + result.getEntity().get("category"));
        System.out.println("  文档 ID:" + result.getEntity().get("doc_id"));
        System.out.println("  内容:" + result.getEntity().get("chunk_text"));
        System.out.println();
    }
}

参数含义如下:

  • topK(3):返回最相关的 3 条结果。
  • outputFields(...):指定需要带回的元数据字段。
  • annsField("vector"):指定在哪个向量字段上检索。
  • ef = 128:HNSW 查询时的搜索宽度,通常可设为 topK 的 4 到 16 倍。

7. 结合元数据过滤的混合检索

单纯依靠语义检索,有时会召回“语义相关但业务范围不准”的内容。这时就需要把向量相似度和标量过滤结合起来。

例如用户问的是试听预约通知,这时可以只在预约流程相关的知识片段中搜索。

SearchReq filtered_search_req = SearchReq.builder()
        .collectionName("course_advisory_chunks")
        .data(milvus_query_vectors)
        .topK(3)
        .outputFields(List.of("chunk_text", "doc_id", "category"))
        .annsField("vector")
        .filter("category == "booking_process"")
        .searchParams(java.util.Map.of("ef", 128))
        .build();
SearchResp filtered_resp = client.search(filtered_search_req);

这类“向量检索 + 元数据过滤”的混合检索,在实际知识库系统里非常常见。常见过滤条件包括:

  • 多租户隔离:tenant_id == "tenant_001"
  • 权限控制:access_level <= 3
  • 时间过滤:updated_at > "2026-01-01"
  • 内容分类:category == "booking_process"

8. 运行结果应该如何理解

如果用户问题是“试听课预约成功后会怎么通知我?”,理想结果应该优先召回与预约确认、消息通知、上课链接下发相关的 chunk。

这时通常会看到:

  • Top-1、Top-2 都与预约通知或试听流程高度相关。
  • 如果不加过滤条件,少量与课程服务流程相关但不完全匹配的内容也可能进入 Top-K。
  • 加上 category 过滤后,结果会更聚焦。

这就构成了一条完整的向量数据库工作流:

创建 collection -> 定义 schema -> 插入数据 -> 创建索引 -> 加载 collection -> 执行检索

在真实系统中,前半段通常属于离线数据准备,最后的检索属于在线查询链路。

实际项目中的关键决策

1. 索引类型怎么选

实操中最常见的选择逻辑如下:

  • 10 万条以下:优先 FLAT,不用为调参增加复杂度。
  • 10 万到 500 万:优先 HNSW
  • 500 万到 5000 万:先看内存是否允许使用 HNSW,不够再转 IVF_SQ8
  • 更大规模:优先评估 DISKANNIVF_PQ

如果场景是知识库 RAG,而不是生物识别或超高精度匹配,通常不需要把索引追求到极致精确,稳定的召回和低时延更重要。

2. 相似度度量怎么选

Milvus 常见的度量方式有三种:

度量方式 含义 值域 越大越相似 常见场景
COSINE 比较方向夹角 [-1, 1] 文本语义检索
IP 比较方向和大小 (-∞, +∞) 向量已归一化时与余弦近似
L2 比较空间距离 [0, +∞) 图像、音频、推荐等

对于大多数文本 embedding 模型,如果官方推荐没有特别说明,COSINE 是最稳妥的选择。

3. 分区策略怎么设计

常见分区维度包括:

分区维度 示例 适用场景
按知识类别 trial_rulebooking_processrefund_rule 分类体系清晰的知识库
按租户 tenant_001tenant_002 多租户平台
按时间 2026_q12026_q2 时效性内容较强

但分区并不是默认答案。以下情况通常更适合直接用标量过滤:

  • 分类值很多,分区数量会失控。
  • 查询经常跨多个类别。
  • 数据量还没大到必须依赖分区优化。

经验上,如果某个过滤维度在绝大多数查询中都会使用,而且候选值种类不多,分区才更值得考虑。

4. 数据更新策略

知识库会持续新增、修订、下线,因此向量库必须与源文档保持同步。

Milvus 对向量字段的更新,工程上通常采用“删旧插新”:

import io.milvus.v2.service.vector.request.DeleteReq;
DeleteReq delete_req = DeleteReq.builder()
        .collectionName("course_advisory_chunks")
        .filter("doc_id == "doc_booking_001"")
        .build();
client.delete(delete_req);

之后再执行:

  1. 重新提取更新后的文档内容。
  2. 重新分块。
  3. 重新向量化。
  4. 重新插入 Milvus。

这里的 doc_id 非常重要,它是源文档与向量库之间的锚点。

5. 性能调优的关键参数

5.1 HNSW 参数
参数 作用 推荐起点 调大 调小
M 每个向量的最大连接数 8 到 32,常用 16 召回率上升,内存上升 内存下降,召回率可能下降
efConstruction 建索引搜索宽度 128 到 512,常用 256 索引质量更高,构建更慢 构建更快,质量下降
ef 查询搜索宽度 topK 的 4 到 16 倍 召回率上升,查询变慢 查询更快,召回率下降

常见起点配置可以是:

  • M = 16
  • efConstruction = 256
  • ef = topK * 8
5.2 IVF 参数
参数 作用 推荐起点 调大 调小
nlist 聚类簇数量 约为数据量平方根 簇更细,训练更慢 簇更粗,检索更慢
nprobe 查询时搜索簇数量 nlist 的 5% 到 10% 召回率上升 查询更快但召回率下降
5.3 通用建议
  • 向量维度越高,内存和检索成本越高。
  • 批量插入通常明显优于逐条插入。
  • 内存吃紧但查询 QPS 不高时,可以优先评估磁盘型索引。
  • 调参不应只看时延,也要同时观察召回率。

小结

本文围绕“向量该存在哪里、如何高效检索”这两个问题,梳理了向量数据库的基本原理与选型逻辑。

核心结论可以总结为三点:

  1. 普通数据库能存向量,但无法在大规模场景下高效完成相似度检索。
  2. 向量数据库真正关键的能力是 ANN 索引,而不是单纯的存储。
  3. 在百万级知识库场景中,HNSW + Milvus 往往是一条兼顾性能、精度与工程可实现性的稳妥路径。

到这里,课程咨询知识库的离线数据链路已经完整打通:

原始文档 -> 文本提取 -> 分块 -> 元数据组织 -> 向量化 -> 写入 Milvus

但检索链路真正上线后,还会遇到另一个问题:只靠向量相似度通常还不够。对于包含课程编号、班型名称、日期、价格、讲师姓名等精确信息的查询,关键词检索、混合检索和重排序往往同样重要。

这也是下一步系统优化的重点方向。

如果我的内容对你有帮助,请辛苦动动您的手指为我点赞,评论,收藏。感谢大家!!

在这里插入图片描述

© 版权声明

相关文章