← 返回文章列表

Milvus底层原理(三):IVF索引家族

2026-03-10·9 分钟阅读

前言

IVF(Inverted File Index,倒排文件索引)是向量搜索中最经典的索引方法之一,通过聚类将向量空间划分为多个区域,搜索时只需扫描部分区域,从而实现亚线性时间复杂度的搜索。IVF 系列索引在 Milvus 中占据重要地位,是平衡召回率、延迟和内存占用的首选方案。

本文将深入分析 IVF 索引家族的核心原理,包括 IVF-Flat、IVF-PQ、IVF-SQ8 等变体的设计思想、实现细节和调优策略。

技术亮点

技术点难度面试价值本文覆盖
K-Means 聚类原理⭐⭐⭐高频考点
IVF 倒排索引结构⭐⭐⭐进阶考点
IVF-PQ 压缩原理⭐⭐⭐⭐高频考点
nlist/nprobe 调优⭐⭐⭐实战技能
Milvus IVF 实现⭐⭐⭐⭐源码级

面试考点

  1. IVF 索引的基本原理是什么?
  2. IVF-Flat 和 IVF-PQ 有什么区别?
  3. nlist 和 nprobe 参数如何影响性能?
  4. IVF 索引适用于什么场景?
  5. 如何选择 IVF 索引的参数?

一、IVF 索引核心原理

1.1 基本思想

IVF 索引的核心思想是"分而治之":通过聚类将向量空间划分为若干区域(聚类桶),每个桶维护一个倒排列表存储属于该桶的向量 ID。搜索时,先找到查询向量最近的几个桶,然后只在这些桶内搜索。

┌─────────────────────────────────────────────────────────────────┐
│                    IVF 索引核心思想                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  原始空间(所有向量需要遍历):                                  │
│                                                                 │
│  ┌───────────────────────────────────────────────────────┐     │
│  │  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  │     │
│  │  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  │     │
│  │  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  │     │
│  │  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  ·  │     │
│  └───────────────────────────────────────────────────────┘     │
│                    需要遍历所有 N 个向量                         │
│                                                                 │
│  IVF 划分后(只搜索部分区域):                                  │
│                                                                 │
│  ┌───────────┬───────────┬───────────┬───────────┐            │
│  │ Cluster 1 │ Cluster 2 │ Cluster 3 │ Cluster 4 │            │
│  │   ·  ·    │   ·  ·    │   ·  ·    │   ·  ·    │            │
│  │   ·  ·    │   ·  ·    │   ·  ·    │   ·  ·    │            │
│  │   ·  ·    │   ·  ·    │   ·  ·    │   ·  ·    │            │
│  │     ★     │           │     ★     │           │            │
│  └───────────┴───────────┴───────────┴───────────┘            │
│              ↑ 只搜索这两个桶(nprobe=2)                        │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

1.2 索引结构

┌─────────────────────────────────────────────────────────────────┐
│                    IVF 索引数据结构                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │                   聚类中心(Centroids)                  │   │
│  │  ┌─────────┐ ┌─────────┐ ┌─────────┐                   │   │
│  │  │  C₁     │ │  C₂     │ │  C₃     │ ... (nlist 个)   │   │
│  │  │ [D 维]  │ │ [D 维]  │ │ [D 维]  │                   │   │
│  │  └────┬────┘ └────┬────┘ └────┬────┘                   │   │
│  └───────┼───────────┼───────────┼─────────────────────────┘   │
│          │           │           │                              │
│          ▼           ▼           ▼                              │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │                   倒排列表(Inverted Lists)             │   │
│  │                                                         │   │
│  │  List₁: [ID₁, ID₅, ID₈, ...] 向量ID列表               │   │
│  │  List₂: [ID₂, ID₃, ID₇, ...]                          │   │
│  │  List₃: [ID₄, ID₆, ID₉, ...]                          │   │
│  │  ...                                                    │   │
│  └─────────────────────────────────────────────────────────┘   │
│          │                                                     │
│          ▼                                                     │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │                   原始向量存储                           │   │
│  │  ID₁ → [v₁₁, v₁₂, ..., v₁D]                           │   │
│  │  ID₂ → [v₂₁, v₂₂, ..., v₂D]                           │   │
│  │  ...                                                    │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

1.3 搜索流程

def ivf_search(query, centroids, inverted_lists, vectors, nprobe, k):
    """
    IVF 搜索流程
    
    1. 找到距离查询向量最近的 nprobe 个聚类中心
    2. 在这些聚类对应的倒排列表中搜索
    3. 返回 Top-K 结果
    """
    # Step 1: 找最近的 nprobe 个聚类中心
    centroid_distances = compute_distances(query, centroids)
    nearest_centroids = top_k_indices(centroid_distances, nprobe)
    
    # Step 2: 收集候选向量
    candidates = []
    for centroid_id in nearest_centroids:
        for vector_id in inverted_lists[centroid_id]:
            candidates.append(vector_id)
    
    # Step 3: 在候选向量中搜索 Top-K
    candidate_vectors = [vectors[id] for id in candidates]
    distances = compute_distances(query, candidate_vectors)
    
    return top_k_indices(distances, k)

1.4 时间复杂度分析

┌─────────────────────────────────────────────────────────────────┐
│                    IVF 时间复杂度分析                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  设:N = 向量总数,D = 向量维度                                 │
│      nlist = 聚类中心数量,nprobe = 搜索桶数                    │
│                                                                 │
│  搜索时间 = O(nlist × D) + O(N × D / nlist × nprobe)           │
│           = 找最近中心   +  在选中桶内搜索                       │
│                                                                 │
│  最优 nlist 选择:                                               │
│  当 nlist ≈ √N × √D 时,搜索时间复杂度 ≈ O(√N × D)             │
│                                                                 │
│  对比暴力搜索 O(N × D):                                         │
│  加速比 ≈ √N / nprobe                                           │
│  例如 N=100万, nprobe=10, 加速比 ≈ 100x                        │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

二、K-Means 聚类算法

2.1 标准 K-Means

IVF 索引使用 K-Means 算法进行聚类:

import numpy as np
from typing import Tuple

class KMeans:
    """K-Means 聚类实现"""
    
    def __init__(self, n_clusters: int, max_iter: int = 20, tol: float = 1e-4):
        self.n_clusters = n_clusters
        self.max_iter = max_iter
        self.tol = tol
        self.centroids = None
    
    def fit(self, X: np.ndarray) -> 'KMeans':
        """
        训练 K-Means
        
        Args:
            X: (N, D) 训练数据
        """
        n, d = X.shape
        
        # K-Means++ 初始化
        self.centroids = self._kmeans_plusplus_init(X)
        
        for iteration in range(self.max_iter):
            # 分配到最近的中心点
            labels = self._assign_labels(X)
            
            # 更新中心点
            new_centroids = np.zeros_like(self.centroids)
            for k in range(self.n_clusters):
                mask = labels == k
                if np.any(mask):
                    new_centroids[k] = np.mean(X[mask], axis=0)
                else:
                    # 空聚类:重新随机初始化
                    new_centroids[k] = X[np.random.randint(n)]
            
            # 检查收敛
            if np.allclose(self.centroids, new_centroids, atol=self.tol):
                break
            
            self.centroids = new_centroids
        
        return self
    
    def _kmeans_plusplus_init(self, X: np.ndarray) -> np.ndarray:
        """K-Means++ 初始化,选择更好的初始中心点"""
        n, d = X.shape
        centroids = np.zeros((self.n_clusters, d), dtype=X.dtype)
        
        # 随机选择第一个中心点
        idx = np.random.randint(n)
        centroids[0] = X[idx]
        
        # 选择剩余中心点
        for k in range(1, self.n_clusters):
            # 计算每个点到最近中心点的距离
            distances = np.min(self._compute_distances(X, centroids[:k]), axis=1)
            
            # 按距离的平方加权随机选择
            probs = distances ** 2
            probs /= probs.sum()
            idx = np.random.choice(n, p=probs)
            centroids[k] = X[idx]
        
        return centroids
    
    def _assign_labels(self, X: np.ndarray) -> np.ndarray:
        """将每个点分配到最近的中心点"""
        distances = self._compute_distances(X, self.centroids)
        return np.argmin(distances, axis=1)
    
    def _compute_distances(self, X: np.ndarray, centroids: np.ndarray) -> np.ndarray:
        """计算距离矩阵"""
        # ||x - c||² = ||x||² + ||c||² - 2*x·c
        X_sq = np.sum(X ** 2, axis=1, keepdims=True)
        C_sq = np.sum(centroids ** 2, axis=1)
        XC = np.dot(X, centroids.T)
        return np.sqrt(np.maximum(X_sq + C_sq - 2 * XC, 0))
    
    def predict(self, X: np.ndarray) -> np.ndarray:
        """预测聚类标签"""
        return self._assign_labels(X)

2.2 聚类质量评估

def evaluate_clustering_quality(
    X: np.ndarray,
    labels: np.ndarray,
    centroids: np.ndarray
) -> dict:
    """
    评估聚类质量
    
    Args:
        X: (N, D) 数据
        labels: (N,) 聚类标签
        centroids: (K, D) 聚类中心
    
    Returns:
        metrics: 质量指标
    """
    n_clusters = len(centroids)
    
    # 1. 簇内距离(Inertia)
    inertia = 0.0
    for k in range(n_clusters):
        mask = labels == k
        if np.any(mask):
            distances = np.linalg.norm(X[mask] - centroids[k], axis=1)
            inertia += np.sum(distances ** 2)
    
    # 2. 簇大小分布
    cluster_sizes = np.bincount(labels, minlength=n_clusters)
    size_std = np.std(cluster_sizes) / np.mean(cluster_sizes)
    
    # 3. 平均簇内距离
    avg_intra_distance = inertia / len(X)
    
    return {
        "inertia": inertia,
        "avg_intra_distance": avg_intra_distance,
        "size_std": size_std,
        "cluster_sizes": cluster_sizes,
    }

三、IVF-Flat 索引

3.1 结构设计

IVF-Flat 是最基础的 IVF 索引,存储原始向量(无压缩):

class IVFFlatIndex:
    """IVF-Flat 索引实现"""
    
    def __init__(self, d: int, nlist: int, metric: str = "l2"):
        """
        Args:
            d: 向量维度
            nlist: 聚类中心数量
            metric: 距离度量
        """
        self.d = d
        self.nlist = nlist
        self.metric = metric
        self.centroids = None
        self.inverted_lists = None
        self.vectors = None
    
    def train(self, X: np.ndarray):
        """训练聚类中心"""
        kmeans = KMeans(n_clusters=self.nlist)
        kmeans.fit(X)
        self.centroids = kmeans.centroids
    
    def add(self, X: np.ndarray):
        """添加向量"""
        n = X.shape[0]
        
        # 分配到聚类桶
        distances = self._compute_distances(X, self.centroids)
        labels = np.argmin(distances, axis=1)
        
        # 构建倒排列表
        self.inverted_lists = [[] for _ in range(self.nlist)]
        for idx, label in enumerate(labels):
            self.inverted_lists[label].append(idx)
        
        # 转换为 numpy 数组
        self.inverted_lists = [np.array(lst, dtype=np.int64) for lst in self.inverted_lists]
        self.vectors = X
    
    def search(
        self,
        query: np.ndarray,
        k: int,
        nprobe: int
    ) -> Tuple[np.ndarray, np.ndarray]:
        """
        搜索
        
        Args:
            query: (D,) 查询向量
            k: 返回数量
            nprobe: 搜索桶数
        """
        # 找最近的 nprobe 个聚类中心
        centroid_distances = self._compute_distances(query.reshape(1, -1), self.centroids)[0]
        nearest_buckets = np.argpartition(centroid_distances, nprobe)[:nprobe]
        
        # 收集候选向量
        candidates = []
        for bucket_id in nearest_buckets:
            candidates.extend(self.inverted_lists[bucket_id])
        
        if len(candidates) == 0:
            return np.array([], dtype=np.int64), np.array([], dtype=np.float32)
        
        candidates = np.array(candidates, dtype=np.int64)
        
        # 计算距离
        candidate_vectors = self.vectors[candidates]
        distances = self._compute_distances(query.reshape(1, -1), candidate_vectors)[0]
        
        # Top-K
        if len(candidates) <= k:
            order = np.argsort(distances)
        else:
            order = np.argpartition(distances, k)[:k]
            order = order[np.argsort(distances[order])]
        
        return candidates[order], distances[order]
    
    def _compute_distances(self, X: np.ndarray, Y: np.ndarray) -> np.ndarray:
        """计算距离矩阵"""
        if self.metric == "l2":
            X_sq = np.sum(X ** 2, axis=1, keepdims=True)
            Y_sq = np.sum(Y ** 2, axis=1)
            XY = np.dot(X, Y.T)
            return np.sqrt(np.maximum(X_sq + Y_sq - 2 * XY, 0))
        elif self.metric == "ip":
            return -np.dot(X, Y.T)
        else:
            raise ValueError(f"Unknown metric: {self.metric}")

3.2 内存占用分析

┌─────────────────────────────────────────────────────────────────┐
│                    IVF-Flat 内存占用                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  聚类中心:nlist × D × 4 bytes (float32)                        │
│  原始向量:N × D × 4 bytes                                      │
│  倒排列表:N × 8 bytes (int64 ID)                               │
│                                                                 │
│  总计 ≈ N × D × 4 + N × 8 + nlist × D × 4                       │
│       ≈ N × (4D + 8) bytes                                      │
│                                                                 │
│  示例:N=100万, D=768                                           │
│  内存 ≈ 100万 × (3072 + 8) ≈ 3.0 GB                            │
│                                                                 │
│  特点:与原始数据大小相同,无压缩                                │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

四、IVF-PQ 索引

4.1 设计思想

IVF-PQ 结合了 IVF 聚类和 PQ 量化,实现高效压缩:

┌─────────────────────────────────────────────────────────────────┐
│                    IVF-PQ 设计思想                               │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  第一层:IVF 聚类                                               │
│  将向量空间划分为 nlist 个区域                                  │
│                                                                 │
│  第二层:PQ 量化                                                │
│  每个向量被量化为 M 个 uint8 索引                               │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │                    向量处理流程                          │   │
│  │                                                         │   │
│  │  原始向量 [D 维 float32]                                │   │
│  │       │                                                 │   │
│  │       ▼                                                 │   │
│  │  IVF 聚类 ──► 分配到某个桶                              │   │
│  │       │                                                 │   │
│  │       ▼                                                 │   │
│  │  PQ 编码 ──► [M 个 uint8]                              │   │
│  │       │                                                 │   │
│  │       ▼                                                 │   │
│  │  存储:(桶ID, PQ编码)                                   │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

4.2 实现

class IVFPQIndex:
    """IVF-PQ 索引实现"""
    
    def __init__(
        self,
        d: int,
        nlist: int,
        m: int = 8,
        nbits: int = 8,
        metric: str = "l2"
    ):
        """
        Args:
            d: 向量维度
            nlist: 聚类中心数量
            m: PQ 子向量数量
            nbits: PQ 编码位数(通常为8,即256个中心点)
            metric: 距离度量
        """
        assert d % m == 0, "d 必须能被 m 整除"
        
        self.d = d
        self.nlist = nlist
        self.m = m
        self.nbits = nbits
        self.k = 2 ** nbits  # PQ 码本大小
        self.metric = metric
        self.sub_dim = d // m
        
        # IVF 部分
        self.centroids = None  # (nlist, d)
        
        # PQ 部分
        self.pq_centroids = None  # (m, k, sub_dim)
        
        # 数据
        self.codes = None  # (N, m) uint8
        self.bucket_assignments = None  # (N,) int
    
    def train(self, X: np.ndarray, n_iter: int = 20):
        """
        训练 IVF 和 PQ 码本
        
        Args:
            X: (N, D) 训练数据
        """
        n = X.shape[0]
        
        # 训练 IVF 聚类中心
        kmeans_ivf = KMeans(n_clusters=self.nlist, max_iter=n_iter)
        kmeans_ivf.fit(X)
        self.centroids = kmeans_ivf.centroids
        
        # 训练 PQ 码本
        self.pq_centroids = np.zeros((self.m, self.k, self.sub_dim), dtype=np.float32)
        
        for m in range(self.m):
            sub_vectors = X[:, m * self.sub_dim:(m + 1) * self.sub_dim]
            kmeans_pq = KMeans(n_clusters=self.k, max_iter=n_iter)
            kmeans_pq.fit(sub_vectors)
            self.pq_centroids[m] = kmeans_pq.centroids
    
    def add(self, X: np.ndarray):
        """
        添加向量
        
        Args:
            X: (N, D) 向量数据
        """
        n = X.shape[0]
        
        # IVF 分配
        distances = self._compute_l2_distances(X, self.centroids)
        self.bucket_assignments = np.argmin(distances, axis=1)
        
        # PQ 编码
        self.codes = np.zeros((n, self.m), dtype=np.uint8)
        for m in range(self.m):
            sub_vectors = X[:, m * self.sub_dim:(m + 1) * self.sub_dim]
            sub_distances = self._compute_l2_distances(sub_vectors, self.pq_centroids[m])
            self.codes[:, m] = np.argmin(sub_distances, axis=1)
        
        # 构建倒排列表
        self.inverted_lists = [[] for _ in range(self.nlist)]
        for idx, bucket in enumerate(self.bucket_assignments):
            self.inverted_lists[bucket].append(idx)
        self.inverted_lists = [np.array(lst, dtype=np.int64) for lst in self.inverted_lists]
    
    def search(
        self,
        query: np.ndarray,
        k: int,
        nprobe: int
    ) -> Tuple[np.ndarray, np.ndarray]:
        """
        搜索
        
        Args:
            query: (D,) 查询向量
            k: 返回数量
            nprobe: 搜索桶数
        """
        # 找最近的 nprobe 个桶
        centroid_distances = self._compute_l2_distances(query.reshape(1, -1), self.centroids)[0]
        nearest_buckets = np.argpartition(centroid_distances, nprobe)[:nprobe]
        
        # 收集候选
        candidates = []
        for bucket_id in nearest_buckets:
            candidates.extend(self.inverted_lists[bucket_id])
        
        if len(candidates) == 0:
            return np.array([], dtype=np.int64), np.array([], dtype=np.float32)
        
        candidates = np.array(candidates, dtype=np.int64)
        
        # 使用 PQ 距离表计算距离
        distances = self._pq_distance(query, candidates)
        
        # Top-K
        if len(candidates) <= k:
            order = np.argsort(distances)
        else:
            order = np.argpartition(distances, k)[:k]
            order = order[np.argsort(distances[order])]
        
        return candidates[order], distances[order]
    
    def _pq_distance(self, query: np.ndarray, indices: np.ndarray) -> np.ndarray:
        """使用 PQ 距离表计算距离"""
        # 预计算距离表
        distance_table = np.zeros((self.m, self.k), dtype=np.float32)
        for m in range(self.m):
            sub_query = query[m * self.sub_dim:(m + 1) * self.sub_dim]
            diff = self.pq_centroids[m] - sub_query
            distance_table[m] = np.sum(diff ** 2, axis=1)
        
        # 查表计算
        codes = self.codes[indices]
        distances = np.zeros(len(indices), dtype=np.float32)
        for m in range(self.m):
            distances += distance_table[m, codes[:, m]]
        
        return np.sqrt(distances)
    
    def _compute_l2_distances(self, X: np.ndarray, Y: np.ndarray) -> np.ndarray:
        """计算 L2 距离矩阵"""
        X_sq = np.sum(X ** 2, axis=1, keepdims=True)
        Y_sq = np.sum(Y ** 2, axis=1)
        XY = np.dot(X, Y.T)
        return np.sqrt(np.maximum(X_sq + Y_sq - 2 * XY, 0))
    
    def memory_usage(self) -> int:
        """计算内存占用"""
        # 聚类中心
        ivf_memory = self.nlist * self.d * 4
        
        # PQ 码本
        pq_memory = self.m * self.k * self.sub_dim * 4
        
        # 编码
        code_memory = len(self.codes) * self.m
        
        return ivf_memory + pq_memory + code_memory

4.3 内存压缩比

┌─────────────────────────────────────────────────────────────────┐
│                    IVF-PQ 内存占用                               │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  聚类中心:nlist × D × 4 bytes                                  │
│  PQ 码本:M × K × (D/M) × 4 bytes                               │
│  PQ 编码:N × M bytes                                           │
│  倒排列表:N × 8 bytes                                          │
│                                                                 │
│  示例:N=100万, D=768, M=16, K=256                              │
│                                                                 │
│  原始向量:100万 × 768 × 4 = 2.93 GB                            │
│  PQ 编码:100万 × 16 = 16 MB                                    │
│  PQ 码本:16 × 256 × 48 × 4 = 0.75 MB                           │
│  聚类中心:nlist × 768 × 4 ≈ 可忽略                             │
│                                                                 │
│  压缩比:2.93 GB / 16 MB ≈ 183x                                │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

五、IVF-SQ8 索引

5.1 标量量化原理

IVF-SQ8 使用标量量化将 float32 压缩为 uint8:

class IVFSQ8Index:
    """IVF-SQ8 索引实现"""
    
    def __init__(self, d: int, nlist: int, metric: str = "l2"):
        """
        Args:
            d: 向量维度
            nlist: 聚类中心数量
            metric: 距离度量
        """
        self.d = d
        self.nlist = nlist
        self.metric = metric
        
        # IVF
        self.centroids = None
        
        # SQ8 量化参数
        self.min_vals = None  # (D,) 每维最小值
        self.max_vals = None  # (D,) 每维最大值
        
        # 数据
        self.codes = None  # (N, D) uint8
        self.inverted_lists = None
    
    def train(self, X: np.ndarray, n_iter: int = 20):
        """训练"""
        # 训练 IVF 聚类
        kmeans = KMeans(n_clusters=self.nlist, max_iter=n_iter)
        kmeans.fit(X)
        self.centroids = kmeans.centroids
        
        # 训练 SQ8 量化器
        self.min_vals = np.min(X, axis=0)
        self.max_vals = np.max(X, axis=0)
    
    def add(self, X: np.ndarray):
        """添加向量"""
        n = X.shape[0]
        
        # IVF 分配
        distances = self._compute_distances(X, self.centroids)
        labels = np.argmin(distances, axis=1)
        
        # SQ8 编码
        normalized = (X - self.min_vals) / (self.max_vals - self.min_vals + 1e-10)
        self.codes = np.clip(normalized * 255, 0, 255).astype(np.uint8)
        
        # 构建倒排列表
        self.inverted_lists = [[] for _ in range(self.nlist)]
        for idx, label in enumerate(labels):
            self.inverted_lists[label].append(idx)
        self.inverted_lists = [np.array(lst, dtype=np.int64) for lst in self.inverted_lists]
    
    def search(self, query: np.ndarray, k: int, nprobe: int) -> Tuple[np.ndarray, np.ndarray]:
        """搜索"""
        # 找最近的桶
        centroid_distances = self._compute_distances(query.reshape(1, -1), self.centroids)[0]
        nearest_buckets = np.argpartition(centroid_distances, nprobe)[:nprobe]
        
        # 收集候选
        candidates = []
        for bucket_id in nearest_buckets:
            candidates.extend(self.inverted_lists[bucket_id])
        
        if len(candidates) == 0:
            return np.array([]), np.array([])
        
        candidates = np.array(candidates, dtype=np.int64)
        
        # 解码并计算距离
        decoded = self._decode(self.codes[candidates])
        distances = self._compute_distances(query.reshape(1, -1), decoded)[0]
        
        # Top-K
        if len(candidates) <= k:
            order = np.argsort(distances)
        else:
            order = np.argpartition(distances, k)[:k]
            order = order[np.argsort(distances[order])]
        
        return candidates[order], distances[order]
    
    def _decode(self, codes: np.ndarray) -> np.ndarray:
        """解码 uint8 -> float32"""
        return codes.astype(np.float32) / 255.0 * (self.max_vals - self.min_vals) + self.min_vals
    
    def _compute_distances(self, X: np.ndarray, Y: np.ndarray) -> np.ndarray:
        """计算距离"""
        X_sq = np.sum(X ** 2, axis=1, keepdims=True)
        Y_sq = np.sum(Y ** 2, axis=1)
        XY = np.dot(X, Y.T)
        return np.sqrt(np.maximum(X_sq + Y_sq - 2 * XY, 0))

5.2 SQ8 vs PQ 对比

┌─────────────────────────────────────────────────────────────────┐
│                    SQ8 vs PQ 对比                                │
├───────────────┬─────────────────────────────────────────────────┤
│ 特性          │ SQ8                    │ PQ                     │
├───────────────┼─────────────────────────────────────────────────┤
│ 压缩比        │ 4x                     │ 16-64x                 │
│ 编码方式      │ 每维独立量化           │ 子向量联合量化         │
│ 精度损失      │ 较小                   │ 较大                   │
│ 距离计算      │ 需要解码               │ 使用距离表             │
│ 适用场景      │ 精度要求较高           │ 内存极其受限           │
│ 训练复杂度    │ 低(只需统计最值)     │ 高(需要训练码本)     │
└───────────────┴─────────────────────────────────────────────────┘

六、参数调优指南

6.1 nlist 选择

┌─────────────────────────────────────────────────────────────────┐
│                    nlist 选择指南                                │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  nlist 越大:                                                    │
│  ✅ 搜索更快(每个桶内向量更少)                                │
│  ✅ 召回率更高(更精细的划分)                                  │
│  ❌ 聚类训练时间更长                                            │
│  ❌ 聚类中心占用更多内存                                        │
│  ❌ 每个桶内向量更少,可能导致空桶                              │
│                                                                 │
│  经验公式:                                                      │
│  nlist ≈ √N 到 4√N                                             │
│                                                                 │
│  示例:                                                          │
│  N = 100 万  → nlist ≈ 1000-4000                               │
│  N = 1000 万 → nlist ≈ 3000-12000                              │
│  N = 1 亿    → nlist ≈ 10000-40000                             │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

6.2 nprobe 选择

┌─────────────────────────────────────────────────────────────────┐
│                    nprobe 选择指南                               │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  nprobe 越大:                                                   │
│  ✅ 召回率更高                                                  │
│  ❌ 搜索延迟更高                                                │
│  ❌ QPS 更低                                                    │
│                                                                 │
│  推荐配置:                                                      │
│  • 高召回场景:nprobe ≈ nlist / 10                              │
│  • 平衡场景:nprobe ≈ nlist / 20                               │
│  • 高吞吐场景:nprobe ≈ nlist / 50                             │
│                                                                 │
│  召回率 vs nprobe 关系:                                        │
│  nprobe / nlist  │  召回率                                      │
│  ────────────────┼────────────                                  │
│  1%             │  85-90%                                       │
│  5%             │  95-98%                                       │
│  10%            │  98-99%                                       │
│  20%            │  99%+                                         │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

6.3 自动调参

def find_optimal_params(
    data: np.ndarray,
    queries: np.ndarray,
    ground_truth: np.ndarray,
    target_recall: float = 0.95,
    k: int = 10
) -> dict:
    """
    自动寻找最优参数
    
    Args:
        data: 数据向量
        queries: 查询向量
        ground_truth: 真实 Top-K
        target_recall: 目标召回率
        k: Top-K
    
    Returns:
        optimal_params: 最优参数
    """
    n = len(data)
    best_params = None
    best_latency = float('inf')
    
    # 搜索空间
    nlist_candidates = [int(np.sqrt(n) * factor) for factor in [1, 2, 4]]
    
    for nlist in nlist_candidates:
        # 训练索引
        index = IVFFlatIndex(d=data.shape[1], nlist=nlist)
        index.train(data)
        index.add(data)
        
        # 二分搜索最小 nprobe 满足目标召回率
        low, high = 1, nlist
        while low < high:
            mid = (low + high) // 2
            
            # 测试召回率
            indices, _ = index.search_batch(queries, k, nprobe=mid)
            recall = compute_recall(indices, ground_truth)
            
            if recall >= target_recall:
                high = mid
            else:
                low = mid + 1
        
        # 测试延迟
        import time
        start = time.time()
        for _ in range(10):
            index.search_batch(queries, k, nprobe=low)
        latency = (time.time() - start) / len(queries) / 10 * 1000
        
        if latency < best_latency:
            best_latency = latency
            best_params = {"nlist": nlist, "nprobe": low}
    
    return best_params

七、Milvus 中的 IVF 实现

7.1 索引创建

from pymilvus import Collection, connections

# 连接 Milvus
connections.connect(host="localhost", port="19530")
collection = Collection("example_collection")

# 创建 IVF-Flat 索引
index_params = {
    "metric_type": "L2",
    "index_type": "IVF_FLAT",
    "params": {"nlist": 1024}
}
collection.create_index(field_name="embedding", index_params=index_params)

# 创建 IVF-PQ 索引
index_params = {
    "metric_type": "L2",
    "index_type": "IVF_PQ",
    "params": {
        "nlist": 1024,
        "m": 16,      # PQ 子向量数
        "nbits": 8    # 每个子向量的编码位数
    }
}
collection.create_index(field_name="embedding", index_params=index_params)

# 创建 IVF-SQ8 索引
index_params = {
    "metric_type": "L2",
    "index_type": "IVF_SQ8",
    "params": {"nlist": 1024}
}
collection.create_index(field_name="embedding", index_params=index_params)

7.2 搜索参数

# IVF-Flat 搜索
search_params = {
    "metric_type": "L2",
    "params": {"nprobe": 64}
}
results = collection.search(
    data=[query_vector],
    anns_field="embedding",
    param=search_params,
    limit=10
)

# IVF-PQ 搜索
search_params = {
    "metric_type": "L2",
    "params": {"nprobe": 64}
}

# IVF-SQ8 搜索
search_params = {
    "metric_type": "L2",
    "params": {"nprobe": 64}
}

7.3 源码架构

┌─────────────────────────────────────────────────────────────────┐
│                    Milvus IVF 源码架构                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  pkg/                                                           │
│  ├── indexbuilder/                                              │
│  │   └── indexbuilder.go      # 索引构建入口                   │
│  │                                                             │
│  ├── indexnode/                                                 │
│  │   └── indexnode.go         # 索引构建执行                   │
│  │                                                             │
│  └── segcore/                                                   │
│      ├── index_cg.h           # 索引接口定义                   │
│      └── indexivf.h           # IVF 索引实现                   │
│                                                                 │
│  内部依赖 Knowhere(Faiss 封装):                              │
│  knowhere/                                                      │
│  ├── index/vector_index/                                        │
│  │   ├── ivf.cc               # IVF 索引封装                   │
│  │   ├── ivf_pq.cc            # IVF-PQ 封装                    │
│  │   └── ivf_sq8.cc           # IVF-SQ8 封装                   │
│  └── clustering/                                                │
│      └── kmeans.cpp           # K-Means 实现                   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

八、性能对比

8.1 基准测试

"""
基准测试结果(SIFT-1M 数据集,N=100万,D=128,K=10)

硬件:Intel i7-12700K, 32GB DDR5, NVMe SSD
"""

benchmark_results = {
    "FLAT": {
        "recall": 1.000,
        "latency_ms": 12.5,
        "memory_gb": 0.49,
        "build_time_s": 0,
    },
    "IVF_FLAT (nlist=1024, nprobe=64)": {
        "recall": 0.975,
        "latency_ms": 0.42,
        "memory_gb": 0.50,
        "build_time_s": 15,
    },
    "IVF_PQ (nlist=1024, m=16, nprobe=64)": {
        "recall": 0.910,
        "latency_ms": 0.18,
        "memory_gb": 0.03,
        "build_time_s": 45,
    },
    "IVF_SQ8 (nlist=1024, nprobe=64)": {
        "recall": 0.965,
        "latency_ms": 0.25,
        "memory_gb": 0.13,
        "build_time_s": 20,
    },
}

8.2 场景选择

┌─────────────────────────────────────────────────────────────────┐
│                    IVF 索引选择指南                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  内存充足 + 高召回要求:                                         │
│  └─► IVF_FLAT                                                  │
│      召回率最高,延迟适中                                       │
│                                                                 │
│  内存受限 + 可接受精度损失:                                     │
│  └─► IVF_PQ                                                    │
│      压缩比最高,延迟最低                                       │
│                                                                 │
│  内存受限 + 较高召回要求:                                       │
│  └─► IVF_SQ8                                                   │
│      平衡压缩比和召回率                                         │
│                                                                 │
│  数据量较小(<10万):                                           │
│  └─► FLAT                                                      │
│      无需索引,精度最高                                         │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

总结

本文深入分析了 IVF 索引家族的核心原理,包括:

  1. IVF 核心原理:聚类划分 + 倒排索引结构
  2. K-Means 聚类:K-Means++ 初始化、聚类质量评估
  3. IVF-Flat:基础实现、内存占用分析
  4. IVF-PQ:结合 PQ 量化实现高压缩比
  5. IVF-SQ8:标量量化实现平衡方案
  6. 参数调优:nlist、nprobe 选择指南
  7. Milvus 实现:索引创建、搜索参数配置

下一章将深入分析 HNSW 图索引的原理和实现,包括图结构设计、搜索算法和性能优化。

参考资料

分享: