前文引用:MTEB 排行榜之外:嵌入模型在 JRXML 场景下的选择逻辑
分块做完了,嵌入模型也选好了,向量生成了。现在需要一个地方存储这些向量,并且支持快速检索——这就是向量数据库。

这一篇展开向量数据库的核心技术、主流产品对比,以及针对本项目规模的选型逻辑。

传统数据库 vs 向量数据库

两者处理的是完全不同的问题:

传统数据库 (MySQL) 向量数据库 (Milvus)
处理数据 结构化数据(数字、字符串) 非结构化数据的向量表示
核心查询 WHERE name = '张三'(精确匹配) 查找与 [0.12, -0.84, ...] 最相似的 Top 10(模糊匹配)
核心技术 B-Tree、Hash 索引 近似最近邻搜索(ANN)算法
典型速度 百万级数据,毫秒级(精确查询) 亿级向量,毫秒级(相似查询)

向量数据库的两个核心技术支柱:向量索引算法(怎么快速找到近似的向量)和距离度量函数(怎么定义"近")。

向量索引算法

精确查找(暴力遍历所有向量计算距离)在小数据量下完全可用。但数据量上到十万级,每次查询遍历所有向量就不现实了。

实际应用全部使用近似最近邻(ANN)算法——通过索引结构快速圈定候选区,只在候选区内精确计算。

HNSW(基于图)

原理:构建一个分层图结构,类似高速公路网。顶层节点稀疏,负责大范围跳跃;底层节点密集,负责精确搜索。搜索时从顶层快速锁定大致区域,逐层下降到目标附近。

  • 查询速度:极快,毫秒级
  • 召回率:高(通常 >95%)
  • 内存消耗:大(需要存储完整的图结构)
  • 构建速度:慢
  • 适用场景:查询延迟要求高、内存充足、数据量 10 万 - 100 万

IVF-PQ(基于量化)

原理分两步:先用 IVF(Inverted File)将向量空间分成很多"类"(聚类中心),搜索时只在最相似的几个类里进行。再用 PQ(Product Quantization)把高维向量切成小段、分别编码压缩,大幅降低存储。

  • 查询速度:较快
  • 召回率:中等(有精度损失)
  • 内存消耗:极低(向量被压缩)
  • 构建速度:需要训练,较慢
  • 适用场景:海量数据(百万 - 亿级)、内存受限

Annoy(基于树)

原理:用随机超平面不断二分向量空间,形成多棵二叉树(类似随机森林的思路)。搜索时每棵树独立查找,合并结果。

  • 查询速度:中等
  • 召回率:中等
  • 内存消耗:低(只读文件可内存映射)
  • 构建速度:快
  • 关键限制不可增量添加。索引建好后不能追加新向量,必须重建
  • 适用场景:只读数据集、多进程共享索引文件

索引选择速查

数据规模 推荐索引
< 10 万 IndexFlatL2(精确查找)
10 万 - 100 万 HNSW
100 万 - 1 亿 IVF-PQ

本项目数据量:104份 JRXML 模板,总计不超过几万个向量。IndexFlatL2 精确查找完全够用。但考虑到索引类型是向量数据库创建时就要设定的,后期迁移成本高,直接用 HNSW 给未来留余量。

距离度量函数

选择度量函数必须在创建索引时设定,且要与嵌入模型的训练目标匹配。

上篇提到 Qwen3-Embedding-4B 使用余弦相似度。在向量数据库中创建索引时,将距离度量设置为余弦相似度。

度量 特点 适用
余弦相似度 看方向不看长度 文本语义首选
欧氏距离 对数值大小敏感 图像、归一化后通用
内积 值越大越相似 需要置信度评分

Qwen3-Embedding-4B 官方文档列明余弦相似度或点积。选择余弦相似度。

过滤与混合搜索

元数据过滤

纯向量检索在某些场景不够用。比如"找包含 $F{total_amount} 字段且属于 detail band 的模板片段"——"属于 detail band"是元数据条件,"包含 total_amount 字段"是语义条件。

两种策略:

  • 预过滤:先用元数据(chunk_type == 'band' AND band_name == 'detail')过滤出子集,再在子集中做向量检索。性能好,适合元数据条件能大幅缩小范围的场景。
  • 后过滤:先向量检索,再用元数据剔除不符合的结果。适合元数据条件比较宽松、过滤不掉太多数据的情况。

Chroma 支持元数据过滤,但它是后过滤模式。对于我们的场景(千级数据量),性能差异可忽略。

混合搜索

向量检索擅长语义匹配,但遇到精确术语(如字段名 employee_id、参数名 start_date)时可能不如关键词检索准确。混合搜索就是双路并行:

  • 稠密向量:语义匹配,"显示员工信息"能找到包含 $F{employee_name} 的片段
  • 稀疏向量/BM25:精确字面匹配,"employee_id"直接命中

两路结果加权融合:

最终得分 = α × 向量相似度得分 + (1 - α) × BM25 得分

α 在 0-1 之间。α=0.7 代表更信任语义,α=0.3 代表更信任关键词。

实现方式有三种:

  1. 分别调向量库和 Elasticsearch,自己加权排序
  2. 选用原生支持混合搜索的数据库(Weaviate、Pinecone、Qdrant、ES 8.x+)
  3. 使用多向量模型(如 BGE-M3),单一模型同时输出稠密向量和词汇权重

本项目当前未启用混合搜索——Chroma 不原生支持,且数据量小,纯向量检索的召回率已满足需求。后续数据量增长再考虑迁移到 Qdrant 或引入 BM25 路。

向量数据库选型

三个层级

分类 代表产品 核心优势 适用场景
专用向量数据库 Milvus, Qdrant, Weaviate, Pinecone 原生向量检索优化,性能极致 生产级 RAG 系统
数据库扩展 pgvector, Elasticsearch, OceanBase 复用现有基础设施,运维统一 已有对应数据库的企业
轻量级库 Chroma, FAISS 零门槛上手,快速验证 原型开发、小规模项目

开源产品深度对比

维度 Milvus Qdrant Chroma Weaviate
开发语言 Go + C++ Rust Python Go
核心索引 HNSW, IVF, RNSG, ANNOY HNSW, IVF, SCANN HNSW HNSW
量化 PQ, SQ, GPU 加速 PQ, SQ, 二进制量化 基础 SQ PQ
分布式 最强(千节点,存算分离) 适中(分片+副本,建议 <10 节点) 实验阶段 较强(云原生)
规模上限 万亿级 亿-十亿级 建议百万以内 十亿级
部署复杂度 高(依赖 etcd, MinIO 等) 低(单节点 docker run 即用) 极低(pip install 即用)
混合检索 向量 + JSON 元数据过滤 索引级过滤 向量 + 元数据过滤(后过滤) GraphQL + 向量混合

场景推荐

场景 推荐 理由
个人开发者原型验证 Chroma 零配置,Python 原生
小型企业 RAG Qdrant 单节点部署,性能优异
数据密集型大型应用 Milvus 功能最全,生态最成熟
已有 PostgreSQL pgvector 复用现有数据库,统一运维
需要全文+向量混合检索 Elasticsearch 8.x+ 或 Weaviate 混合检索能力最强

本项目的选择:Chroma

选 Chroma 的理由:

  1. 规模匹配:几万个 chunk,百万以内的规模上限完全够用
  2. 零部署成本pip install chromadb,不需要 Docker、不需要独立服务
  3. Python 原生:与现有的 LangChain 生态无缝对接
  4. 快速验证:当前阶段是原型验证,后续如果需要混合搜索或分布式,迁移到 Qdrant 的成本可控

Chroma 的局限也很明确:不支持分布式、不支持混合搜索原生融合、后过滤而非索引级过滤。但这些对于现阶段都不是瓶颈。

Chroma 入库

向量化在 embed_chunks.py 中已完成,入库脚本 import_to_chroma.py 负责读取预计算的向量和 chunk 数据,写入 Chroma 持久化存储。

读取预计算数据

不从 chunk JSON 实时 embedding——直接加载上一步产出的 embeddings.npychunks.json

embeddings = np.load(embeddings_file).astype('float32')
with open(chunks_file, 'r', encoding='utf-8') as f:
    chunks = json.load(f)

if len(embeddings) != len(chunks):
    print(f"数量不匹配: {len(embeddings)} vs {len(chunks)}")
    return None

预计算的好处:embedding 是耗时最长的步骤,把向量化和入库分离后,可以反复重建数据库而不用重新跑模型。

创建集合,指定余弦距离

client = chromadb.PersistentClient(path=str(chroma_path))

# 删除旧集合(如果存在)
try:
    client.delete_collection(collection_name)
except Exception:
    pass

collection = client.create_collection(
    name=collection_name,
    metadata={"hnsw:space": "cosine"}  # 与 Qwen3-Embedding-4B 匹配
)

hnsw:space: cosine 必须在创建集合时设定,与上篇选的余弦相似度一致。如果这里设成欧氏距离,后续检索结果会偏差。

元数据精选

不是把所有 metadata 字段全塞进去——Chroma 的 metadata 会参与过滤索引,字段越多写入越慢。只提取检索和过滤实际用得上的字段:

meta = {
    "chunk_type": chunk.get("chunk_type", ""),
    "context": chunk.get("context", ""),
    # 只取 metadata 子集中用于过滤的字段
}
chunk_meta = chunk.get("metadata", {})
for key in ["report_name", "band_name", "element_kind", "query_language"]:
    if key in chunk_meta:
        meta[key] = chunk_meta[key]

human_description 存入 documents(Chroma 的全文检索字段),原始 raw_xml 不存 Chroma——检索命中后通过 chunk_id 回查 chunks.json 获取完整 XML。这样 Chroma 内部存储减半,查询更快。

分批导入 + 去重

# 处理可能重复的 chunk_id
seen_ids = {}
for i, chunk in enumerate(chunks):
    raw_id = str(chunk.get("chunk_id", i))
    if raw_id in seen_ids:
        seen_ids[raw_id] += 1
        chunk_id = f"{raw_id}_{seen_ids[raw_id]}"
    else:
        seen_ids[raw_id] = 0
        chunk_id = raw_id
    ids.append(chunk_id)

# 分批写入,每批 1000 条
for start in range(0, len(ids), 1000):
    end = min(start + 1000, len(ids))
    collection.add(
        ids=ids[start:end],
        documents=documents[start:end],
        metadatas=metadatas[start:end],
        embeddings=embeddings_list[start:end]
    )

导入后验证

results = collection.query(
    query_embeddings=[embeddings_list[0]],  # 用第一个向量查自身
    n_results=3,
    include=["documents", "metadatas", "distances"]
)
# 自身距离应接近 0(余弦距离 = 1 - 余弦相似度)
print(f"Top-3 相似度距离: {distances}")

用第一个向量查自身,自身距离应接近 0。如果不是,说明索引的度量函数与向量不匹配。

入库完成后还会输出元数据字段覆盖率:

📊 元数据字段分布:
   chunk_type: 356
   context: 356
   band_name: 256
   report_name: 104
   element_kind: 89
   query_language: 15

这能快速发现数据问题——比如 band_name 覆盖率只有 72%,说明有 28% 的 chunk 不是 band 类型,正常。

命令行

# 默认参数
.venv\Scripts\python.exe import_to_chroma.py

# 指定路径和集合名
.venv\Scripts\python.exe import_to_chroma.py \
    --embeddings_dir ./embeddings \
    --chroma_path ./chroma_db \
    --collection_name jrxml_templates

Chroma 检索

入库是手段,检索才是目的。项目中 query_chroma.py 封装了检索逻辑。

JRXMLSearcher

核心类 JRXMLSearcher 复用了 embed_chunks.py 的模型加载逻辑(SentenceTransformer + FP16 + 设备检测),然后连接 Chroma 的已有集合:

from query_chroma import JRXMLSearcher

searcher = JRXMLSearcher()

# 基础检索
results = searcher.search("如何定义日期参数", n_results=5)

# 带阈值的检索(余弦距离 < 0.3,即相似度 > 0.7)
results = searcher.search_with_threshold(
    "textField 表达式怎么写", n_results=5, threshold=0.3
)

# 元数据过滤:只搜 chart 类型的 chunk
results = searcher.search("饼图的 series 怎么配",
                          n_results=5,
                          filter_meta={"chunk_type": "chart"})

search_with_thresholdsearch 基础上按余弦距离阈值裁剪结果——距离越小越相似。阈值设为 0.3 意味着只保留相似度 0.7 以上的结果,避免低质量匹配干扰 LLM 生成。

交互模式

不传查询参数时自动进入交互模式,支持三个特殊命令:

filter:<类型>   按 chunk_type 过滤(如 filter:query)
t:<阈值>        相似度阈值 0~1(如 t:0.5)
k:<数量>        返回结果数(如 k:10)
🔍 搜索> filter:field 数据源有哪些字段
🔍 搜索> t:0.5 band:title 标题区域怎么设置
🔍 搜索> k:10 报表参数定义

适合调试阶段快速摸索知识库覆盖了哪些内容、检索质量如何。

命令行

# 单次查询
.venv\Scripts\python.exe query_chroma.py "如何定义日期参数"

# 指定返回数量 + 类型过滤
.venv\Scripts\python.exe query_chroma.py "饼图配置" -k 10 -f chart

# 带阈值
.venv\Scripts\python.exe query_chroma.py "textField 表达式" -t 0.3

# 交互模式(默认)
.venv\Scripts\python.exe query_chroma.py

总结

向量数据库的选型取决于三个变量:数据规模、检索延迟要求、运维复杂度接受度。

数据量百万以内、不需要分布式、追求开发效率——Chroma。数据量上亿、要求毫秒级检索、有运维团队——Milvus。已有 PostgreSQL 基础设施——pgvector。

本项目的选择是 Chroma,不是因为它最强,而是因为它在当前规模下最合适。RAG 系统的每一层都在做这种取舍——下一篇将把这四篇串联起来,展示完整的 JRXML 知识库构建流程。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐