第一章:向量检索的工程本质与架构选型

第一章:向量检索的工程本质与架构选型

开篇语

语义理解已经成为 AI 应用的核心竞争力,向量检索不再只是"优雅的算法",而是支撑百万级并发、毫秒级响应的工程基础设施。这一章,我们会深入探讨向量检索的工程本质------从数学原理到架构决策,从单机原型到企业级生产部署。


1.1 向量检索解决了什么问题?

1.1.1 传统关键词检索的瓶颈

传统搜索引擎依赖倒排索引,通过精确的词项匹配返回结果。这种方法在以下场景力不从心:

  1. 同义词问题:用户搜索"手机",无法匹配包含"智能手机"、"移动电话"的文档
  2. 多语言问题:中文"人工智能"与英文"Artificial Intelligence"语义相同但字符不同
  3. 上下文依赖:"苹果"是指水果还是公司?需要上下文才能判断
  4. 长尾查询:低频、口语化、错别字的查询无法精确匹配

工程本质:关键词检索是离散的符号匹配,而人类语言是连续的语义空间。向量检索通过 Embedding 技术,将离散符号映射到连续向量空间,实现语义级别的匹配。

1.1.2 Embedding 如何将"语义"转化为向量

Embedding 模型(如 BERT、Sentence-BERT、text-embedding-3)通过深度神经网络,将文本转换为高维空间中的点(通常 768 维或 1536 维)。语义相似的文本,其向量在空间中距离更近。

对于给定的文本对 (T1,T2)(T_1, T_2)(T1,T2),Embedding 模型输出向量 v1,v2∈Rd\mathbf{v}_1, \mathbf{v}_2 \in \mathbb{R}^dv1,v2∈Rd,其中 ddd 是向量维度。

语义相似度通过向量夹角余弦衡量:

cosine_similarity(v1,v2)=v1⋅v2∥v1∥∥v2∥=∑i=1dv1iv2i∑i=1dv1i2∑i=1dv2i2 \text{cosine\similarity}(\mathbf{v}1, \mathbf{v}2) = \frac{\mathbf{v}1 \cdot \mathbf{v}2}{\|\mathbf{v}1\| \|\mathbf{v}2\|} = \frac{\sum{i=1}^d v{1i} v{2i}}{\sqrt{\sum{i=1}^d v{1i}^2} \sqrt{\sum{i=1}^d v{2i}^2}} cosine_similarity(v1,v2)=∥v1∥∥v2∥v1⋅v2=∑i=1dv1i2 ∑i=1dv2i2 ∑i=1dv1iv2i

值域 −1,1-1, 1−1,1,值越大表示语义越相似。

企业级工程陷阱

⚠️ 陷阱 1:Embedding 版本漂移

某电商公司在 2023 年使用 text-embedding-ada-002 构建了 1000 万商品的向量索引。2024 年 OpenAI 发布新版本 text-embedding-3-large,精度更高。但直接替换模型会导致新旧向量不在同一个语义空间,混合检索结果混乱。

解决方案

  1. 双写策略:同时用新旧模型生成向量,逐步迁移
  2. 版本化存储:在向量字段中标记 embedding_version
  3. A/B 测试:对比新旧模型的召回率和业务指标

1.1.3 向量检索的核心指标:召回率 vs 延迟 vs 内存

向量检索的本质是在高维空间中寻找最近邻(Nearest Neighbor Search, NNS)。精确搜索(Brute Force)的时间复杂度为 O(N⋅d)O(N \cdot d)O(N⋅d),NNN 是向量数量,ddd 是维度。当 N>106N > 10^6N>106 时,精确搜索不可接受。

近似最近邻搜索(Approximate Nearest Neighbor, ANN) 通过牺牲少量精度,将复杂度降低到 O(log⁡N)O(\log N)O(logN) 或 O(1)O(1)O(1)。

核心指标体系

指标 定义 典型值 优化方向
召回率 (Recall) 返回的结果中包含真实最近邻的比例 0.85~0.99 增加候选集大小(efSearch)
延迟 (Latency) 单次查询的响应时间 10ms~200ms 优化索引结构、量化、缓存
内存 (Memory) 存储索引所需的内存空间 1GB~100GB 量化(INT8/INT4)、压缩
QPS (Queries Per Second) 系统每秒处理的查询数 100~10000 并行化、负载均衡、缓存
写入吞吐 (Ingestion Rate) 每秒可写入的向量数 1000~50000 批量写入、异步索引构建

企业级工程案例

某金融风控公司需要实时检索 5000 万笔交易记录的相似度,要求 P99 延迟 < 50ms,召回率 > 0.95。

初始方案(失败):

  • 使用 Faiss IndexFlatL2(暴力搜索),召回率 = 1.0,但延迟 > 2000ms
  • 无法满足实时风控需求

优化方案(成功):

  • 使用 Faiss IndexHNSW(Hierarchical Navigable Small World)
  • 参数调优:M=32, efConstruction=200, efSearch=128
  • 结果:召回率 = 0.96,P99 延迟 = 35ms,内存占用 = 28GB

关键代码(Faiss HNSW 调优):

python 复制代码
import faiss
import numpy as np
from typing import Tuple

def build_hnsw_index(
    dimension: int,
    m: int = 32,
    ef_construction: int = 200
) -> faiss.IndexHNSWFlat:
    """
    构建 HNSW 索引
    
    Args:
        dimension: 向量维度
        m: 每个节点的双向链接数(越大召回率越高,但内存和计算量越大)
        ef_construction: 构建时的候选集大小(越大索引质量越高,但构建越慢)
    
    Returns:
        Faiss HNSW 索引
    """
    # 使用 L2 距离(也可使用 Inner Product)
    quantizer = faiss.IndexFlatL2(dimension)
    index = faiss.IndexHNSWFlat(quantizer, m)
    
    # 设置构建参数
    index.hnsw.efConstruction = ef_construction
    
    return index

def search_with_recall_optimization(
    index: faiss.IndexHNSWFlat,
    query_vector: np.ndarray,
    k: int = 10,
    ef_search: int = 128
) -> Tuple[np.ndarray, np.ndarray]:
    """
    带召回率优化的搜索
    
    Args:
        index: HNSW 索引
        query_vector: 查询向量 (1, dimension)
        k: 返回的近邻数
        ef_search: 搜索时的候选集大小(越大召回率越高,但延迟越大)
    
    Returns:
        distances: 距离数组
        indices: 索引数组
    """
    # 动态调整 ef_search 以平衡召回率和延迟
    index.hnsw.efSearch = ef_search
    
    # 执行搜索
    distances, indices = index.search(query_vector, k)
    
    return distances, indices

# 使用示例
dimension = 768
index = build_hnsw_index(dimension, m=32, ef_construction=200)

# 添加向量(假设有 100 万条)
num_vectors = 1_000_000
vectors = np.random.rand(num_vectors, dimension).astype('float32')
index.add(vectors)

# 搜索
query = np.random.rand(1, dimension).astype('float32')
distances, indices = search_with_recall_optimization(
    index, query, k=10, ef_search=128
)
print(f"召回率(估计): {estimate_recall(index, query, k=10):.3f}")

1.1.4 企业级工程陷阱:为什么 Faiss 不够用

Faiss 是 Facebook 开源的向量检索库,性能卓越,但在企业级生产环境中,仅靠 Faiss 会遇到以下问题:

问题 描述 影响
无持久化 Faiss 索引存储在内存中,进程重启后丢失 需要重新加载索引(耗时数分钟到数小时)
无分布式支持 无法跨多台机器扩展 单机内存上限(通常 256GB~1TB)
无并发写入 写入时索引锁定,阻塞读取 实时更新场景不可用
无多租户隔离 所有向量在同一个索引中 数据泄露风险、性能隔离困难
无监控告警 无法监控 QPS、延迟、召回率 故障发现滞后

解决方案:使用专用向量数据库(Milvus、Qdrant、Weaviate),它们底层可能使用 Faiss/HNSW 作为索引引擎,但提供了企业级特性。

Mermaid 架构图:从 Faiss 到向量数据库的演进
#mermaid-svg-VyPdpGI9wbcDrPIk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VyPdpGI9wbcDrPIk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VyPdpGI9wbcDrPIk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VyPdpGI9wbcDrPIk .error-icon{fill:#552222;}#mermaid-svg-VyPdpGI9wbcDrPIk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VyPdpGI9wbcDrPIk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VyPdpGI9wbcDrPIk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VyPdpGI9wbcDrPIk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VyPdpGI9wbcDrPIk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VyPdpGI9wbcDrPIk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VyPdpGI9wbcDrPIk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VyPdpGI9wbcDrPIk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VyPdpGI9wbcDrPIk .marker.cross{stroke:#333333;}#mermaid-svg-VyPdpGI9wbcDrPIk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VyPdpGI9wbcDrPIk p{margin:0;}#mermaid-svg-VyPdpGI9wbcDrPIk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-VyPdpGI9wbcDrPIk .cluster-label text{fill:#333;}#mermaid-svg-VyPdpGI9wbcDrPIk .cluster-label span{color:#333;}#mermaid-svg-VyPdpGI9wbcDrPIk .cluster-label span p{background-color:transparent;}#mermaid-svg-VyPdpGI9wbcDrPIk .label text,#mermaid-svg-VyPdpGI9wbcDrPIk span{fill:#333;color:#333;}#mermaid-svg-VyPdpGI9wbcDrPIk .node rect,#mermaid-svg-VyPdpGI9wbcDrPIk .node circle,#mermaid-svg-VyPdpGI9wbcDrPIk .node ellipse,#mermaid-svg-VyPdpGI9wbcDrPIk .node polygon,#mermaid-svg-VyPdpGI9wbcDrPIk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-VyPdpGI9wbcDrPIk .rough-node .label text,#mermaid-svg-VyPdpGI9wbcDrPIk .node .label text,#mermaid-svg-VyPdpGI9wbcDrPIk .image-shape .label,#mermaid-svg-VyPdpGI9wbcDrPIk .icon-shape .label{text-anchor:middle;}#mermaid-svg-VyPdpGI9wbcDrPIk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-VyPdpGI9wbcDrPIk .rough-node .label,#mermaid-svg-VyPdpGI9wbcDrPIk .node .label,#mermaid-svg-VyPdpGI9wbcDrPIk .image-shape .label,#mermaid-svg-VyPdpGI9wbcDrPIk .icon-shape .label{text-align:center;}#mermaid-svg-VyPdpGI9wbcDrPIk .node.clickable{cursor:pointer;}#mermaid-svg-VyPdpGI9wbcDrPIk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-VyPdpGI9wbcDrPIk .arrowheadPath{fill:#333333;}#mermaid-svg-VyPdpGI9wbcDrPIk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-VyPdpGI9wbcDrPIk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-VyPdpGI9wbcDrPIk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VyPdpGI9wbcDrPIk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-VyPdpGI9wbcDrPIk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VyPdpGI9wbcDrPIk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-VyPdpGI9wbcDrPIk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-VyPdpGI9wbcDrPIk .cluster text{fill:#333;}#mermaid-svg-VyPdpGI9wbcDrPIk .cluster span{color:#333;}#mermaid-svg-VyPdpGI9wbcDrPIk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-VyPdpGI9wbcDrPIk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-VyPdpGI9wbcDrPIk rect.text{fill:none;stroke-width:0;}#mermaid-svg-VyPdpGI9wbcDrPIk .icon-shape,#mermaid-svg-VyPdpGI9wbcDrPIk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VyPdpGI9wbcDrPIk .icon-shape p,#mermaid-svg-VyPdpGI9wbcDrPIk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-VyPdpGI9wbcDrPIk .icon-shape .label rect,#mermaid-svg-VyPdpGI9wbcDrPIk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VyPdpGI9wbcDrPIk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-VyPdpGI9wbcDrPIk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-VyPdpGI9wbcDrPIk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 向量数据库(生产阶段)
应用服务器
负载均衡器
向量数据库集群
查询节点
数据节点
索引节点
分布式 Faiss/HNSW
持久化存储

对象存储/本地磁盘
单机 Faiss(原型阶段)
应用服务器
Faiss 索引

内存存储
持久化到磁盘

进程重启后重新加载


1.2 主流向量数据库对比与选型决策树

1.2.1 Milvus、Qdrant、Weaviate、PgVector、Elasticsearch 的横向对比

特性 Milvus Qdrant Weaviate PgVector Elasticsearch
开源协议 Apache 2.0 Apache 2.0 BSD 3-Clause PostgreSQL 扩展 Dual (SSPL/Elastic)
开发语言 Go + C++ Rust Go C (PostgreSQL) Java
部署复杂度 高(依赖 etcd、MinIO) 低(单二进制) 中(依赖 S3/MinIO) 低(集成 PostgreSQL) 高(依赖 JVM、集群协调)
性能 (QPS) ⭐⭐⭐⭐⭐ (10K+) ⭐⭐⭐⭐ (5K+) ⭐⭐⭐ (2K+) ⭐⭐ (500-) ⭐⭐⭐ (1K+)
内存效率 ⭐⭐⭐⭐ (量化支持好) ⭐⭐⭐⭐⭐ (Rust 零成本抽象) ⭐⭐⭐ (Go GC 开销) ⭐⭐ (PostgreSQL 内存管理) ⭐⭐ (JVM 堆外内存)
分布式架构 原生支持(shared-nothing) 支持(Raft 共识) 支持(无共识,最终一致) 依赖 PostgreSQL 流复制 原生支持(分片+副本)
多模态支持 向量 + 标量 + 稀疏向量 向量 + 标量 + 稀疏向量 向量 + 标量 + 跨模态模块 向量 + 关系型数据 向量 + 全文搜索 + 地理
混合检索 向量 + 标量过滤(优秀) 向量 + 标量过滤(优秀) 向量 + 标量过滤(良好) 向量 + SQL WHERE(优秀) 向量 + BM25(优秀)
实时更新 支持(异步索引构建) 支持(立即可见) 支持(最终一致) 支持(事务保证) 支持(近实时刷新)
多租户隔离 Partition Key(优秀) Collection 级别(良好) Multi-tenancy 模块(良好) Schema 级别(良好) Index 级别(一般)
监控运维 Prometheus + Grafana(完善) Prometheus + 内置 Dashboard Prometheus + 内置指标 PostgreSQL 监控(成熟) Kibana + monitoring(完善)
社区生态 ⭐⭐⭐⭐⭐ (CNCF 项目) ⭐⭐⭐⭐ (快速增长) ⭐⭐⭐⭐ (CNCF 项目) ⭐⭐⭐ (PostgreSQL 社区) ⭐⭐⭐⭐ (Elastic 生态)
企业版特性 角色权限、审计日志、备份恢复 云托管、高级过滤 云托管、核心模块增强 无(完全开源) 安全、机器学习、告警

深度分析

  1. Milvus:适合大规模(亿级向量)、高并发(万级 QPS)、多模态(文本+图像+音频)场景。缺点是部署复杂(需要 Kubernetes),小规模场景(百万级向量)杀鸡用牛刀。

  2. Qdrant:适合中小规模(千万级向量)、追求低延迟(Rust 实现)、简化部署(单二进制)的场景。内存效率极高,适合边缘计算。

  3. Weaviate:适合需要模块化扩展(如自定义 Embedding 模块、跨模态检索)的场景。生态友好(支持 Cohere、HuggingFace、OpenAI 原生集成)。

  4. PgVector:适合已有 PostgreSQL 基础设施、数据规模较小(百万级向量)、需要强事务保证(ACID)的场景。缺点是性能上限低,不适合高并发。

  5. Elasticsearch:适合已有 Elasticsearch 集群、需要向量检索与全文搜索深度融合(Hybrid Search)的场景。缺点是 JVM 内存管理复杂,调优成本高。

1.2.2 选型决策树:数据规模、QPS、运维成本、多模态支持

#mermaid-svg-sjh42G63h4r8Dxea{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sjh42G63h4r8Dxea .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sjh42G63h4r8Dxea .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sjh42G63h4r8Dxea .error-icon{fill:#552222;}#mermaid-svg-sjh42G63h4r8Dxea .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sjh42G63h4r8Dxea .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sjh42G63h4r8Dxea .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sjh42G63h4r8Dxea .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sjh42G63h4r8Dxea .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sjh42G63h4r8Dxea .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sjh42G63h4r8Dxea .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sjh42G63h4r8Dxea .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sjh42G63h4r8Dxea .marker.cross{stroke:#333333;}#mermaid-svg-sjh42G63h4r8Dxea svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sjh42G63h4r8Dxea p{margin:0;}#mermaid-svg-sjh42G63h4r8Dxea .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-sjh42G63h4r8Dxea .cluster-label text{fill:#333;}#mermaid-svg-sjh42G63h4r8Dxea .cluster-label span{color:#333;}#mermaid-svg-sjh42G63h4r8Dxea .cluster-label span p{background-color:transparent;}#mermaid-svg-sjh42G63h4r8Dxea .label text,#mermaid-svg-sjh42G63h4r8Dxea span{fill:#333;color:#333;}#mermaid-svg-sjh42G63h4r8Dxea .node rect,#mermaid-svg-sjh42G63h4r8Dxea .node circle,#mermaid-svg-sjh42G63h4r8Dxea .node ellipse,#mermaid-svg-sjh42G63h4r8Dxea .node polygon,#mermaid-svg-sjh42G63h4r8Dxea .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-sjh42G63h4r8Dxea .rough-node .label text,#mermaid-svg-sjh42G63h4r8Dxea .node .label text,#mermaid-svg-sjh42G63h4r8Dxea .image-shape .label,#mermaid-svg-sjh42G63h4r8Dxea .icon-shape .label{text-anchor:middle;}#mermaid-svg-sjh42G63h4r8Dxea .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-sjh42G63h4r8Dxea .rough-node .label,#mermaid-svg-sjh42G63h4r8Dxea .node .label,#mermaid-svg-sjh42G63h4r8Dxea .image-shape .label,#mermaid-svg-sjh42G63h4r8Dxea .icon-shape .label{text-align:center;}#mermaid-svg-sjh42G63h4r8Dxea .node.clickable{cursor:pointer;}#mermaid-svg-sjh42G63h4r8Dxea .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-sjh42G63h4r8Dxea .arrowheadPath{fill:#333333;}#mermaid-svg-sjh42G63h4r8Dxea .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-sjh42G63h4r8Dxea .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-sjh42G63h4r8Dxea .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sjh42G63h4r8Dxea .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-sjh42G63h4r8Dxea .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sjh42G63h4r8Dxea .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-sjh42G63h4r8Dxea .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-sjh42G63h4r8Dxea .cluster text{fill:#333;}#mermaid-svg-sjh42G63h4r8Dxea .cluster span{color:#333;}#mermaid-svg-sjh42G63h4r8Dxea div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-sjh42G63h4r8Dxea .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-sjh42G63h4r8Dxea rect.text{fill:none;stroke-width:0;}#mermaid-svg-sjh42G63h4r8Dxea .icon-shape,#mermaid-svg-sjh42G63h4r8Dxea .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sjh42G63h4r8Dxea .icon-shape p,#mermaid-svg-sjh42G63h4r8Dxea .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-sjh42G63h4r8Dxea .icon-shape .label rect,#mermaid-svg-sjh42G63h4r8Dxea .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sjh42G63h4r8Dxea .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-sjh42G63h4r8Dxea .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-sjh42G63h4r8Dxea :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} < 100 万向量

(小规模)
100 万 ~ 1 亿向量

(中规模)
> 1 亿向量

(大规模)
是(PostgreSQL)

是(P99 < 20ms)
否(P99 < 100ms)
是(向量+关键词)

是(文本+图像+音频)
否(仅文本)
是(QPS > 5000)
否(QPS < 5000)
开始选型
数据规模?
已有数据库?
追求低延迟?
需要多模态?
PgVector

利用现有基础设施
Qdrant

单二进制部署
Qdrant

Rust 零成本抽象
需要混合检索?
Elasticsearch

BM25 + 向量融合
Milvus

简化部署版
Milvus

多向量字段支持
高并发?
Milvus 集群

分布式架构
Weaviate

模块化设计

决策树使用指南

  1. 第一跳:数据规模

    • < 100 万:小规模,优先考虑部署简单
    • 100 万 ~ 1 亿:中规模,平衡性能和复杂度
    • 1 亿:大规模,必须分布式

  2. 第二跳:性能需求

    • 低延迟(P99 < 20ms):Qdrant(Rust)
    • 高并发(QPS > 5000):Milvus 集群
    • 混合检索:Elasticsearch 或 Milvus
  3. 第三跳:运维成本

    • 无专职运维:Qdrant(单二进制)或 PgVector(集成现有数据库)
    • 有专职运维:Milvus 或 Weaviate(Kubernetes 部署)
  4. 第四跳:多模态支持

    • 需要:Milvus(多向量字段)
    • 不需要:根据前三跳决策

1.2.3 企业级工程案例:从单机 Faiss 迁移到 Milvus 集群的真实踩坑记录

背景:某 AI 客服公司,2023 年初使用 Faiss 构建知识库检索系统,支撑 10 万条 FAQ 的语义检索。随着业务增长,2024 年中达到 500 万条知识条目,日查询量从 1 万增长到 100 万,单机 Faiss 无法支撑。

迁移目标

  • 数据规模:500 万 → 5000 万(10 倍增长)
  • QPS:100 → 5000(50 倍增长)
  • 延迟:P99 < 100ms
  • 可用性:99.9%(单机无法保证)

迁移过程与踩坑

坑 1:数据迁移期间的业务中断

问题:Faiss 索引文件 50GB,通过网络传输到 Milvus 需要 2 小时。期间系统不可用。

错误做法

python 复制代码
# 停止服务 → 导出 Faiss → 导入 Milvus → 重启服务(停机 4 小时)

正确做法(双写 + 灰度切换):

python 复制代码
from typing import List, Dict
import asyncio
from pymilvus import Collection, connections

class DualWriteMigration:
    """
    双写迁移策略:新旧系统并行写入,逐步切流量
    """
    def __init__(self, faiss_index, milvus_collection: Collection):
        self.faiss = faiss_index
        self.milvus = milvus_collection
        self.migration_mode = "dual_write"  # dual_write, read_from_new, cutoff_old
    
    async def add_vectors(self, vectors: List[List[float]], metadata: List[Dict]):
        """
        双写:同时写入 Faiss 和 Milvus
        """
        # 1. 写入旧系统(Faiss)
        self.faiss.add(np.array(vectors))
        
        # 2. 写入新系统(Milvus)
        entities = [
            vectors,
            [m["id"] for m in metadata],
            [m["content"] for m in metadata],
            [m["timestamp"] for m in metadata]
        ]
        self.milvus.insert(entities)
        
        # 3. 异步构建 Milvus 索引(不阻塞写入)
        asyncio.create_task(self._async_build_index())
    
    async def search(self, query_vector: List[float], top_k: int = 10):
        """
        读取策略:根据迁移阶段选择数据源
        """
        if self.migration_mode == "dual_write":
            # 阶段 1:仍从旧系统读取,验证新系统正确性
            results_faiss = self.faiss.search(query_vector, top_k)
            results_milvus = self.milvus.search(query_vector, top_k)
            
            # 比对结果(召回率验证)
            recall = self._calculate_recall(results_faiss, results_milvus)
            if recall < 0.95:
                logging.warning(f"召回率下降: {recall}")
            
            return results_faiss  # 暂时返回旧系统结果
        
        elif self.migration_mode == "read_from_new":
            # 阶段 2:从新系统读取,旧系统兜底
            try:
                return self.milvus.search(query_vector, top_k)
            except Exception as e:
                logging.error(f"新系统故障,降级到旧系统: {e}")
                return self.faiss.search(query_vector, top_k)
        
        else:  # cutoff_old
            # 阶段 3:完全切换到新系统
            return self.milvus.search(query_vector, top_k)
    
    def _calculate_recall(self, results_faiss, results_milvus) -> float:
        """
        计算召回率:新系统返回的结果中,有多少在旧系统的 top-K 中
        """
        set_faiss = set([r["id"] for r in results_faiss])
        set_milvus = set([r["id"] for r in results_milvus])
        return len(set_milvus & set_faiss) / len(set_faiss)

# 迁移步骤
# 第 1 周:开启 dual_write,验证数据一致性
# 第 2 周:切换到 read_from_new,监控新系统稳定性
# 第 3 周:切换到 cutoff_old,下线旧系统
坑 2:Milvus 索引构建期间查询性能骤降

问题:Milvus 默认在每次写入后自动构建索引,导致写入高峰期间查询延迟从 20ms 飙升到 500ms。

原因分析:索引构建(HNSW)是 CPU 密集型的,与查询竞争计算资源。

解决方案

python 复制代码
from pymilvus import Collection, Index

# 错误做法:自动构建索引
collection.create_index(
    field_name="embedding",
    index_params={"index_type": "HNSW", "metric_type": "L2", "params": {"M": 16, "efConstruction": 200}}
)
# 每次 insert 后自动触发索引构建 → 查询性能抖动

# 正确做法:手动控制索引构建时机
collection.create_index(
    field_name="embedding",
    index_params={
        "index_type": "HNSW",
        "metric_type": "L2",
        "params": {"M": 16, "efConstruction": 200},
        "index_build_policy": "USER"  # 手动触发
    }
)

# 在低峰期(凌晨 2 点)批量构建索引
import schedule
import time

def nightly_index_build():
    """
    每天凌晨 2 点构建索引
    """
    logging.info("开始夜间索引构建...")
    collection.flush()  # 确保数据落盘
    collection.compact()  # 合并小 segment,提升查询性能
    logging.info("索引构建完成")

schedule.every().day.at("02:00").do(nightly_index_build)

while True:
    schedule.run_pending()
    time.sleep(60)
坑 3:多租户数据隔离实现错误

问题 :使用 Milvus 的 partition_key 实现多租户隔离,但某个大租户(占 80% 数据)的查询拖慢了所有租户。

错误做法

python 复制代码
# 所有租户共享一个 Collection,用 partition_key 区分
schema = {"fields": [
    {"name": "id", "type": "Int64", "is_primary": True},
    {"name": "embedding", "type": "FloatVector", "dim": 768},
    {"name": "tenant_id", "type": "Int64", "partition_key": True}  # 分区键
]}
# 问题:大租户的向量集中在少数 partition,查询时这些 partition 成为热点

正确做法(混合策略):

python 复制代码
from enum import Enum

class TenantStrategy(Enum):
    SHARED_PARTITION = "shared"  # 小租户:共享 partition
    DEDICATED_COLLECTION = "dedicated"  # 大租户:独立 collection

class MultiTenantVectorDB:
    """
    多租户向量数据库:根据租户大小动态选择隔离策略
    """
    def __init__(self, milvus_client):
        self.client = milvus_client
        self.tenant_size_threshold = 1_000_000  # 100 万向量以上用独立 collection
    
    def get_collection_for_tenant(self, tenant_id: int, vector_count: int) -> str:
        """
        根据租户大小返回对应的 collection 名称
        
        Args:
            tenant_id: 租户 ID
            vector_count: 该租户的向量数量
        
        Returns:
            collection_name: 使用的 collection 名称
        """
        if vector_count > self.tenant_size_threshold:
            # 大租户:独立 collection
            return f"tenant_{tenant_id}_vectors"
        else:
            # 小租户:共享 collection,用 partition_key 隔离
            return "shared_vectors"
    
    async def search(self, tenant_id: int, query_vector: List[float], top_k: int = 10):
        """
        多租户感知的搜索
        """
        # 1. 获取租户的 collection
        collection_name = self.get_collection_for_tenant(tenant_id, self._get_tenant_size(tenant_id))
        collection = Collection(collection_name)
        
        # 2. 如果是共享 collection,添加 partition 过滤
        if collection_name == "shared_vectors":
            search_params = {"partition_key": tenant_id}
        else:
            search_params = {}
        
        # 3. 执行查询
        results = collection.search(
            data=[query_vector],
            anns_field="embedding",
            param={"metric_type": "L2", "params": {"ef": 64}},
            limit=top_k,
            expr=f"tenant_id == {tenant_id}",  # 强制过滤(安全隔离)
            **search_params
        )
        
        return results
    
    def _get_tenant_size(self, tenant_id: int) -> int:
        """
        查询租户的向量数量(从元数据表获取,避免全表扫描)
        """
        # 实际实现:查询元数据数据库
        return self.metadata_db.get_vector_count(tenant_id)

Mermaid 架构图:多租户隔离策略
#mermaid-svg-VM6odvqluS57k2Su{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VM6odvqluS57k2Su .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VM6odvqluS57k2Su .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VM6odvqluS57k2Su .error-icon{fill:#552222;}#mermaid-svg-VM6odvqluS57k2Su .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VM6odvqluS57k2Su .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VM6odvqluS57k2Su .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VM6odvqluS57k2Su .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VM6odvqluS57k2Su .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VM6odvqluS57k2Su .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VM6odvqluS57k2Su .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VM6odvqluS57k2Su .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VM6odvqluS57k2Su .marker.cross{stroke:#333333;}#mermaid-svg-VM6odvqluS57k2Su svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VM6odvqluS57k2Su p{margin:0;}#mermaid-svg-VM6odvqluS57k2Su .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-VM6odvqluS57k2Su .cluster-label text{fill:#333;}#mermaid-svg-VM6odvqluS57k2Su .cluster-label span{color:#333;}#mermaid-svg-VM6odvqluS57k2Su .cluster-label span p{background-color:transparent;}#mermaid-svg-VM6odvqluS57k2Su .label text,#mermaid-svg-VM6odvqluS57k2Su span{fill:#333;color:#333;}#mermaid-svg-VM6odvqluS57k2Su .node rect,#mermaid-svg-VM6odvqluS57k2Su .node circle,#mermaid-svg-VM6odvqluS57k2Su .node ellipse,#mermaid-svg-VM6odvqluS57k2Su .node polygon,#mermaid-svg-VM6odvqluS57k2Su .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-VM6odvqluS57k2Su .rough-node .label text,#mermaid-svg-VM6odvqluS57k2Su .node .label text,#mermaid-svg-VM6odvqluS57k2Su .image-shape .label,#mermaid-svg-VM6odvqluS57k2Su .icon-shape .label{text-anchor:middle;}#mermaid-svg-VM6odvqluS57k2Su .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-VM6odvqluS57k2Su .rough-node .label,#mermaid-svg-VM6odvqluS57k2Su .node .label,#mermaid-svg-VM6odvqluS57k2Su .image-shape .label,#mermaid-svg-VM6odvqluS57k2Su .icon-shape .label{text-align:center;}#mermaid-svg-VM6odvqluS57k2Su .node.clickable{cursor:pointer;}#mermaid-svg-VM6odvqluS57k2Su .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-VM6odvqluS57k2Su .arrowheadPath{fill:#333333;}#mermaid-svg-VM6odvqluS57k2Su .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-VM6odvqluS57k2Su .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-VM6odvqluS57k2Su .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VM6odvqluS57k2Su .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-VM6odvqluS57k2Su .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VM6odvqluS57k2Su .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-VM6odvqluS57k2Su .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-VM6odvqluS57k2Su .cluster text{fill:#333;}#mermaid-svg-VM6odvqluS57k2Su .cluster span{color:#333;}#mermaid-svg-VM6odvqluS57k2Su div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-VM6odvqluS57k2Su .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-VM6odvqluS57k2Su rect.text{fill:none;stroke-width:0;}#mermaid-svg-VM6odvqluS57k2Su .icon-shape,#mermaid-svg-VM6odvqluS57k2Su .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VM6odvqluS57k2Su .icon-shape p,#mermaid-svg-VM6odvqluS57k2Su .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-VM6odvqluS57k2Su .icon-shape .label rect,#mermaid-svg-VM6odvqluS57k2Su .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VM6odvqluS57k2Su .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-VM6odvqluS57k2Su .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-VM6odvqluS57k2Su :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 大租户(≥ 100 万向量)
小租户(< 100 万向量)
Tenant A
shared_vectors

partition_key=tenant_id
Tenant B
Tenant C
Tenant X

500 万向量
tenant_X_vectors

独立 collection
Tenant Y

1000 万向量
tenant_Y_vectors

独立 collection
查询节点 1
查询节点 2
查询节点 3

专用资源
查询节点 4

专用资源


1.3 混合检索:向量+关键词+结构化过滤

1.3.1 为什么纯向量检索不够用

纯向量检索(Dense Retrieval)通过语义相似度匹配,但在以下场景存在缺陷:

  1. 精确匹配需求:用户搜索"iPhone 15 Pro Max 256GB",向量检索可能返回"iPhone 15 Pro 128GB"(语义相似但规格错误)
  2. 稀有实体:向量检索对长尾实体(如专业术语、产品型号)的区分度不足
  3. 多意图查询:用户搜索"Python 教程 PDF",既需要语义相似的教程(向量),也需要格式为 PDF 的资源(结构化过滤)
  4. 可解释性:向量检索返回的结果难以解释"为什么相关",而关键词检索可以高亮匹配词

解决方案:混合检索(Hybrid Retrieval)= 向量检索(语义) + 关键词检索(精确) + 结构化过滤(约束)

1.3.2 RRF(Reciprocal Rank Fusion)算法详解

RRF 是一种将多个召回列表融合为单个排序的算法,由 Cormack 等人在 2009 年提出。核心思想:排名越靠前的结果,得分越高;多个列表都排名靠前的结果,最终排名更高。

数学公式

给定 NNN 个召回列表 R1,R2,...,RNR_1, R_2, \ldots, R_NR1,R2,...,RN,对于文档 ddd,其 RRF 得分为:

RRFscore(d)=∑i=1N1k+ranki(d) \text{RRFscore}(d) = \sum_{i=1}^{N} \frac{1}{k + \text{rank}_i(d)} RRFscore(d)=i=1∑Nk+ranki(d)1

其中:

  • ranki(d)\text{rank}_i(d)ranki(d) 是文档 ddd 在第 iii 个召回列表中的排名(从 1 开始)
  • kkk 是一个常数(通常取 60),用于调节低频列表的影响
  • 如果 ddd 不在第 iii 个列表中,则 ranki(d)=∞\text{rank}_i(d) = \inftyranki(d)=∞,该项为 0

Python 实现(生产级)

python 复制代码
from typing import List, Dict, Tuple
import heapq

class RRFusion:
    """
    Reciprocal Rank Fusion 实现
    
    用途:融合多个召回源的结果(向量检索、BM25、知识图谱等)
    """
    def __init__(self, k: int = 60):
        """
        Args:
            k: RRF 常数,调节低频列表的影响(默认 60,来源于原始论文)
        """
        self.k = k
    
    def fuse(self, recall_lists: List[List[Tuple[str, float]]]) -> List[Tuple[str, float]]:
        """
        融合多个召回列表
        
        Args:
            recall_lists: 每个元素是 [(doc_id, score), ...],按 score 降序排列
        
        Returns:
            融合后的 [(doc_id, rrf_score), ...],按 rrf_score 降序排列
        """
        # 1. 统计每个文档的 RRF 得分
        doc_scores: Dict[str, float] = {}
        
        for recall_list in recall_lists:
            for rank, (doc_id, original_score) in enumerate(recall_list, start=1):
                if doc_id not in doc_scores:
                    doc_scores[doc_id] = 0.0
                doc_scores[doc_id] += 1.0 / (self.k + rank)
        
        # 2. 按 RRF 得分排序
        fused_list = sorted(doc_scores.items(), key=lambda x: x[1], reverse=True)
        
        return fused_list
    
    def fuse_with_weights(self, recall_lists: List[List[Tuple[str, float]]], weights: List[float]) -> List[Tuple[str, float]]:
        """
        带权重的 RRF 融合(某些召回源更重要)
        
        Args:
            recall_lists: 召回列表
            weights: 每个召回源的权重(和为 1)
        """
        assert len(recall_lists) == len(weights), "召回列表数量与权重数量必须一致"
        
        doc_scores: Dict[str, float] = {}
        
        for recall_list, weight in zip(recall_lists, weights):
            for rank, (doc_id, _) in enumerate(recall_list, start=1):
                if doc_id not in doc_scores:
                    doc_scores[doc_id] = 0.0
                doc_scores[doc_id] += weight * (1.0 / (self.k + rank))
        
        return sorted(doc_scores.items(), key=lambda x: x[1], reverse=True)

# 使用示例
rrf = RRFusion(k=60)

# 模拟三个召回源的结果
vector_recall = [("doc1", 0.95), ("doc2", 0.87), ("doc3", 0.76)]  # 向量检索
bm25_recall = [("doc2", 12.5), ("doc1", 10.2), ("doc4", 8.9)]  # BM25 关键词
kg_recall = [("doc1", 0.99), ("doc5", 0.94)]  # 知识图谱

# 融合
fused = rrf.fuse([vector_recall, bm25_recall, kg_recall])
print("融合结果(RRF 得分):")
for doc_id, score in fused[:10]:
    print(f"  {doc_id}: {score:.4f}")

# 输出示例:
# doc1: 0.0331 (在三个列表中分别排第 1、第 2、第 1)
# doc2: 0.0256 (在向量检索排第 2,在 BM25 排第 1)
# doc3: 0.0161 (仅在向量检索排第 3)

企业级工程优化

  1. 截断优化:每个召回列表只取 top-K(如 K=100),避免低频结果引入噪声
  2. 并行召回 :使用 asyncio 并行执行多个召回源,减少端到端延迟
  3. 缓存 RRF 结果:对热门查询,缓存融合后的结果(TTL = 5 分钟)

1.3.3 工程实现:如何并行执行多路检索并合并

完整流程
结果缓存 RRF 融合 结构化过滤 关键词检索 向量检索 查询重写服务 API 网关 用户 结果缓存 RRF 融合 结构化过滤 关键词检索 向量检索 查询重写服务 API 网关 用户 #mermaid-svg-awrCzId1CKWTLjGT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-awrCzId1CKWTLjGT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-awrCzId1CKWTLjGT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-awrCzId1CKWTLjGT .error-icon{fill:#552222;}#mermaid-svg-awrCzId1CKWTLjGT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-awrCzId1CKWTLjGT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-awrCzId1CKWTLjGT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-awrCzId1CKWTLjGT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-awrCzId1CKWTLjGT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-awrCzId1CKWTLjGT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-awrCzId1CKWTLjGT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-awrCzId1CKWTLjGT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-awrCzId1CKWTLjGT .marker.cross{stroke:#333333;}#mermaid-svg-awrCzId1CKWTLjGT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-awrCzId1CKWTLjGT p{margin:0;}#mermaid-svg-awrCzId1CKWTLjGT .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-awrCzId1CKWTLjGT text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-awrCzId1CKWTLjGT .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-awrCzId1CKWTLjGT .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-awrCzId1CKWTLjGT .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-awrCzId1CKWTLjGT .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-awrCzId1CKWTLjGT #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-awrCzId1CKWTLjGT .sequenceNumber{fill:white;}#mermaid-svg-awrCzId1CKWTLjGT #sequencenumber{fill:#333;}#mermaid-svg-awrCzId1CKWTLjGT #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-awrCzId1CKWTLjGT .messageText{fill:#333;stroke:none;}#mermaid-svg-awrCzId1CKWTLjGT .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-awrCzId1CKWTLjGT .labelText,#mermaid-svg-awrCzId1CKWTLjGT .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-awrCzId1CKWTLjGT .loopText,#mermaid-svg-awrCzId1CKWTLjGT .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-awrCzId1CKWTLjGT .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-awrCzId1CKWTLjGT .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-awrCzId1CKWTLjGT .noteText,#mermaid-svg-awrCzId1CKWTLjGT .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-awrCzId1CKWTLjGT .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-awrCzId1CKWTLjGT .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-awrCzId1CKWTLjGT .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-awrCzId1CKWTLjGT .actorPopupMenu{position:absolute;}#mermaid-svg-awrCzId1CKWTLjGT .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-awrCzId1CKWTLjGT .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-awrCzId1CKWTLjGT .actor-man circle,#mermaid-svg-awrCzId1CKWTLjGT line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-awrCzId1CKWTLjGT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 查询: "Python 教程 PDF"重写查询"Python 教程", "format:PDF"并行: 向量检索(语义)并行: BM25 检索(关键词)并行: 过滤 format=PDF向量结果 (top 100)关键词结果 (top 100)过滤后 ID 列表融合三路结果最终排序 (top 10)缓存结果返回结果

Python 实现(异步并行 + RRF 融合)

python 复制代码
import asyncio
from typing import List, Dict, Any
from dataclasses import dataclass
from enum import Enum

@dataclass
class SearchResult:
    """搜索结果的数据模型(Pydantic V2 风格)"""
    doc_id: str
    score: float
    source: str  # "vector", "bm25", "filter"
    metadata: Dict[str, Any]

class HybridSearchService:
    """
    混合检索服务:并行执行多路检索,RRF 融合
    """
    def __init__(self, vector_db, keyword_db, metadata_db):
        self.vector_db = vector_db
        self.keyword_db = keyword_db
        self.metadata_db = metadata_db
        self.rrf = RRFusion(k=60)
    
    async def search(self, query: str, top_k: int = 10, filters: Dict[str, Any] = None) -> List[SearchResult]:
        """
        混合检索入口
        
        Args:
            query: 用户查询
            top_k: 返回结果数量
            filters: 结构化过滤条件(如 {"format": "PDF", "price": "<100"})
        """
        # 1. 查询重写(可选,提升召回率)
        rewritten_queries = await self._rewrite_query(query)
        
        # 2. 并行执行三路检索
        tasks = []
        
        # 任务 1:向量检索(语义匹配)
        for q in rewritten_queries:
            tasks.append(self._vector_search(q, top_k=100))
        
        # 任务 2:关键词检索(精确匹配)
        tasks.append(self._keyword_search(query, top_k=100))
        
        # 任务 3:结构化过滤(约束条件)
        if filters:
            tasks.append(self._structured_filter(filters))
        
        # 等待所有任务完成
        results = await asyncio.gather(*tasks, return_exceptions=True)
        
        # 3. 合并结果(RRF 融合)
        recall_lists = []
        for result in results:
            if isinstance(result, Exception):
                logging.error(f"检索失败: {result}")
                continue
            recall_lists.append(result)
        
        fused_results = self.rrf.fuse(recall_lists)
        
        # 4. 重新排序(可选:引入业务权重)
        final_results = await self._rerank(fused_results[:top_k], query)
        
        return final_results
    
    async def _rewrite_query(self, query: str) -> List[str]:
        """
        查询重写:扩展同义词、纠正拼写、识别意图
        
        示例:
            输入: "Python 教程 PDF"
            输出: ["Python 教程 PDF", "Python 入门教程", "Python 学习资料"]
        """
        # 实际实现:调用 LLM 或规则引擎
        # 这里简化为返回原查询
        return [query]
    
    async def _vector_search(self, query: str, top_k: int) -> List[Tuple[str, float]]:
        """
        向量检索:将查询转为 Embedding,在向量数据库中搜索
        """
        # 1. 生成查询向量
        query_embedding = await self.embedding_model.encode(query)
        
        # 2. 在向量数据库中搜索
        results = await self.vector_db.search(
            vector=query_embedding,
            top_k=top_k,
            metric_type="cosine"
        )
        
        # 3. 返回 [(doc_id, score), ...]
        return [(r["id"], r["score"]) for r in results]
    
    async def _keyword_search(self, query: str, top_k: int) -> List[Tuple[str, float]]:
        """
        关键词检索:使用 BM25 算法
        """
        results = await self.keyword_db.search_bm25(
            query=query,
            top_k=top_k
        )
        
        return [(r["id"], r["bm25_score"]) for r in results]
    
    async def _structured_filter(self, filters: Dict[str, Any]) -> List[Tuple[str, float]]:
        """
        结构化过滤:根据元数据过滤(如价格、格式、日期)
        
        返回:[(doc_id, 1.0), ...] (过滤命中则得分为 1.0)
        """
        filtered_ids = await self.metadata_db.query(filters)
        
        # 过滤结果不参与排序,只作为"通行证"
        # 这里返回固定得分 1.0,RRF 会将其与其他结果融合
        return [(doc_id, 1.0) for doc_id in filtered_ids]
    
    async def _rerank(self, results: List[Tuple[str, float]], query: str) -> List[SearchResult]:
        """
        重排序:使用交叉编码器(Cross-Encoder)对 top-K 结果精细排序
        
        为什么需要重排序?
        - 向量检索和 BM25 使用的是双编码器(Bi-Encoder),查询和文档独立编码,速度快但精度低
        - 重排序使用交叉编码器,查询和文档拼接后编码,精度高但速度慢
        - 只对 top-K 结果重排序,平衡精度和延迟
        """
        # 实际实现:调用 Cross-Encoder 模型
        # 这里简化为返回原结果
        return [
            SearchResult(doc_id=doc_id, score=score, source="hybrid", metadata={})
            for doc_id, score in results
        ]

# 使用示例
service = HybridSearchService(vector_db, keyword_db, metadata_db)

results = await service.search(
    query="Python 教程 PDF",
    top_k=10,
    filters={"format": "PDF", "language": "中文"}
)

for i, result in enumerate(results, start=1):
    print(f"{i}. {result.doc_id} (得分: {result.score:.4f})")

1.3.4 企业级工程技巧:查询重写与意图识别前置

问题:用户输入的查询往往不规范(口语化、拼写错误、缺少上下文),直接用于检索会导致召回率低。

解决方案:在检索前,使用 LLM 或规则引擎对查询进行重写和意图识别。

查询重写策略

策略 示例 实现方式
拼写纠正 "Pyhton 教程" → "Python 教程" SymSpell、LLM
同义词扩展 "手机" → "手机 OR 智能手机 OR 移动电话" 同义词词典、WordNet
查询扩展 "Python" → "Python 编程 教程 入门" LLM 生成、伪相关反馈
意图识别 "如何学习 Python" → intent=教程搜索 分类模型、LLM
实体识别 "iPhone 15 Pro Max" → product=iPhone_15_Pro_Max NER 模型

Python 实现(LLM 查询重写)

python 复制代码
from openai import AsyncOpenAI
from pydantic import BaseModel, Field

class QueryRewriteResponse(BaseModel):
    """
    LLM 查询重写的输出模型(Pydantic V2)
    """
    original_query: str = Field(..., description="原始查询")
    rewritten_query: str = Field(..., description="重写后的查询")
    intent: str = Field(..., description="用户意图(如:教程搜索、产品对比、故障排查)")
    entities: List[str] = Field(default_factory=list, description="识别的实体列表")
    filters: Dict[str, Any] = Field(default_factory=dict, description="提取的过滤条件")

class QueryRewriter:
    """
    查询重写服务:使用 LLM 理解用户意图,生成优化后的查询
    """
    def __init__(self, llm_client: AsyncOpenAI):
        self.client = llm_client
    
    async def rewrite(self, query: str, context: Dict[str, Any] = None) -> QueryRewriteResponse:
        """
        使用 LLM 重写查询
        
        Args:
            query: 用户输入的原始查询
            context: 上下文(如用户历史、当前页面)
        """
        # 1. 构造 Prompt
        system_prompt = """
        你是一个查询优化助手。分析用户输入的查询,完成以下任务:
        1. 纠正拼写错误
        2. 扩展同义词(提升召回率)
        3. 识别用户意图(教程搜索、产品对比、故障排查等)
        4. 提取实体(产品名、型号、品牌等)
        5. 提取过滤条件(价格、格式、日期等)
        
        输出 JSON 格式,包含字段:
        - original_query: 原始查询
        - rewritten_query: 重写后的查询(优化版)
        - intent: 用户意图
        - entities: 实体列表
        - filters: 过滤条件(字典)
        """
        
        user_prompt = f"查询: {query}"
        if context:
            user_prompt += f"\n上下文: {context}"
        
        # 2. 调用 LLM
        response = await self.client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt}
            ],
            response_format={"type": "json_object"}  # 强制输出 JSON
        )
        
        # 3. 解析结果
        result_json = json.loads(response.choices[0].message.content)
        return QueryRewriteResponse(**result_json)
    
    async def batch_rewrite(self, queries: List[str]) -> List[QueryRewriteResponse]:
        """
        批量重写(提升吞吐量)
        """
        tasks = [self.rewrite(q) for q in queries]
        return await asyncio.gather(*tasks)

# 使用示例
rewriter = QueryRewriter(AsyncOpenAI())

result = await rewriter.rewrite("iPhone 15 Pro Max 256GB 价格")
print(f"原始查询: {result.original_query}")
print(f"重写查询: {result.rewritten_query}")
print(f"意图: {result.intent}")
print(f"实体: {result.entities}")
print(f"过滤条件: {result.filters}")

# 输出示例:
# 原始查询: iPhone 15 Pro Max 256GB 价格
# 重写查询: iPhone 15 Pro Max 256GB 价格 报价 多少钱
# 意图: 产品查询
# 实体: ['iPhone 15 Pro Max', '256GB']
# 过滤条件: {'product': 'iPhone 15 Pro Max', 'storage': '256GB', 'query_type': 'price'}

企业级工程陷阱

⚠️ 陷阱:LLM 查询重写增加延迟

某电商公司使用 LLM 重写所有查询,导致 P99 延迟从 50ms 增加到 800ms(LLM 调用耗时)。

解决方案

  1. 缓存重写结果:对热门查询,缓存 LLM 输出(TTL = 1 小时)
  2. 异步重写:在用户打字时预重写(前端实时传输输入)
  3. 降级策略:LLM 超时时,回退到规则重写(同义词词典)
  4. 批量重写:将多个用户的查询合并,一次 LLM 调用处理(减少 API 次数)

1.4 多租户场景下的向量库隔离策略

1.4.1 Partition Key vs 独立 Collection 的利弊

在多租户 SaaS 场景中,不同租户的数据必须严格隔离。向量数据库提供两种隔离粒度:

方案 描述 优点 缺点 适用场景
Partition Key 所有租户共享一个 Collection,用 partition_key 字段区分 1. 运维简单(只需管理一个 Collection) 2. 跨租户统计方便 1. 性能隔离差(大租户影响小租户) 2. 索引重建影响所有租户 3. 删除租户需要批量删除(慢) 租户数量多(>1000) 单租户数据量小(<100 万)
独立 Collection 每个租户一个独立的 Collection 1. 性能完全隔离 2. 按需创建索引(大租户用 HNSW,小租户用 FLAT) 3. 删除租户 = 删除 Collection(快) 1. 运维复杂(成千上万个 Collection) 2. 跨租户查询需要 Union(慢) 3. 资源开销大(每个 Collection 有独立缓存) 租户数量少(<100) 单租户数据量大(>100 万)
混合策略 小租户共享 Collection,大租户独立 Collection 平衡运维成本和性能隔离 需要动态迁移策略(租户从小变大时) 大多数 SaaS 场景

Milvus 中的实现对比

python 复制代码
from pymilvus import Collection, connections, FieldSchema, CollectionSchema, DataType

# 方案 1:Partition Key(共享 Collection)
def create_shared_collection():
    """
    所有租户共享一个 Collection,用 partition_key 隔离
    """
    fields = [
        FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
        FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
        FieldSchema(name="tenant_id", dtype=DataType.INT64, partition_key=True),  # 关键:分区键
        FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=65535)
    ]
    schema = CollectionSchema(fields=fields, description="多租户共享 Collection")
    
    collection = Collection(name="shared_knowledge_base", schema=schema)
    
    # 创建索引(所有租户共用一个索引)
    collection.create_index(
        field_name="embedding",
        index_params={"index_type": "HNSW", "metric_type": "L2", "params": {"M": 16, "efConstruction": 200}}
    )
    
    return collection

# 方案 2:独立 Collection(每租户一个)
def create_dedicated_collection(tenant_id: int):
    """
    为指定租户创建独立的 Collection
    """
    fields = [
        FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
        FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
        FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=65535)
        # 注意:不需要 tenant_id 字段,因为 Collection 本身就是一个租户的
    ]
    schema = CollectionSchema(fields=fields, description=f"租户 {tenant_id} 的独立 Collection")
    
    collection_name = f"tenant_{tenant_id}_knowledge_base"
    collection = Collection(name=collection_name, schema=schema)
    
    # 根据租户数据量选择索引类型
    vector_count = get_tenant_vector_count(tenant_id)
    if vector_count > 10_000_000:
        # 大租户:HNSW(高性能)
        index_params = {"index_type": "HNSW", "metric_type": "L2", "params": {"M": 32, "efConstruction": 200}}
    else:
        # 小租户:FLAT(精确搜索,索引占用内存小)
        index_params = {"index_type": "FLAT", "metric_type": "L2"}
    
    collection.create_index(field_name="embedding", index_params=index_params)
    
    return collection

def get_tenant_vector_count(tenant_id: int) -> int:
    """
    查询租户的向量数量(从元数据表获取)
    """
    # 实际实现:查询元数据数据库
    return metadata_db.query(f"SELECT COUNT(*) FROM vectors WHERE tenant_id = {tenant_id}")

1.4.2 混合策略:按租户大小动态选择

核心思想:租户的数据量会增长,隔离策略需要动态调整。

触发条件

  • 小 → 大:当租户的向量数量超过阈值(如 100 万),从共享 Collection 迁移到独立 Collection
  • 大 → 小:当租户降级(退订),从独立 Collection 合并到共享 Collection(少见)

Python 实现(自动迁移)

python 复制代码
import asyncio
from enum import Enum
from pydantic import BaseModel, Field

class TenantTier(Enum):
    SMALL = "small"  # < 100 万
    MEDIUM = "medium"  # 100 万 ~ 1000 万
    LARGE = "large"  # > 1000 万

class TenantMetadata(BaseModel):
    """
    租户元数据模型
    """
    tenant_id: int
    tier: TenantTier
    vector_count: int
    collection_name: str  # 当前使用的 collection
    migration_status: str = Field(default="none")  # none, migrating, migrated

class DynamicIsolationManager:
    """
    动态隔离管理器:根据租户大小自动调整隔离策略
    """
    def __init__(self, milvus_client, metadata_db):
        self.milvus = milvus_client
        self.metadata_db = metadata_db
        self.size_threshold = 1_000_000  # 100 万向量
    
    async def check_and_migrate(self, tenant_id: int):
        """
        检查租户大小,必要时触发迁移
        """
        # 1. 获取租户元数据
        metadata = await self._get_tenant_metadata(tenant_id)
        
        # 2. 判断是否需要迁移
        if metadata.tier == TenantTier.SMALL and metadata.vector_count > self.size_threshold:
            # 小 → 大:迁移到独立 Collection
            await self._migrate_to_dedicated(tenant_id, metadata)
        
        elif metadata.tier == TenantTier.LARGE and metadata.vector_count < self.size_threshold * 0.5:
            # 大 → 小:合并到共享 Collection(谨慎操作,通常不发生)
            await self._migrate_to_shared(tenant_id, metadata)
    
    async def _migrate_to_dedicated(self, tenant_id: int, metadata: TenantMetadata):
        """
        迁移:从共享 Collection → 独立 Collection
        
        步骤:
        1. 创建新的独立 Collection
        2. 从共享 Collection 导出该租户的数据
        3. 导入到独立 Collection
        4. 验证数据一致性
        5. 切换读取流量到新 Collection
        6. 从共享 Collection 删除该租户的数据
        """
        logging.info(f"开始迁移租户 {tenant_id} 到独立 Collection...")
        
        metadata.migration_status = "migrating"
        await self._update_metadata(metadata)
        
        try:
            # 步骤 1:创建独立 Collection
            new_collection = create_dedicated_collection(tenant_id)
            
            # 步骤 2:导出数据
            shared_collection = Collection("shared_knowledge_base")
            expr = f"tenant_id == {tenant_id}"
            results = shared_collection.query(expr=expr, output_fields=["id", "embedding", "content"])
            
            # 步骤 3:导入到独立 Collection
            entities = [
                [r["id"] for r in results],
                [r["embedding"] for r in results],
                [r["content"] for r in results]
            ]
            new_collection.insert(entities)
            new_collection.flush()
            
            # 步骤 4:验证数据一致性
            old_count = len(results)
            new_count = new_collection.num_entities
            assert old_count == new_count, f"数据不一致: {old_count} vs {new_count}"
            
            # 步骤 5:切换读取流量(更新元数据)
            metadata.collection_name = f"tenant_{tenant_id}_knowledge_base"
            metadata.tier = TenantTier.MEDIUM
            await self._update_metadata(metadata)
            
            # 步骤 6:异步删除旧数据(避免影响线上)
            asyncio.create_task(self._delete_from_shared(tenant_id))
            
            logging.info(f"迁移完成: 租户 {tenant_id}")
        
        except Exception as e:
            logging.error(f"迁移失败: {e}")
            metadata.migration_status = "failed"
            await self._update_metadata(metadata)
            raise
    
    async def _migrate_to_shared(self, tenant_id: int, metadata: TenantMetadata):
        """
        迁移:从独立 Collection → 共享 Collection
        
        少见操作(通常租户不会从大变小)
        """
        logging.info(f"开始迁移租户 {tenant_id} 到共享 Collection...")
        
        # 反向操作(略)
        pass
    
    async def _get_tenant_metadata(self, tenant_id: int) -> TenantMetadata:
        """
        从元数据数据库获取租户信息
        """
        # 实际实现:查询数据库
        pass
    
    async def _update_metadata(self, metadata: TenantMetadata):
        """
        更新租户元数据
        """
        # 实际实现:写入数据库
        pass
    
    async def _delete_from_shared(self, tenant_id: int):
        """
        从共享 Collection 删除租户数据(异步,不阻塞主流程)
        """
        await asyncio.sleep(60)  # 等待 1 分钟,确保流量已切换
        
        shared_collection = Collection("shared_knowledge_base")
        expr = f"tenant_id == {tenant_id}"
        shared_collection.delete(expr)
        
        logging.info(f"已从共享 Collection 删除租户 {tenant_id} 的数据")

# 定期运行(每天凌晨检查)
async def daily_migration_check(manager: DynamicIsolationManager):
    """
    每天检查所有租户,触发必要的迁移
    """
    tenant_ids = await manager.metadata_db.get_all_tenant_ids()
    
    for tenant_id in tenant_ids:
        await manager.check_and_migrate(tenant_id)
    
    logging.info(f"完成每日迁移检查,检查了 {len(tenant_ids)} 个租户")

1.4.3 企业级工程陷阱:热点租户的性能和隔离

问题:多租户系统中,某个大租户(热点租户)的查询量激增,导致共享资源(CPU、内存、网络)被占满,影响其他租户。

场景示例

某 SaaS 知识库系统,有 1000 个租户共享一个 Milvus 集群。租户 A(大型企业客户)突然发起批量检索(1000 QPS),导致集群 CPU 使用率从 30% 飙升到 95%,其他租户的延迟从 20ms 增加到 500ms。

解决方案

  1. 资源配额(Resource Quota):限制每个租户的最大 QPS、内存使用量
  2. 请求队列隔离:每个租户独立的请求队列,避免头部阻塞
  3. 降级策略:当集群负载 > 80% 时,对低优先级租户返回缓存结果或限流
  4. 热点检测与扩容:自动检测热点租户,将其迁移到独立节点

Python 实现(基于 Redis 的租户级限流)

python 复制代码
import redis
import asyncio
from typing import Tuple

class TenantRateLimiter:
    """
    租户级限流器:基于令牌桶算法
    
    每个租户有独立的令牌桶,限制其最大 QPS
    """
    def __init__(self, redis_client: redis.Redis, default_qps: int = 100):
        self.redis = redis_client
        self.default_qps = default_qps
    
    async def allow_request(self, tenant_id: int, burst: int = None) -> Tuple[bool, str]:
        """
        判断租户是否可以发起请求
        
        Args:
            tenant_id: 租户 ID
            burst: 突发流量允许的最大值(默认为 QPS 的 2 倍)
        
        Returns:
            (allowed, reason): 是否允许,以及原因
        """
        # 1. 获取租户的 QPS 配额
        qps_quota = await self._get_tenant_quota(tenant_id)
        if burst is None:
            burst = qps_quota * 2
        
        # 2. 使用令牌桶算法
        key = f"rate_limit:{tenant_id}"
        now = time.time()
        
        # Lua 脚本(保证原子性)
        lua_script = """
        local key = KEYS[1]
        local now = tonumber(ARGV[1])
        local qps = tonumber(ARGV[2])
        local burst = tonumber(ARGV[3])
        
        local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
        local tokens = tonumber(bucket[1]) or burst
        local last_refill = tonumber(bucket[2]) or now
        
        -- 计算需要补充的令牌数
        local elapsed = now - last_refill
        local refill = elapsed * qps
        tokens = math.min(burst, tokens + refill)
        
        if tokens >= 1 then
            tokens = tokens - 1
            redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
            redis.call('EXPIRE', key, 60)  -- 1 分钟过期
            return 1  -- 允许
        else
            return 0  -- 拒绝
        end
        """
        
        # 执行 Lua 脚本
        allowed = self.redis.eval(lua_script, 1, key, now, qps_quota, burst)
        
        if allowed:
            return True, "OK"
        else:
            return False, f"QPS 超限(配额: {qps_quota})"
    
    async def _get_tenant_quota(self, tenant_id: int) -> int:
        """
        获取租户的 QPS 配额(从数据库或配置中心)
        """
        # 实际实现:查询数据库或配置中心(如 etcd、Consul)
        # 这里简化为返回默认值
        return self.default_qps

class IsolationProxy:
    """
    隔离代理:在查询执行前检查租户配额,超限则拒绝或降级
    """
    def __init__(self, vector_db, rate_limiter: TenantRateLimiter):
        self.vector_db = vector_db
        self.limiter = rate_limiter
    
    async def search(self, tenant_id: int, query_vector: List[float], top_k: int = 10):
        """
        带限流的搜索
        """
        # 1. 检查租户配额
        allowed, reason = await self.limiter.allow_request(tenant_id)
        if not allowed:
            # 2. 拒绝或降级
            logging.warning(f"租户 {tenant_id} 被限流: {reason}")
            
            # 降级策略:返回缓存结果(如果有)
            cached_result = await self._get_cached_result(tenant_id, query_vector)
            if cached_result:
                return cached_result
            
            # 否则抛出限流异常
            raise RateLimitExceeded(reason)
        
        # 3. 执行查询
        return await self.vector_db.search(tenant_id, query_vector, top_k)
    
    async def _get_cached_result(self, tenant_id: int, query_vector: List[float]) -> List[Dict]:
        """
        获取缓存的查询结果(降级策略)
        """
        # 实际实现:查询 Redis 或 Memcached
        pass

# 使用示例
redis_client = redis.Redis(host="localhost", port=6379)
rate_limiter = TenantRateLimiter(redis_client, default_qps=100)
proxy = IsolationProxy(vector_db, rate_limiter)

try:
    results = await proxy.search(tenant_id=123, query_vector=[0.1, 0.2, ...], top_k=10)
    print(f"查询成功,返回 {len(results)} 条结果")
except RateLimitExceeded as e:
    print(f"查询被限流: {e}")

Mermaid 架构图:热点租户隔离策略
#mermaid-svg-Tet0OlLXevxEKOt1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Tet0OlLXevxEKOt1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Tet0OlLXevxEKOt1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Tet0OlLXevxEKOt1 .error-icon{fill:#552222;}#mermaid-svg-Tet0OlLXevxEKOt1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Tet0OlLXevxEKOt1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Tet0OlLXevxEKOt1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Tet0OlLXevxEKOt1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Tet0OlLXevxEKOt1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Tet0OlLXevxEKOt1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Tet0OlLXevxEKOt1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Tet0OlLXevxEKOt1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Tet0OlLXevxEKOt1 .marker.cross{stroke:#333333;}#mermaid-svg-Tet0OlLXevxEKOt1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Tet0OlLXevxEKOt1 p{margin:0;}#mermaid-svg-Tet0OlLXevxEKOt1 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Tet0OlLXevxEKOt1 .cluster-label text{fill:#333;}#mermaid-svg-Tet0OlLXevxEKOt1 .cluster-label span{color:#333;}#mermaid-svg-Tet0OlLXevxEKOt1 .cluster-label span p{background-color:transparent;}#mermaid-svg-Tet0OlLXevxEKOt1 .label text,#mermaid-svg-Tet0OlLXevxEKOt1 span{fill:#333;color:#333;}#mermaid-svg-Tet0OlLXevxEKOt1 .node rect,#mermaid-svg-Tet0OlLXevxEKOt1 .node circle,#mermaid-svg-Tet0OlLXevxEKOt1 .node ellipse,#mermaid-svg-Tet0OlLXevxEKOt1 .node polygon,#mermaid-svg-Tet0OlLXevxEKOt1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Tet0OlLXevxEKOt1 .rough-node .label text,#mermaid-svg-Tet0OlLXevxEKOt1 .node .label text,#mermaid-svg-Tet0OlLXevxEKOt1 .image-shape .label,#mermaid-svg-Tet0OlLXevxEKOt1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-Tet0OlLXevxEKOt1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Tet0OlLXevxEKOt1 .rough-node .label,#mermaid-svg-Tet0OlLXevxEKOt1 .node .label,#mermaid-svg-Tet0OlLXevxEKOt1 .image-shape .label,#mermaid-svg-Tet0OlLXevxEKOt1 .icon-shape .label{text-align:center;}#mermaid-svg-Tet0OlLXevxEKOt1 .node.clickable{cursor:pointer;}#mermaid-svg-Tet0OlLXevxEKOt1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Tet0OlLXevxEKOt1 .arrowheadPath{fill:#333333;}#mermaid-svg-Tet0OlLXevxEKOt1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Tet0OlLXevxEKOt1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Tet0OlLXevxEKOt1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Tet0OlLXevxEKOt1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Tet0OlLXevxEKOt1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Tet0OlLXevxEKOt1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Tet0OlLXevxEKOt1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Tet0OlLXevxEKOt1 .cluster text{fill:#333;}#mermaid-svg-Tet0OlLXevxEKOt1 .cluster span{color:#333;}#mermaid-svg-Tet0OlLXevxEKOt1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Tet0OlLXevxEKOt1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Tet0OlLXevxEKOt1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-Tet0OlLXevxEKOt1 .icon-shape,#mermaid-svg-Tet0OlLXevxEKOt1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Tet0OlLXevxEKOt1 .icon-shape p,#mermaid-svg-Tet0OlLXevxEKOt1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Tet0OlLXevxEKOt1 .icon-shape .label rect,#mermaid-svg-Tet0OlLXevxEKOt1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Tet0OlLXevxEKOt1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Tet0OlLXevxEKOt1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Tet0OlLXevxEKOt1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 租户请求
租户 A: 允许
租户 B: 限流
租户 C: 允许
租户 A

小客户

QPS: 10
负载均衡器
租户 B

热点客户

QPS: 1000
租户 C

中型客户

QPS: 100
租户级限流器

Redis 令牌桶
查询节点 1
返回缓存结果

或抛出限流异常
查询节点 2
共享存储

热点数据缓存

1.4.4 监控指标:每个租户的向量库性能 SLA

为什么需要租户级监控?

  1. 性能隔离验证:确保热点租户没有影响其他租户
  2. 容量规划:预测租户增长,提前扩容
  3. 计费依据:按用量计费(如向量存储量、查询次数)
  4. SLA 报告:向企业客户证明服务可用性

关键监控指标

指标 描述 目标值 告警阈值
P50 延迟 中位数延迟 < 20ms > 50ms
P99 延迟 99 分位延迟 < 100ms > 200ms
QPS 每秒查询数 - > 配额的 80%
召回率 返回结果中包含真实相关文档的比例 > 0.95 < 0.90
错误率 失败请求数 / 总请求数 < 0.1% > 1%
向量存储量 租户的向量数量 - > 配额的 90%
内存使用量 租户占用的内存 - > 物理内存的 80%

Python 实现(租户级监控 + Prometheus)

python 复制代码
from prometheus_client import Counter, Histogram, Gauge, generate_latest
from typing import Dict
import time

# Prometheus 指标定义
tenant_qps = Counter(
    name="vector_search_qps_total",
    documentation="租户级 QPS",
    labelnames=["tenant_id", "collection"]
)

tenant_latency = Histogram(
    name="vector_search_latency_seconds",
    documentation="租户级查询延迟",
    labelnames=["tenant_id", "collection"],
    buckets=[0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0]
)

tenant_recall = Gauge(
    name="vector_search_recall",
    documentation="租户级召回率",
    labelnames=["tenant_id", "collection"]
)

class MonitoredVectorDB:
    """
    带监控的向量数据库代理
    """
    def __init__(self, vector_db):
        self.vector_db = vector_db
    
    async def search(self, tenant_id: int, collection_name: str, query_vector: List[float], top_k: int = 10):
        """
        带监控的搜索
        """
        # 1. 记录开始时间
        start_time = time.time()
        
        try:
            # 2. 执行查询
            results = await self.vector_db.search(tenant_id, query_vector, top_k)
            
            # 3. 记录 QPS
            tenant_qps.labels(tenant_id=tenant_id, collection=collection_name).inc()
            
            # 4. 记录延迟
            latency = time.time() - start_time
            tenant_latency.labels(tenant_id=tenant_id, collection=collection_name).observe(latency)
            
            # 5. 异步评估召回率(采样 1% 的查询)
            if hash(str(query_vector)) % 100 == 0:  # 1% 采样
                asyncio.create_task(self._evaluate_recall(tenant_id, collection_name, query_vector, results))
            
            return results
        
        except Exception as e:
            # 记录错误
            tenant_qps.labels(tenant_id=tenant_id, collection=collection_name).inc()
            raise
    
    async def _evaluate_recall(self, tenant_id: int, collection_name: str, query_vector: List[float], results: List[Dict]):
        """
        评估召回率(通过人工标注或规则)
        
        注意:这是离线任务,不应阻塞在线查询
        """
        # 实际实现:
        # 1. 获取查询的真实相关文档(从标注数据集或用户点击日志)
        # 2. 计算召回率 = (返回的相关文档数) / (真实相关文档总数)
        # 3. 更新 Prometheus Gauge
        
        recall = 0.96  # 模拟值
        tenant_recall.labels(tenant_id=tenant_id, collection=collection_name).set(recall)
    
    def export_metrics(self) -> bytes:
        """
        导出 Prometheus 格式的指标
        """
        return generate_latest()

# Prometheus 配置(prometheus.yml)
"""
scrape_configs:
  - job_name: 'vector_search'
    static_configs:
      - targets: ['localhost:8000']  # 暴露 /metrics 端点
    relabel_configs:
      - source_labels: [__name__]
        regex: 'vector_search_.*'
        action: keep  # 只采集向量检索相关的指标
"""

# Grafana 仪表盘配置(JSON,略)
"""
关键面板:
1. 每个租户的 QPS(时间序列图)
2. 每个租户的 P99 延迟(热力图)
3. 召回率趋势(折线图)
4. 错误率(柱状图)
5. 向量存储量 TOP 10 租户(排行图)
"""

企业级工程案例

某 SaaS 公司使用以上监控方案,发现租户 X 的 P99 延迟从 50ms 逐渐增加到 300ms(持续 1 周)。经排查,原因是租户 X 的向量数据量从 100 万增加到 500 万,但索引参数未调整(efSearch 太小,导致召回率下降,应用层重试增加延迟)。

解决方案 :自动调整 efSearch 参数(根据数据量动态调整)。

python 复制代码
def auto_tune_efsearch(vector_count: int) -> int:
    """
    根据向量数量自动调整 efSearch 参数
    
    经验公式:
    - < 100 万:efSearch = 64
    - 100 万 ~ 1000 万:efSearch = 128
    - > 1000 万:efSearch = 256
    """
    if vector_count < 1_000_000:
        return 64
    elif vector_count < 10_000_000:
        return 128
    else:
        return 256

# 在查询时动态调整
efsearch = auto_tune_efsearch(get_tenant_vector_count(tenant_id))
results = collection.search(
    data=[query_vector],
    param={"metric_type": "L2", "params": {"ef": efsearch}},
    limit=top_k
)

本章小结

第一章核心要点回顾

  1. 向量检索的工程本质:将语义转化为向量,在高维空间中快速找到最近邻。核心挑战是平衡召回率、延迟、内存三者。

  2. 架构选型决策树

    • 小规模(< 100 万):PgVector 或 Qdrant
    • 中规模(100 万 ~ 1 亿):Qdrant 或 Milvus
    • 大规模(> 1 亿):Milvus 集群
    • 混合检索需求:Elasticsearch 或 Milvus
  3. 混合检索:向量(语义) + 关键词(精确) + 结构化过滤(约束),使用 RRF 算法融合。

  4. 多租户隔离:小租户用 Partition Key,大租户用独立 Collection,并实现动态迁移和热点隔离。

从原型到生产的 Checklist

  • 选择合适的向量数据库(参考 1.2 节决策树)
  • 实现混合检索(向量 + 关键词 + 过滤)
  • 配置多租户隔离策略(Partition Key 或独立 Collection)
  • 部署监控系统(Prometheus + Grafana)
  • 压测并优化索引参数(M, efConstruction, efSearch
  • 制定迁移计划(从单机 Faiss 到分布式向量数据库)
  • 实现查询重写和意图识别(提升召回率)
  • 配置租户级限流(避免热点租户影响)

思考题

  1. (基础) 向量检索中的 ANN 算法有哪些?它们的核心区别是什么?在什么场景下应该选择 HNSW 而不是 IVF?

  2. (进阶) 在设计一个支持百万级租户的向量检索系统时,如何平衡隔离性和运维成本?请提出至少三种方案,并分析其优缺点。

  3. (实战) 你在生产环境中发现向量检索的召回率突然下降(从 0.96 降到 0.85),请列出可能的 5 个原因,并说明如何排查。

  4. (开放) 大语言模型(LLM)的检索增强生成(RAG)系统中,向量检索只是第一步。请思考:除了向量检索,还有哪些技术可以提升 RAG 的准确率?



下一步是什么?

第二章将深入讨论 Embedding 模型的选型、微调与工程化部署。我们将探讨:

  • 如何选择合适的 Embedding 模型(OpenAI vs 开源)
  • 如何微调 Embedding 模型以提升特定领域的检索精度
  • 如何批量生成向量并写入向量数据库(ETL 流程)
  • 如何监控 Embedding 模型的效果并持续优化

敬请期待。