前文引用: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零门槛上手,快速验证原型开发、小规模项目

开源产品深度对比

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

场景推荐

场景推荐理由
个人开发者原型验证Chroma零配置,Python 原生
小型企业 RAGQdrant单节点部署,性能优异
数据密集型大型应用Milvus功能最全,生态最成熟
已有 PostgreSQLpgvector复用现有数据库,统一运维
需要全文+向量混合检索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.npy 和 chunks.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_threshold 在 search 基础上按余弦距离阈值裁剪结果——距离越小越相似。阈值设为 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 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐