聊聊【AI】向量数据库的原理与选型详解(整理分享)

前段时间遇到一个小问题,后来发现这是个挺常见的坑,顺手整理一篇笔记。

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

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

🤞先做到 再看见!


目录

向量检索的核心算法:如何避免逐个算是 3. HNSW:当前最主流的索引方案 4. 索引算法怎么选 主流向量数据库对比与选型 2. 什么时候选哪一类3. 主流方案对比4. 为什么本文选择 Milvus Milvus 核心概念:借助传统数据库做类比 3. Index = 索引4. Partition = 分区5. 概念对照表 动手实践:用 Docker 启动 Milvus 并跑通完整流程 实际项目中的关键决策 小结

上一篇已经把文本 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 万条时,整体计算量会十分可观。

在普通服务器上,单核 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:使用乘积量化进一步压缩。

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 系列或磁盘型索引。

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.jsHNSW、IVF、DISKANN 等支持是大规模生产环境Qdrant专用自部署或云托管百万到亿级Python、Rust、Go、JavaHNSW支持是高性能单机或中等规模集群Weaviate专用自部署或云托管百万到千万级Python、Go、Java、JSHNSW支持是偏一体化、偏平台化方案Pinecone专用云托管百万到亿级Python、Node.js自研支持否不希望自行运维Chroma专用嵌入式或 Docker百万以下更合适Python、JSHNSW支持是原型验证和轻量场景pgvector扩展随 PostgreSQL 部署百万以下更合适所有支持 PG 的语言HNSW、IVF_FLAT支持是已有 PG 体系且数据量不大

4. 为什么本文选择 Milvus

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

  • 开源成熟,社区活跃,资料完整。
  • Java SDK 较完善,适合 Java 技术栈做演示。
  • 支持的数据规模跨度大,从单机验证到集群生产都有路径。
  • 索引类型丰富,便于根据不同数据规模切换策略。
  • 原生支持标量过滤,适合知识库类场景。
  • 本地启动成本低,便于教学和实验。
需要强调的是,这并不意味着 Milvus 在所有项目里都是唯一答案。技术选型没有银弹,最优解始终取决于数据规模、资源条件和团队现状。

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

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

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说明CollectionTable基本数据组织单位Schema表结构定义字段与类型定义FieldColumn单个字段Partition分区表中的 Partition按业务维度拆分数据向量索引无直接等价物专门用于 ANN 检索标量索引B+ 树索引加速元数据过滤EntityRow一条记录

动手实践:用 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、9001etcd存储元数据2379standaloneMilvus 单机服务19530、9091attuMilvus 可视化管理界面8000

2. Maven 依赖配置

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


        io.milvus
        milvus-sdk-java
        2.6.6

        com.squareup.okhttp3
        okhttp
        4.12.0

        com.google.code.gson
        gson
        2.13.1

本文示例统一使用 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 chunk_texts = List.of(
                "试听规则:新学员可预约 1 次免费线下或线上试听,试听申请提交后 24 小时内会有课程顾问确认时间。",
                "预约流程:课程预约成功后,系统会通过短信和站内信发送上课时间、教室链接和注意事项。",
                "改期说明:如需调整试听时间,请至少提前 12 小时提交改期申请;超过时限可能需要重新排期。",
                "退款规则:正式报名后 7 天内且未解锁超过 20% 课程内容,可按协议申请退款。",
                "讲师答疑:直播课结束后支持 48 小时内回看,班主任会统一收集问题并安排讲师答疑。"
        );

        List doc_ids = List.of(
                "doc_trial_001",
                "doc_booking_001",
                "doc_booking_001",
                "doc_refund_001",
                "doc_teaching_001"
        );

        List categories = List.of(
                "trial_rule",
                "booking_process",
                "booking_process",
                "refund_rule",
                "teacher_faq"
        );

        List vectors = get_embeddings(chunk_texts);

        List 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 get_embeddings(List 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 vectors = new ArrayList();
            for (int i = 0; i < data_array.size(); i++) {
                JsonArray embedding_array = data_array.get(i)
                        .getAsJsonObject()
                        .getAsJsonArray("embedding");
                List 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 表达。
  • 向量维度一定要与 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 query_vectors = get_embeddings(List.of(query));

List 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 results = search_resp.getSearchResults();
for (List 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
  • 时间过滤: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

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

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

**


暂时整理到这里。以上都是个人理解,可能有疏漏,欢迎指正。

评论 (0)

暂无评论