不是最强而是最合适:原型阶段的向量数据库选型逻辑
前文引用: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 代表更信任关键词。
实现方式有三种:
- 分别调向量库和 Elasticsearch,自己加权排序
- 选用原生支持混合搜索的数据库(Weaviate、Pinecone、Qdrant、ES 8.x+)
- 使用多向量模型(如 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 的理由:
- 规模匹配:几万个 chunk,百万以内的规模上限完全够用
- 零部署成本:
pip install chromadb,不需要 Docker、不需要独立服务 - Python 原生:与现有的 LangChain 生态无缝对接
- 快速验证:当前阶段是原型验证,后续如果需要混合搜索或分布式,迁移到 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 知识库构建流程。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)