未记录事实核验 · 状态:维护中
记录仅说明核验时间与维护状态,不保证适用于所有环境;使用前请留意正文中的版本、适用条件与风险提示。
warning 适用边界与维护说明
本文适合有向量检索与 RAG 开发基础的读者,用于研究 Collection、分块索引、双路召回、多租户过滤和评估流程,不是已贯通、可直接部署的生产系统。本次仅作全文结构审阅,未运行代码或进行外部事实核验:LangChain 对 text 与平铺元数据的映射、LlamaIndex 对已有向量字段及节点格式的适配仍不完整,量化配置、Discovery 单正例分支和 RAGAS 数据及结果接口尚缺明确兼容依据;9.1 的 exact 配置并未启用精确搜索,9.2 实际只比较稠密相似度与 MMR,不能作为三种混合及量化策略的对照。32x 仅是向量表示的理论比例,8.3 尚未统一向量副本、RAM、磁盘及分块后点数口径,不能用于容量或成本保证;跨产品排他性比较、BGE-M3 查询前缀要求和过滤延迟断言也不能视为已验证结论。租户过滤不替代鉴权、可信租户字段写入和推荐输入点归属校验;文档与标注集需自行准备,四篇前置及对比材料在所给记录中尚无可定位目标,但不阻断独立片段实验。 已取消无法确定目标的可点击引用「向量数据库/Qdrant/08-搜索与查询详解」,保留原提及;Missing link target: 向量数据库/Qdrant/08-搜索与查询详解 已取消无法确定目标的可点击引用「向量数据库/通用知识/04-RAG系统架构设计模式」,保留原提及;Missing link target: 向量数据库/通用知识/04-RAG系统架构设计模式 已取消无法确定目标的可点击引用「向量数据库/通用知识/03-混合搜索工程实践」,保留原提及;Missing link target: 向量数据库/通用知识/03-混合搜索工程实践 已取消无法确定目标的可点击引用「向量数据库/Milvus/10-RAG集成实战」,保留原提及;Missing link target: 向量数据库/Milvus/10-RAG集成实战
Qdrant RAG 集成实战
abstract 本文目标
- 掌握 Qdrant 独有的 RAG 优势:理解 Payload 过滤、原生混合搜索、FastEmbed、Binary Quantization 如何协同提升 RAG 质量
- 设计生产级 Collection Schema:规划稀疏 + 稠密双路向量字段和完整的 Payload 元数据体系
- 零配置启动:使用 FastEmbed 在不依赖外部 Embedding 服务的情况下构建完整 RAG 管道
- 框架集成示例:展示 LangChain 和 LlamaIndex 的接入思路;投入生产前需确认接口兼容性、数据字段映射和安全边界
- 多租户 RAG:理解 per-tenant collection 与 shared collection + payload filter 两种策略的适用场景
- 量化效果评估:探索 Binary Quantization 在大规模 RAG 中的应用;float32 到 1-bit 的向量表示理论压缩比为 32x,召回率下降 < 2% 仅作为待验证的实验目标,不代表亿级系统效果保证
Qdrant 与 Milvus 都是主流向量数据库,但在 RAG 场景中的技术路线存在显著差异。Milvus 更侧重大规模分布式,Qdrant 则在灵活性、开发体验和运营成本上形成差异化——内置 FastEmbed 零配置嵌入、Payload 过滤直达向量索引内部、原生稀疏向量无需外挂 BM25 服务,以及二值量化将内存需求压至极限。
本文以构建企业知识库 RAG 系统为主线,完整展示 Qdrant 的这些差异化能力如何在真实工程场景中落地。
阅读前置:搜索与查询详解 | RAG 架构设计模式 | 混合搜索工程实践
对比参考:Milvus RAG 集成实战
目录
- 一、Qdrant RAG 架构与差异化定位
- 二、Collection Schema 设计
- 三、FastEmbed 集成(Qdrant 独有)
- 四、文档处理与索引流水线
- 五、LangChain 集成
- 六、LlamaIndex 集成
- 七、Qdrant 独有的 RAG 高级技巧
- 八、Binary Quantization 在大规模 RAG 中的应用
- 九、性能评估与 RAGAS 测试
一、Qdrant RAG 架构与差异化定位
1.1 Qdrant 在 RAG 中的技术定位
在向量数据库的竞争格局里,Qdrant 为 RAG 场景提供了几个 Milvus 没有的原生能力:
| 能力 | Qdrant | Milvus |
|---|---|---|
| 内置 Embedding | FastEmbed(ONNX量化) | 无,需外部服务 |
| 稀疏向量支持 | 原生 SparseVector 字段 | 需单独服务 + Hybrid搜索中间层 |
| Payload 过滤粒度 | 过滤直接参与 HNSW 导航 | 过滤在召回后执行 |
| 多租户策略 | Collection + Payload 双模式 | Collection 隔离为主 |
| 二值量化 | 内置,32x 压缩 | 需要外部压缩 |
| Discovery API | 独有,上下文感知搜索 | 无 |
note Payload 过滤的核心优势
Qdrant 的过滤器直接在 HNSW 图遍历过程中生效(filtered navigation),而不是先召回 Top-K 再过滤。这有助于在满足过滤条件的候选范围内检索;结果质量仍取决于过滤选择性、索引和搜索参数,应在多租户数据上评估。
1.2 完整 RAG 数据流
%%{init: {"theme":"base","themeVariables":{"fontFamily":"PingFang SC, -apple-system, Source Han Serif SC, serif","fontSize":"16px","background":"#FDFBF5","primaryColor":"#FDFBF5","primaryTextColor":"#2D2A26","primaryBorderColor":"#6E6A60","secondaryColor":"#E8EFE8","secondaryBorderColor":"#8FA890","secondaryTextColor":"#2D2A26","tertiaryColor":"#FDFBF5","tertiaryBorderColor":"#9A9488","tertiaryTextColor":"#6E6A60","lineColor":"#6E6A60","textColor":"#2D2A26","mainBkg":"#FDFBF5","nodeBorder":"#2D5A3D","clusterBkg":"#F3EEDF","clusterBorder":"#9A9488","edgeLabelBackground":"#FDFBF5"},"flowchart":{"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":60,"rankSpacing":70,"useMaxWidth":false},"themeCSS":".node rect, .node polygon, .node circle { stroke-width: 1.5px; } .edgePath .path { stroke-width: 1.5px; } .cluster-label .nodeLabel, .cluster-label foreignObject { color: #2D2A26 !important; } .edgeLabel, .edgeLabel foreignObject { color: #2D2A26 !important; background-color: #FDFBF5; } .edgeLabel rect { fill: #FDFBF5; }"}}%%
flowchart TB
subgraph offline["离线阶段(数据索引)"]
D1["原始文档<br/>PDF / MD / HTML / DOCX"]
D2["文档解析<br/>提取文本 + 元数据"]
D3["语义分块<br/>chunk + parent 双层策略"]
D4["向量化<br/>FastEmbed / BGE-M3 双路"]
D5["Payload 构造<br/>source / tenant_id / chunk_index"]
D6["批量 Upsert<br/>稠密 + 稀疏向量 + Payload"]
D1 --> D2 --> D3 --> D4 --> D5 --> D6
end
subgraph online["在线阶段(检索生成)"]
Q1["用户提问<br/>含租户上下文"]
Q2["Query 向量化<br/>FastEmbed 即时生成"]
Q3["Qdrant 混合搜索<br/>稠密 + 稀疏 + Payload 过滤"]
Q4["候选召回<br/>Top-K 文本块"]
Q5["Reranker 精排<br/>BGE-Reranker-v2-m3"]
Q6["Parent 文档回溯<br/>小块检索大块上下文"]
Q7["LLM 生成<br/>含来源引用"]
Q1 --> Q2 --> Q3 --> Q4 --> Q5 --> Q6 --> Q7
end
D6 -.->|"Qdrant Collection<br/>Binary Quantized"| Q3
style D1 fill:#dbeafe,stroke:#3b82f6
style D6 fill:#dcfce7,stroke:#16a34a
style Q3 fill:#fef9c3,stroke:#ca8a04
style Q7 fill:#fce7f3,stroke:#db2777
1.3 与 Milvus RAG 的关键差异点
| 对比维度 | Qdrant | Milvus |
|---|---|---|
| Embedding 服务 | FastEmbed 内置,零外部依赖 | 必须部署独立 Embedding 服务 |
| 稀疏向量 | 原生 SparseVector 字段类型 |
需要 BM25 Function 或 BGE-M3 服务 |
| Payload 过滤 | 过滤嵌入 HNSW 图导航中 | 后过滤(post-filtering) |
| 分块策略支持 | payload.parent_id 自然支持 Parent-Child |
需要额外 Collection 或字段设计 |
| 量化策略 | Binary / Scalar / Product 三种 | IVF+PQ 为主 |
| API 风格 | REST + gRPC,Payload 驱动 | SDK 驱动,Schema 强约束 |
二、Collection Schema 设计
2.1 核心设计原则
Qdrant 的 Schema 设计思路与 Milvus 不同。Milvus 是"先定义所有字段"的强 Schema,Qdrant 则是"向量字段声明 + Payload 自由扩展"——向量字段必须在 Collection 创建时声明,但 Payload 可以随时添加任意字段。
RAG 专用 Collection 需要声明的向量字段:
dense:稠密语义向量(HNSW 索引)sparse:稀疏词汇向量(用于关键词匹配,无需 BM25 服务)
Payload 字段(动态,无需预声明,但需要为过滤字段建索引):
| 字段名 | 类型 | 用途 | 是否建索引 |
|---|---|---|---|
source |
keyword | 来源文档路径/URL | 是(过滤) |
chunk_index |
integer | 块在原文中的顺序 | 是(排序) |
parent_id |
keyword | 父文档 ID | 是(关联查询) |
tenant_id |
keyword | 租户标识(多租户) | 是(关键!) |
doc_type |
keyword | 文档类型(pdf/md/html) | 是(过滤) |
language |
keyword | 语言标识(zh/en) | 是(过滤) |
created_at |
datetime | 创建时间 | 是(范围查询) |
chunk_size |
integer | 块字符数 | 否 |
text |
text | 原始文本内容 | 否(全文由稀疏向量负责) |
title |
keyword | 所属文档标题 | 是(过滤) |
section |
keyword | 所属章节 | 是(过滤) |
2.2 Collection 创建代码
from qdrant_client import QdrantClient
from qdrant_client.models import (
VectorParams, Distance,
SparseVectorParams, SparseIndexParams,
HnswConfigDiff, QuantizationConfig,
BinaryQuantization, BinaryQuantizationConfig,
PayloadSchemaType
)
client = QdrantClient(url="http://localhost:6333")
COLLECTION_NAME = "enterprise_rag"
DENSE_DIM = 1024 # BGE-M3 稠密向量维度
# 创建支持混合搜索 + Binary Quantization 的 RAG Collection
client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config={
# 稠密语义向量
"dense": VectorParams(
size=DENSE_DIM,
distance=Distance.COSINE,
hnsw_config=HnswConfigDiff(
m=16, # HNSW 连接数,越大精度越高内存越大
ef_construct=200, # 构建时探索宽度
full_scan_threshold=10000, # 小集合时直接暴力搜索
on_disk=False, # 热数据保留内存,冷数据可设 True
),
# 启用 Binary Quantization:内存压缩 32x
quantization_config=QuantizationConfig(
binary=BinaryQuantization(
binary=BinaryQuantizationConfig(
always_ram=True # 量化后向量常驻内存,原始向量可落盘
)
)
),
on_disk=True, # 原始向量落盘,只有量化向量在内存
)
},
# 稀疏向量:原生支持,无需外部 BM25 服务
sparse_vectors_config={
"sparse": SparseVectorParams(
index=SparseIndexParams(
on_disk=False, # 稀疏索引保留内存
)
)
},
# 优化器配置:针对 RAG 写入模式优化
optimizers_config={
"default_segment_number": 4,
"indexing_threshold": 10000, # 10k 以上才触发索引构建
}
)
# 为需要过滤的 Payload 字段创建索引(关键!)
# 没有索引的字段过滤会触发全量扫描
payload_indexes = [
("tenant_id", PayloadSchemaType.KEYWORD),
("source", PayloadSchemaType.KEYWORD),
("doc_type", PayloadSchemaType.KEYWORD),
("chunk_index", PayloadSchemaType.INTEGER),
("parent_id", PayloadSchemaType.KEYWORD),
("language", PayloadSchemaType.KEYWORD),
("title", PayloadSchemaType.KEYWORD),
("section", PayloadSchemaType.KEYWORD),
("created_at", PayloadSchemaType.DATETIME),
]
for field_name, schema_type in payload_indexes:
client.create_payload_index(
collection_name=COLLECTION_NAME,
field_name=field_name,
field_schema=schema_type,
)
print(f"Created payload index: {field_name}")
print(f"Collection '{COLLECTION_NAME}' created successfully.")
warning Payload 索引是过滤性能的关键
不建索引的 Payload 字段在过滤时会回退到全量顺序扫描,在百万级向量时延迟会从毫秒级跌落到秒级。多租户 RAG 中 tenant_id 字段的索引尤其重要。
2.3 Parent-Child 分块的 Schema 设计
Parent-Child 策略是提升 RAG 质量的核心技巧:小块索引(精准检索),大块回溯(完整上下文)。
# Parent 文档:存储完整段落(500-1000 字),不参与向量检索
# Child 文档:存储细粒度块(100-200 字),参与向量检索
# 关联:child.parent_id = parent 的 point_id
import uuid
from qdrant_client.models import PointStruct
def create_parent_child_points(
parent_text: str,
child_chunks: list[str],
source: str,
tenant_id: str,
doc_type: str,
embed_fn, # 生成稠密向量的函数
sparse_embed_fn, # 生成稀疏向量的函数
) -> list[PointStruct]:
"""构造 Parent-Child 结构的 Qdrant Points"""
points = []
parent_id = str(uuid.uuid4())
# Parent 点:存储完整上下文,向量为空(不参与向量检索)
# 使用 Payload 标记为 parent,child 通过 parent_id 关联
parent_point = PointStruct(
id=parent_id,
vector={}, # 空向量,parent 不参与向量搜索
payload={
"text": parent_text,
"is_parent": True,
"source": source,
"tenant_id": tenant_id,
"doc_type": doc_type,
"chunk_index": -1, # -1 标记为 parent
"parent_id": parent_id, # 自身即为 parent
}
)
points.append(parent_point)
# 注意:Qdrant 允许存储没有向量的点(用于 payload 关联查询)
# 实际上通常用另一个 Collection 存储 parent,这里为演示简化
# Child 点:小块,携带向量,通过 parent_id 关联 parent
for idx, chunk_text in enumerate(child_chunks):
child_id = str(uuid.uuid4())
dense_vec = embed_fn(chunk_text)
sparse_vec = sparse_embed_fn(chunk_text)
child_point = PointStruct(
id=child_id,
vector={
"dense": dense_vec,
"sparse": sparse_vec,
},
payload={
"text": chunk_text,
"is_parent": False,
"parent_id": parent_id, # 关联 parent
"source": source,
"tenant_id": tenant_id,
"doc_type": doc_type,
"chunk_index": idx,
}
)
points.append(child_point)
return points
三、FastEmbed 集成(Qdrant 独有)
FastEmbed 是 Qdrant 官方开发并维护的轻量级 Embedding 库,基于 ONNX Runtime,无需 GPU、无需 PyTorch,在 CPU 上也能达到生产可用的吞吐量。
3.1 FastEmbed 的核心优势
下表中的内存、首次加载时间和 CPU 吞吐量数字仅作为待验证的对照假设保留,并非本文已测得的结果;比较需固定模型、输入长度、硬件、批大小及缓存状态。
| 维度 | FastEmbed | SentenceTransformers |
|---|---|---|
| 运行时依赖 | ONNX Runtime(轻量) | PyTorch(重量级) |
| GPU 依赖 | 不需要 | 推荐 |
| 内存占用 | ~200MB(量化模型) | ~1GB+ |
| 首次加载时间 | ~2秒 | ~10秒 |
| 吞吐量(CPU) | ~500 docs/s | ~100 docs/s |
| 与 Qdrant 集成 | 原生内置 | 需手动桥接 |
| 稀疏向量支持 | SPLADE/BM25-S | 需额外安装 |
3.2 直接在 QdrantClient 中配置 FastEmbed
from qdrant_client import QdrantClient
# 方式一:在 QdrantClient 初始化时直接指定 embedding 模型
# 这是 Qdrant 独有的零配置方式
client = QdrantClient(
url="http://localhost:6333",
# 无需任何 embedding 配置,FastEmbed 会自动下载模型
)
# 使用内置 FastEmbed 直接 add_documents(最简方式)
# Qdrant Client 自动完成向量化 + 存储,无需手动生成向量
docs = [
"Qdrant 是用 Rust 编写的向量数据库",
"FastEmbed 是 Qdrant 官方的轻量 Embedding 库",
"RAG 系统通过检索相关文档来增强 LLM 的回答质量",
]
client.add(
collection_name="quick_start_rag",
documents=docs,
# 默认使用 BAAI/bge-small-en-v1.5(384维),无需任何配置
)
# 直接用自然语言查询,无需手动生成 query vector
results = client.query(
collection_name="quick_start_rag",
query_text="什么是向量数据库",
limit=3,
)
for result in results:
print(f"Score: {result.score:.4f} | Text: {result.document}")
3.3 FastEmbed 高级配置
from fastembed import TextEmbedding, SparseTextEmbedding
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, SparseVectorParams
# 查看所有支持的模型
print(TextEmbedding.list_supported_models())
# 常用模型:
# - BAAI/bge-small-en-v1.5 (384维,极速,英文)
# - BAAI/bge-base-en-v1.5 (768维,均衡,英文)
# - BAAI/bge-large-en-v1.5 (1024维,高精度,英文)
# - BAAI/bge-m3 (1024维,多语言,推荐中文)
# - sentence-transformers/all-MiniLM-L6-v2 (384维,通用)
# 查看稀疏向量模型
print(SparseTextEmbedding.list_supported_models())
# - prithivida/Splade_PP_en_v1 (英文稀疏)
# - Qdrant/BM25 (BM25 近似稀疏)
# 手动配置 FastEmbed(用于需要精细控制的场景)
dense_embedder = TextEmbedding(
model_name="BAAI/bge-m3", # 中文推荐
max_length=512,
# cache_dir="~/.cache/fastembed", # 模型缓存目录
)
sparse_embedder = SparseTextEmbedding(
model_name="Qdrant/BM25", # 轻量稀疏向量
)
# 批量生成向量(生成器方式,节省内存)
texts = ["文档内容1", "文档内容2", "文档内容3"]
dense_embeddings = list(dense_embedder.embed(texts, batch_size=32))
sparse_embeddings = list(sparse_embedder.embed(texts, batch_size=32))
print(f"Dense shape: {len(dense_embeddings[0])} dims")
print(f"Sparse indices: {len(sparse_embeddings[0].indices)} non-zero terms")
3.4 FastEmbed 在 RAG 中的生产配置
import os
from typing import Generator
from fastembed import TextEmbedding, SparseTextEmbedding
import numpy as np
class FastEmbedRAGEncoder:
"""
生产级 FastEmbed 封装
支持批量编码、缓存、错误处理
"""
def __init__(
self,
dense_model: str = "BAAI/bge-m3",
sparse_model: str = "Qdrant/BM25",
batch_size: int = 64,
cache_dir: str = None,
):
self.batch_size = batch_size
# 初始化稠密向量模型
kwargs = {}
if cache_dir:
kwargs["cache_dir"] = cache_dir
print(f"Loading dense model: {dense_model}")
self.dense_model = TextEmbedding(
model_name=dense_model,
**kwargs
)
print(f"Loading sparse model: {sparse_model}")
self.sparse_model = SparseTextEmbedding(
model_name=sparse_model,
**kwargs
)
# 预热模型(避免第一次查询延迟)
_ = list(self.dense_model.embed(["warmup"]))
_ = list(self.sparse_model.embed(["warmup"]))
print("Models warmed up.")
def encode_dense(self, texts: list[str]) -> list[list[float]]:
"""批量生成稠密向量"""
embeddings = list(
self.dense_model.embed(texts, batch_size=self.batch_size)
)
return [emb.tolist() for emb in embeddings]
def encode_sparse(self, texts: list[str]) -> list[dict]:
"""批量生成稀疏向量,返回 Qdrant SparseVector 格式"""
from qdrant_client.models import SparseVector
sparse_embs = list(
self.sparse_model.embed(texts, batch_size=self.batch_size)
)
return [
SparseVector(
indices=emb.indices.tolist(),
values=emb.values.tolist(),
)
for emb in sparse_embs
]
def encode_query(self, query: str) -> tuple[list[float], dict]:
"""
编码查询文本,返回 (稠密向量, 稀疏向量)
查询编码使用 query_prefix(BGE-M3 特性)
"""
dense = list(self.dense_model.query_embed([query]))[0].tolist()
sparse = list(self.sparse_model.embed([query]))[0]
from qdrant_client.models import SparseVector
sparse_vec = SparseVector(
indices=sparse.indices.tolist(),
values=sparse.values.tolist(),
)
return dense, sparse_vec
# 初始化编码器(全局单例,避免重复加载)
encoder = FastEmbedRAGEncoder(
dense_model="BAAI/bge-m3",
sparse_model="Qdrant/BM25",
batch_size=64,
)
tip BGE-M3 的 query_prefix 机制
BGE-M3 在编码查询时需要添加前缀 "Represent this sentence for searching relevant passages: ",FastEmbed 的 query_embed 方法会自动处理这个细节。手动调用 embed 方法对查询文本不会加前缀,会导致检索精度下降。
四、文档处理与索引流水线
4.1 文档解析(多格式支持)
import os
import re
from pathlib import Path
from typing import Optional
from dataclasses import dataclass, field
@dataclass
class DocumentChunk:
"""文档块的数据结构"""
text: str
source: str
doc_type: str
chunk_index: int
title: str = ""
section: str = ""
language: str = "zh"
parent_text: str = "" # Parent 块的完整文本
metadata: dict = field(default_factory=dict)
class DocumentParser:
"""多格式文档解析器"""
def parse(self, file_path: str) -> str:
"""根据文件类型选择解析方法"""
path = Path(file_path)
suffix = path.suffix.lower()
parsers = {
".pdf": self._parse_pdf,
".md": self._parse_markdown,
".html": self._parse_html,
".htm": self._parse_html,
".txt": self._parse_text,
".docx": self._parse_docx,
}
parser = parsers.get(suffix, self._parse_text)
return parser(file_path)
def _parse_pdf(self, path: str) -> str:
"""PDF 解析:使用 pdfminer 保留结构信息"""
try:
from pdfminer.high_level import extract_text
text = extract_text(path)
# 清理多余空白
text = re.sub(r'\n{3,}', '\n\n', text)
return text.strip()
except ImportError:
raise ImportError("需要安装 pdfminer.six: pip install pdfminer.six")
def _parse_markdown(self, path: str) -> str:
"""Markdown 解析:保留标题层级作为结构信息"""
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
# 移除 YAML frontmatter
content = re.sub(r'^---\n.*?\n---\n', '', content, flags=re.DOTALL)
return content.strip()
def _parse_html(self, path: str) -> str:
"""HTML 解析:使用 BeautifulSoup 提取正文"""
try:
from bs4 import BeautifulSoup
with open(path, 'r', encoding='utf-8') as f:
soup = BeautifulSoup(f.read(), 'html.parser')
# 移除脚本和样式
for tag in soup(["script", "style", "nav", "footer"]):
tag.decompose()
return soup.get_text(separator='\n', strip=True)
except ImportError:
raise ImportError("需要安装 beautifulsoup4: pip install beautifulsoup4")
def _parse_docx(self, path: str) -> str:
"""Word 文档解析"""
try:
from docx import Document
doc = Document(path)
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
return '\n\n'.join(paragraphs)
except ImportError:
raise ImportError("需要安装 python-docx: pip install python-docx")
def _parse_text(self, path: str) -> str:
with open(path, 'r', encoding='utf-8') as f:
return f.read()
4.2 语义分块(Chunk + Parent 策略)
from langchain.text_splitter import RecursiveCharacterTextSplitter
class SemanticChunker:
"""
Parent-Child 语义分块器
- Parent 块:500-1000 字,保留完整语义上下文
- Child 块:100-200 字,精细粒度用于精准检索
"""
def __init__(
self,
parent_chunk_size: int = 800,
parent_overlap: int = 100,
child_chunk_size: int = 200,
child_overlap: int = 50,
):
# Parent 分块器:较大窗口
self.parent_splitter = RecursiveCharacterTextSplitter(
chunk_size=parent_chunk_size,
chunk_overlap=parent_overlap,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
length_function=len,
)
# Child 分块器:细粒度
self.child_splitter = RecursiveCharacterTextSplitter(
chunk_size=child_chunk_size,
chunk_overlap=child_overlap,
separators=["\n", "。", "!", "?", ";", ",", " ", ""],
length_function=len,
)
def chunk_with_parent(
self,
text: str,
source: str,
doc_type: str,
title: str = "",
language: str = "zh",
) -> list[DocumentChunk]:
"""
生成 Parent-Child 结构的文档块
返回: child chunks,每个 child 携带对应 parent_text
"""
# 第一层:切分 parent 块
parent_chunks = self.parent_splitter.split_text(text)
all_child_chunks = []
global_chunk_idx = 0
for parent_idx, parent_text in enumerate(parent_chunks):
# 第二层:在每个 parent 内切分 child 块
child_texts = self.child_splitter.split_text(parent_text)
# 提取 parent 所在章节(简单实现:查找最近的标题)
section = self._extract_section(parent_text)
for child_idx, child_text in enumerate(child_texts):
if len(child_text.strip()) < 20: # 跳过过短的块
continue
chunk = DocumentChunk(
text=child_text.strip(),
source=source,
doc_type=doc_type,
chunk_index=global_chunk_idx,
title=title,
section=section,
language=language,
parent_text=parent_text, # 关键:携带完整 parent 上下文
metadata={
"parent_index": parent_idx,
"child_index_in_parent": child_idx,
}
)
all_child_chunks.append(chunk)
global_chunk_idx += 1
return all_child_chunks
def _extract_section(self, text: str) -> str:
"""从文本中提取章节标题(Markdown 格式)"""
lines = text.split('\n')
for line in lines:
if line.startswith('#'):
return line.lstrip('#').strip()
return ""
4.3 批量 Upsert(BGE-M3 双路向量)
import uuid
import time
from tqdm import tqdm
from qdrant_client.models import PointStruct, SparseVector
class QdrantRAGIndexer:
"""
Qdrant RAG 批量索引器
支持批量 upsert 和进度追踪;当前使用随机点 ID,未实现断点续传或幂等更新
"""
def __init__(
self,
client: QdrantClient,
collection_name: str,
encoder: FastEmbedRAGEncoder,
batch_size: int = 100,
tenant_id: str = "default",
):
self.client = client
self.collection_name = collection_name
self.encoder = encoder
self.batch_size = batch_size
self.tenant_id = tenant_id
def index_documents(
self,
file_paths: list[str],
chunker: SemanticChunker = None,
) -> dict:
"""
完整的文档索引流程
返回统计信息:{indexed_docs, total_chunks, failed_docs}
"""
if chunker is None:
chunker = SemanticChunker()
parser = DocumentParser()
stats = {"indexed_docs": 0, "total_chunks": 0, "failed_docs": []}
for file_path in tqdm(file_paths, desc="Indexing documents"):
try:
# 1. 解析文档
text = parser.parse(file_path)
doc_type = Path(file_path).suffix.lstrip('.')
title = Path(file_path).stem
# 2. 语义分块
chunks = chunker.chunk_with_parent(
text=text,
source=file_path,
doc_type=doc_type,
title=title,
)
# 3. 批量向量化 + upsert
self._batch_upsert(chunks)
stats["indexed_docs"] += 1
stats["total_chunks"] += len(chunks)
except Exception as e:
print(f"Failed to index {file_path}: {e}")
stats["failed_docs"].append(file_path)
return stats
def _batch_upsert(self, chunks: list[DocumentChunk]):
"""批量生成向量并写入 Qdrant"""
texts = [chunk.text for chunk in chunks]
# 批量向量化(FastEmbed 自动批处理)
dense_vecs = self.encoder.encode_dense(texts)
sparse_vecs = self.encoder.encode_sparse(texts)
# 构造 Qdrant Points
points = []
for i, chunk in enumerate(chunks):
point_id = str(uuid.uuid4())
# 构造 Payload(Qdrant 的核心优势:灵活的元数据)
payload = {
"text": chunk.text,
"parent_text": chunk.parent_text, # 存储 parent 上下文
"source": chunk.source,
"doc_type": chunk.doc_type,
"chunk_index": chunk.chunk_index,
"title": chunk.title,
"section": chunk.section,
"language": chunk.language,
"tenant_id": self.tenant_id,
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
**chunk.metadata,
}
point = PointStruct(
id=point_id,
vector={
"dense": dense_vecs[i],
"sparse": sparse_vecs[i],
},
payload=payload,
)
points.append(point)
# 分批写入(避免单次请求过大)
for batch_start in range(0, len(points), self.batch_size):
batch = points[batch_start:batch_start + self.batch_size]
self.client.upsert(
collection_name=self.collection_name,
points=batch,
wait=True, # 等待写入完成再返回
)
def index_texts(
self,
texts: list[str],
metadatas: list[dict] = None,
):
"""
直接索引文本列表(用于增量更新场景)
metadatas: 每个文本对应的元数据字典
"""
if metadatas is None:
metadatas = [{} for _ in texts]
dense_vecs = self.encoder.encode_dense(texts)
sparse_vecs = self.encoder.encode_sparse(texts)
points = []
for i, (text, meta) in enumerate(zip(texts, metadatas)):
payload = {
"text": text,
"tenant_id": self.tenant_id,
**meta,
}
points.append(PointStruct(
id=str(uuid.uuid4()),
vector={
"dense": dense_vecs[i],
"sparse": sparse_vecs[i],
},
payload=payload,
))
for batch_start in range(0, len(points), self.batch_size):
batch = points[batch_start:batch_start + self.batch_size]
self.client.upsert(
collection_name=self.collection_name,
points=batch,
wait=True,
)
五、LangChain 集成
5.1 安装依赖
pip install langchain langchain-community langchain-qdrant
pip install qdrant-client[fastembed]
pip install langchain-openai # 或其他 LLM 提供商
5.2 基础 LangChain + Qdrant 集成
from langchain_qdrant import QdrantVectorStore
from langchain_community.embeddings import FastEmbedEmbeddings
from langchain_openai import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
from qdrant_client import QdrantClient
# 初始化 Embedding(使用 FastEmbed,无需 OpenAI Embedding API)
embeddings = FastEmbedEmbeddings(
model_name="BAAI/bge-m3",
)
# 连接已有 Collection
client = QdrantClient(url="http://localhost:6333")
vectorstore = QdrantVectorStore(
client=client,
collection_name="enterprise_rag",
embedding=embeddings,
vector_name="dense", # 指定稠密向量字段名
)
# 基础检索器
retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": 5},
)
# 构造 RAG Chain
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
prompt = PromptTemplate(
template="""基于以下上下文回答问题。如果上下文中没有相关信息,请明确说明。
上下文:
{context}
问题:{question}
回答(请引用来源):""",
input_variables=["context", "question"],
)
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=retriever,
chain_type_kwargs={"prompt": prompt},
return_source_documents=True,
)
# 执行查询
result = qa_chain.invoke({"query": "Qdrant 的核心优势是什么?"})
print("Answer:", result["result"])
print("\nSources:")
for doc in result["source_documents"]:
print(f" - {doc.metadata.get('source', 'Unknown')} | chunk {doc.metadata.get('chunk_index', 0)}")
5.3 带 Payload 过滤的 RAG Chain(Qdrant 核心优势)
这是 Qdrant 与 Milvus 在 RAG 中最显著的差异——Payload 过滤直接参与 HNSW 图导航,而非后过滤。
from langchain_qdrant import QdrantVectorStore
from qdrant_client.models import Filter, FieldCondition, MatchValue, Range
from langchain.schema import BaseRetriever
from langchain.schema.document import Document
from typing import Any
class QdrantPayloadFilterRetriever(BaseRetriever):
"""
带 Payload 过滤的 Qdrant 检索器
核心优势:过滤直接嵌入 HNSW 图搜索中,不是后过滤
"""
vectorstore: QdrantVectorStore
k: int = 5
tenant_id: str = "default"
doc_type: str = None
language: str = None
source_filter: str = None
def _get_relevant_documents(
self, query: str, *, run_manager=None
) -> list[Document]:
"""带 Payload 过滤的文档检索"""
# 构造 Qdrant 过滤条件
must_conditions = [
# 多租户隔离:最重要的过滤条件
FieldCondition(
key="tenant_id",
match=MatchValue(value=self.tenant_id)
)
]
if self.doc_type:
must_conditions.append(
FieldCondition(
key="doc_type",
match=MatchValue(value=self.doc_type)
)
)
if self.language:
must_conditions.append(
FieldCondition(
key="language",
match=MatchValue(value=self.language)
)
)
if self.source_filter:
must_conditions.append(
FieldCondition(
key="source",
match=MatchValue(value=self.source_filter)
)
)
qdrant_filter = Filter(must=must_conditions)
# 执行带过滤的相似度搜索
docs = self.vectorstore.similarity_search_with_score(
query=query,
k=self.k,
filter=qdrant_filter,
)
# 转换为 LangChain Document 格式
return [
Document(
page_content=doc.page_content,
metadata={
**doc.metadata,
"score": score,
}
)
for doc, score in docs
]
# 使用示例:企业多租户 RAG
def build_tenant_rag_chain(tenant_id: str, doc_type: str = None):
"""为特定租户构建隔离的 RAG Chain"""
embeddings = FastEmbedEmbeddings(model_name="BAAI/bge-m3")
client = QdrantClient(url="http://localhost:6333")
vectorstore = QdrantVectorStore(
client=client,
collection_name="enterprise_rag",
embedding=embeddings,
vector_name="dense",
)
# 带租户过滤的检索器
retriever = QdrantPayloadFilterRetriever(
vectorstore=vectorstore,
k=6,
tenant_id=tenant_id,
doc_type=doc_type,
)
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_template("""
你是 {tenant_id} 企业的专属知识助手。仅基于以下文档内容回答问题,
不要使用训练数据中的通用知识。
参考文档:
{context}
用户问题:{input}
回答:""")
document_chain = create_stuff_documents_chain(llm, prompt)
rag_chain = create_retrieval_chain(retriever, document_chain)
return rag_chain
# 租户 A 的 RAG(只能访问 tenant_a 的文档)
chain_a = build_tenant_rag_chain("tenant_a", doc_type="pdf")
result = chain_a.invoke({
"input": "公司的采购流程是什么?",
"tenant_id": "tenant_a",
})
print(result["answer"])
5.4 流式输出的 RAG Chain
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from langchain_qdrant import QdrantVectorStore
def format_docs(docs: list[Document]) -> str:
"""将检索到的文档格式化为上下文字符串"""
formatted = []
for i, doc in enumerate(docs, 1):
source = doc.metadata.get('source', 'Unknown')
chunk_idx = doc.metadata.get('chunk_index', 0)
formatted.append(f"[{i}] 来源: {source} | 块 {chunk_idx}\n{doc.page_content}")
return "\n\n".join(formatted)
# 构造流式 RAG Chain(LangChain Expression Language)
def build_streaming_rag_chain(
vectorstore: QdrantVectorStore,
llm,
tenant_id: str,
k: int = 5,
):
from qdrant_client.models import Filter, FieldCondition, MatchValue
# 创建带过滤的检索器
retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={
"k": k,
"filter": Filter(
must=[
FieldCondition(
key="tenant_id",
match=MatchValue(value=tenant_id)
)
]
),
},
)
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个专业的知识库助手,请基于提供的上下文回答问题。"),
("human", "上下文:\n{context}\n\n问题:{question}"),
])
# LCEL 链:支持流式输出
chain = (
{
"context": retriever | format_docs,
"question": RunnablePassthrough(),
}
| prompt
| llm
| StrOutputParser()
)
return chain
# 流式调用
chain = build_streaming_rag_chain(vectorstore, llm, tenant_id="tenant_a")
# 非流式
response = chain.invoke("Qdrant 的 Payload 过滤有什么优势?")
# 流式输出
for token in chain.stream("解释一下 Binary Quantization 的原理"):
print(token, end="", flush=True)
print()
5.5 带 Parent 文档回溯的高质量 RAG
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryByteStore
from langchain_qdrant import QdrantVectorStore
def build_parent_document_rag(
client: QdrantClient,
collection_name: str,
embeddings,
):
"""
Parent Document Retriever:
- 小块(child)用于向量检索(精准定位)
- 大块(parent)用于 LLM 上下文(完整信息)
Qdrant 实现方式:child 的 payload.parent_text 存储了对应 parent 内容
检索到 child 后,直接从 payload 取 parent_text 扩展上下文
"""
vectorstore = QdrantVectorStore(
client=client,
collection_name=collection_name,
embedding=embeddings,
vector_name="dense",
)
retriever = vectorstore.as_retriever(search_kwargs={"k": 8})
class ParentExpandRetriever(BaseRetriever):
"""
Qdrant 特化的 Parent 扩展检索器
利用 payload.parent_text 字段直接扩展上下文
"""
base_retriever: Any
def _get_relevant_documents(self, query: str, **kwargs) -> list[Document]:
# 第一步:检索 child chunks(小块,精准定位)
child_docs = self.base_retriever.get_relevant_documents(query)
# 第二步:从 payload 取出 parent_text(完整上下文)
# 去重:同一 parent 只保留一次
seen_parent_texts = set()
expanded_docs = []
for doc in child_docs:
parent_text = doc.metadata.get("parent_text", "")
if parent_text and parent_text not in seen_parent_texts:
seen_parent_texts.add(parent_text)
expanded_docs.append(Document(
page_content=parent_text, # 用完整 parent 文本
metadata=doc.metadata,
))
elif not parent_text:
expanded_docs.append(doc) # 没有 parent,用原始 child
return expanded_docs[:5] # 最多返回 5 个 parent 上下文
return ParentExpandRetriever(base_retriever=retriever)
六、LlamaIndex 集成
6.1 安装依赖
pip install llama-index
pip install llama-index-vector-stores-qdrant
pip install llama-index-embeddings-fastembed
pip install llama-index-llms-openai
6.2 基础 LlamaIndex + Qdrant 集成
from llama_index.core import VectorStoreIndex, StorageContext, Settings
from llama_index.vector_stores.qdrant import QdrantVectorStore
from llama_index.embeddings.fastembed import FastEmbedEmbedding
from llama_index.llms.openai import OpenAI
from qdrant_client import QdrantClient
# 全局配置(LlamaIndex v0.10+ 的新方式)
Settings.embed_model = FastEmbedEmbedding(
model_name="BAAI/bge-m3",
)
Settings.llm = OpenAI(model="gpt-4o-mini", temperature=0)
Settings.chunk_size = 512
Settings.chunk_overlap = 50
# 初始化 Qdrant 客户端
client = QdrantClient(url="http://localhost:6333")
# 创建 QdrantVectorStore
vector_store = QdrantVectorStore(
client=client,
collection_name="enterprise_rag",
enable_hybrid=False, # 纯稠密搜索
dense_config=VectorParams(size=1024, distance=Distance.COSINE),
)
# 构建 StorageContext
storage_context = StorageContext.from_defaults(vector_store=vector_store)
# 从已有 Collection 加载索引(不重建)
index = VectorStoreIndex.from_vector_store(
vector_store=vector_store,
storage_context=storage_context,
)
# 创建查询引擎
query_engine = index.as_query_engine(
similarity_top_k=5,
response_mode="compact", # compact / tree_summarize / simple_summarize
)
# 查询
response = query_engine.query("Qdrant 和 Milvus 的主要区别是什么?")
print(response.response)
print("\n引用来源:")
for node in response.source_nodes:
print(f" Score: {node.score:.4f} | {node.metadata.get('source', 'N/A')}")
6.3 混合搜索 QueryEngine(LlamaIndex + Qdrant 原生混合)
from llama_index.core import VectorStoreIndex, StorageContext
from llama_index.vector_stores.qdrant import QdrantVectorStore
from llama_index.embeddings.fastembed import FastEmbedEmbedding
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, SparseVectorParams
# 初始化(混合搜索需要 enable_hybrid=True)
client = QdrantClient(url="http://localhost:6333")
# 混合搜索 VectorStore
hybrid_vector_store = QdrantVectorStore(
client=client,
collection_name="enterprise_rag_hybrid",
# 启用混合搜索:同时使用稠密 + 稀疏向量
enable_hybrid=True,
# 稀疏向量使用 FastEmbed 内置的 SPLADE
sparse_doc_fn=None, # None 表示使用 Qdrant 默认的 FastEmbed sparse
sparse_query_fn=None,
)
# 如果 collection 不存在则创建
if not client.collection_exists("enterprise_rag_hybrid"):
from qdrant_client.models import SparseIndexParams
client.create_collection(
collection_name="enterprise_rag_hybrid",
vectors_config={
"text-dense": VectorParams(
size=1024,
distance=Distance.COSINE,
)
},
sparse_vectors_config={
"text-sparse": SparseVectorParams(
index=SparseIndexParams(on_disk=False)
)
},
)
# 索引文档
from llama_index.core import SimpleDirectoryReader
documents = SimpleDirectoryReader(
input_dir="./knowledge_base",
recursive=True,
required_exts=[".md", ".pdf", ".txt"],
).load_data()
storage_context = StorageContext.from_defaults(
vector_store=hybrid_vector_store
)
index = VectorStoreIndex.from_documents(
documents,
storage_context=storage_context,
show_progress=True,
)
# 混合搜索 QueryEngine
# LlamaIndex 自动处理稠密 + 稀疏双路搜索 + RRF 融合
hybrid_query_engine = index.as_query_engine(
similarity_top_k=8,
sparse_top_k=12, # 稀疏向量召回数(多于稠密,然后融合排序)
vector_store_query_mode="hybrid", # 关键:启用混合模式
response_mode="tree_summarize", # 多文档综合摘要
)
response = hybrid_query_engine.query(
"如何在生产环境中部署 Qdrant 集群并实现高可用?"
)
print("混合搜索回答:")
print(response.response)
6.4 带 Metadata 过滤的 LlamaIndex 查询
from llama_index.core.vector_stores import (
MetadataFilter,
MetadataFilters,
FilterOperator,
FilterCondition,
)
# 构造 LlamaIndex 元数据过滤器
# LlamaIndex 会自动转换为 Qdrant Payload Filter
filters = MetadataFilters(
filters=[
# 多租户隔离
MetadataFilter(
key="tenant_id",
value="tenant_a",
operator=FilterOperator.EQ,
),
# 文档类型过滤
MetadataFilter(
key="doc_type",
value="pdf",
operator=FilterOperator.EQ,
),
],
condition=FilterCondition.AND,
)
# 创建带过滤的查询引擎
filtered_query_engine = index.as_query_engine(
similarity_top_k=5,
filters=filters,
vector_store_query_mode="hybrid",
)
response = filtered_query_engine.query("公司的财务政策是什么?")
print(response.response)
6.5 LlamaIndex 多文档路由 RAG
from llama_index.core.tools import QueryEngineTool, ToolMetadata
from llama_index.core.query_engine import RouterQueryEngine
from llama_index.core.selectors import LLMSingleSelector
# 为不同文档类型构建独立的查询引擎
engines = {}
for doc_type in ["pdf", "md", "html"]:
type_filters = MetadataFilters(
filters=[
MetadataFilter(key="doc_type", value=doc_type, operator=FilterOperator.EQ),
]
)
engines[doc_type] = index.as_query_engine(
similarity_top_k=5,
filters=type_filters,
vector_store_query_mode="hybrid",
)
# 构造路由查询引擎:LLM 自动选择最合适的引擎
router_engine = RouterQueryEngine(
selector=LLMSingleSelector.from_defaults(),
query_engine_tools=[
QueryEngineTool(
query_engine=engines["pdf"],
metadata=ToolMetadata(
name="pdf_docs",
description="适用于 PDF 格式的正式文件,如报告、合同、规范"
),
),
QueryEngineTool(
query_engine=engines["md"],
metadata=ToolMetadata(
name="markdown_docs",
description="适用于 Markdown 格式的技术文档、Wiki 和知识库"
),
),
QueryEngineTool(
query_engine=engines["html"],
metadata=ToolMetadata(
name="web_docs",
description="适用于网页内容和 HTML 格式的在线文档"
),
),
],
)
# 自动路由到合适的文档类型
response = router_engine.query("技术规范文档中关于 API 设计的要求是什么?")
print(response.response)
七、Qdrant 独有的 RAG 高级技巧
7.1 多租户 RAG 策略选型
Qdrant 对多租户 RAG 提供两种策略,选择依据是租户数量和数据隔离需求:
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Per-tenant Collection | 租户数 < 100,高安全需求 | 完全隔离,独立索引无干扰 | Collection 数量多,管理复杂度高 |
| Shared Collection + Payload Filter | 租户数 100~10000,大多数 SaaS 场景 | 资源利用率高,单一管理入口 | 依赖 Payload 过滤,租户间数据同库 |
| Payload + Groups | 超大规模多租户,租户数 > 10000 | 最高资源利用率,Qdrant 优化的分组搜索 | 搜索时需指定 group |
策略一:Per-tenant Collection(严格隔离)
class PerTenantRAG:
"""
每租户独立 Collection 策略
适合:企业级 SaaS,数据合规要求严格,租户数 < 100
"""
def __init__(self, client: QdrantClient, encoder: FastEmbedRAGEncoder):
self.client = client
self.encoder = encoder
self._tenant_collections = {}
def create_tenant(self, tenant_id: str):
"""为新租户创建专属 Collection"""
collection_name = f"rag_{tenant_id}"
if not self.client.collection_exists(collection_name):
self.client.create_collection(
collection_name=collection_name,
vectors_config={
"dense": VectorParams(size=1024, distance=Distance.COSINE)
},
sparse_vectors_config={
"sparse": SparseVectorParams()
},
)
print(f"Created collection for tenant: {tenant_id}")
self._tenant_collections[tenant_id] = collection_name
def search(self, tenant_id: str, query: str, k: int = 5) -> list:
"""在租户专属 Collection 中搜索"""
collection_name = self._tenant_collections.get(tenant_id)
if not collection_name:
raise ValueError(f"Tenant '{tenant_id}' not found")
dense_vec, sparse_vec = self.encoder.encode_query(query)
from qdrant_client.models import (
SearchRequest, NamedVector, NamedSparseVector,
Prefetch, FusionQuery, Fusion
)
# 混合搜索(无需过滤,因为整个 Collection 属于该租户)
results = self.client.query_points(
collection_name=collection_name,
prefetch=[
Prefetch(
query=dense_vec,
using="dense",
limit=k * 3,
),
Prefetch(
query=sparse_vec,
using="sparse",
limit=k * 3,
),
],
query=FusionQuery(fusion=Fusion.RRF),
limit=k,
with_payload=True,
)
return results.points
策略二:Shared Collection + Payload Filter(推荐大多数场景)
class SharedCollectionRAG:
"""
共享 Collection + Payload 过滤策略
适合:大多数 SaaS 场景,租户数 100~10000
Qdrant 的 Payload 过滤直接在 HNSW 图内执行(filtered navigation)
不是简单的"先检索再过滤";性能是否接近无过滤状态,取决于过滤选择性、索引、数据分布和搜索参数
"""
def __init__(
self,
client: QdrantClient,
collection_name: str,
encoder: FastEmbedRAGEncoder,
):
self.client = client
self.collection_name = collection_name
self.encoder = encoder
def search(
self,
tenant_id: str,
query: str,
k: int = 5,
doc_type: str = None,
language: str = None,
date_from: str = None,
) -> list:
"""
带完整 Payload 过滤的混合搜索
过滤直接在 HNSW 图导航中生效,非后过滤
"""
from qdrant_client.models import (
Filter, FieldCondition, MatchValue, DatetimeRange,
Prefetch, FusionQuery, Fusion
)
dense_vec, sparse_vec = self.encoder.encode_query(query)
# 构造过滤条件
must_conditions = [
FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id))
]
if doc_type:
must_conditions.append(
FieldCondition(key="doc_type", match=MatchValue(value=doc_type))
)
if language:
must_conditions.append(
FieldCondition(key="language", match=MatchValue(value=language))
)
if date_from:
from qdrant_client.models import DatetimeRange
must_conditions.append(
FieldCondition(
key="created_at",
range=DatetimeRange(gte=date_from)
)
)
payload_filter = Filter(must=must_conditions)
# 混合搜索 + Payload 过滤(一次调用完成)
results = self.client.query_points(
collection_name=self.collection_name,
prefetch=[
Prefetch(
query=dense_vec,
using="dense",
limit=k * 3,
filter=payload_filter, # 过滤嵌入 prefetch 中
),
Prefetch(
query=sparse_vec,
using="sparse",
limit=k * 3,
filter=payload_filter,
),
],
query=FusionQuery(fusion=Fusion.RRF), # RRF 融合排序
limit=k,
with_payload=True,
)
return results.points
7.2 Discovery API 做 RAG 相关推荐
Discovery API 是 Qdrant 独有的"上下文感知搜索"——不仅指定目标向量,还指定"正例"和"负例"上下文,让搜索结果更符合语义意图。
from qdrant_client.models import ContextExamplePair, DiscoverRequest
class QdrantDiscoveryRAG:
"""
使用 Discovery API 的上下文感知 RAG
场景:用户已阅读了某些文档,希望找"更多类似但有所扩展"的内容
"""
def __init__(self, client: QdrantClient, collection_name: str):
self.client = client
self.collection_name = collection_name
def discover_related(
self,
target_query: str,
positive_doc_ids: list[str], # 用户认为有帮助的文档 ID
negative_doc_ids: list[str] = None, # 用户认为无关的文档 ID
encoder: FastEmbedRAGEncoder = None,
k: int = 5,
tenant_id: str = "default",
) -> list:
"""
Discovery 搜索:结合目标查询 + 正负例文档
比纯向量搜索更精准地捕捉用户意图
使用场景:
- 用户已阅读 doc_A,希望找"和 doc_A 类似且回答 query 的文档"
- 排除用户已确认无关的文档(negative_doc_ids)
"""
from qdrant_client.models import (
Filter, FieldCondition, MatchValue,
ContextExamplePair
)
dense_vec, _ = encoder.encode_query(target_query)
payload_filter = Filter(
must=[
FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id))
]
)
# 构造正负例对
context = []
if positive_doc_ids and negative_doc_ids:
# 构造成对的正负例
for pos_id, neg_id in zip(positive_doc_ids, negative_doc_ids):
context.append(
ContextExamplePair(
positive=pos_id, # point ID
negative=neg_id,
)
)
elif positive_doc_ids:
# 只有正例
for pos_id in positive_doc_ids:
context.append(
ContextExamplePair(positive=pos_id)
)
results = self.client.discover(
collection_name=self.collection_name,
target=dense_vec, # 目标查询向量
context=context, # 正负例约束
filter=payload_filter,
using="dense",
limit=k,
with_payload=True,
)
return results
7.3 Recommendation API 实现"更多类似内容"
from qdrant_client.models import RecommendStrategy
class QdrantRecommendRAG:
"""
使用 Recommendation API 的内容推荐
适用场景:
- 用户看完一篇文档,推荐相关文档
- RAG 系统中的"相关阅读"功能
- 基于已检索结果扩展召回范围
"""
def __init__(self, client: QdrantClient, collection_name: str):
self.client = client
self.collection_name = collection_name
def recommend_similar(
self,
positive_point_ids: list[str], # 相关文档的 point ID
negative_point_ids: list[str] = None, # 不相关的文档 ID(可选)
k: int = 5,
tenant_id: str = "default",
using: str = "dense",
) -> list:
"""
基于已知相关文档推荐更多类似内容
与 Discovery 的区别:
- Recommendation: 已知哪些文档相关,找更多类似文档
- Discovery: 有查询目标,同时用正负例引导搜索方向
"""
from qdrant_client.models import Filter, FieldCondition, MatchValue
payload_filter = Filter(
must=[
FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id))
]
)
results = self.client.recommend(
collection_name=self.collection_name,
positive=positive_point_ids,
negative=negative_point_ids or [],
strategy=RecommendStrategy.AVERAGE_VECTOR, # 平均向量策略
using=using,
filter=payload_filter,
limit=k,
with_payload=True,
)
return results
def expand_rag_context(
self,
retrieved_point_ids: list[str],
tenant_id: str,
additional_k: int = 3,
) -> list:
"""
RAG 上下文扩展:基于已检索的文档扩充更多相关文档
在精排之前增加候选集,提高召回覆盖率
"""
expanded = self.recommend_similar(
positive_point_ids=retrieved_point_ids,
k=additional_k,
tenant_id=tenant_id,
)
# 过滤掉已存在的 IDs
existing_ids = set(retrieved_point_ids)
new_results = [r for r in expanded if str(r.id) not in existing_ids]
return new_results
八、Binary Quantization 在大规模 RAG 中的应用
8.1 Binary Quantization 原理
Binary Quantization 是 Qdrant 独有的量化策略,将浮点向量压缩为 1-bit 二进制表示:
量化过程:原始向量(float32)经符号函数转换为二进制向量(1-bit):正数→1,负数→0。
内存对比(1024维,100万向量):
| 方案 | 内存占用 | 压缩比 |
|---|---|---|
| float32 原始向量 | ~4 GB | 1x |
| int8 标量量化 | ~1 GB | 4x |
| binary 二进制量化 | ~128 MB | 32x |
待验证的精度目标,而非已测结果:
- 高维向量(>= 512维):可将召回率下降 < 2% 设为实验目标,维度本身不保证达标
- 1024维(BGE-M3):可将召回率下降约 0.5-1% 设为实验目标,需在具体数据与搜索参数下验证
- 配合 oversampling 和原始向量重排可尝试改善召回率,但不能保证完全恢复精度
8.2 Binary Quantization 配置与使用
from qdrant_client.models import (
SearchParams,
QuantizationSearchParams,
)
# 查询时的量化搜索参数
def search_with_binary_quantization(
client: QdrantClient,
collection_name: str,
query_vector: list[float],
k: int = 5,
payload_filter=None,
oversampling: float = 3.0, # 超采样倍数,用于恢复精度
rescore: bool = True, # 是否用原始向量重排
) -> list:
"""
带 Binary Quantization 的高效搜索
关键参数说明:
- oversampling: 实际候选数 = k * oversampling(从二进制向量快速初筛)
- rescore: True 时用原始 float32 向量对候选重排(精度接近无量化)
原理:
1. 用二进制向量(128 MB)快速找 k * oversampling 个候选(极快)
2. 用原始 float32 向量(4 GB,可在磁盘上)对候选重排(只需读少量数据)
结果:速度接近纯二进制,精度接近纯 float32
"""
results = client.search(
collection_name=collection_name,
query_vector=("dense", query_vector),
limit=k,
query_filter=payload_filter,
search_params=SearchParams(
quantization=QuantizationSearchParams(
ignore=False, # 不忽略量化(使用量化加速)
rescore=rescore, # 用原始向量重排
oversampling=oversampling, # 超采样系数
),
exact=False, # 不做精确搜索(允许近似)
),
with_payload=True,
)
return results
# 性能基准测试
import time
def benchmark_quantization_impact(
client: QdrantClient,
collection_name: str,
query_vector: list[float],
k: int = 5,
):
"""对比三种搜索模式的性能"""
results = {}
# 模式 1:纯精确搜索(不使用量化)
start = time.perf_counter()
exact_results = client.search(
collection_name=collection_name,
query_vector=("dense", query_vector),
limit=k,
search_params=SearchParams(
exact=True,
quantization=QuantizationSearchParams(ignore=True), # 忽略量化并使用精确搜索
),
)
results["exact"] = {
"latency_ms": (time.perf_counter() - start) * 1000,
"point_ids": [str(r.id) for r in exact_results],
}
# 模式 2:二进制量化(无重排)
start = time.perf_counter()
binary_results = client.search(
collection_name=collection_name,
query_vector=("dense", query_vector),
limit=k,
search_params=SearchParams(
quantization=QuantizationSearchParams(
ignore=False,
rescore=False, # 不重排,速度最快,精度最低
),
),
)
results["binary_no_rescore"] = {
"latency_ms": (time.perf_counter() - start) * 1000,
"point_ids": [str(r.id) for r in binary_results],
}
# 模式 3:二进制量化 + 重排(推荐生产配置)
start = time.perf_counter()
binary_rescore_results = client.search(
collection_name=collection_name,
query_vector=("dense", query_vector),
limit=k,
search_params=SearchParams(
quantization=QuantizationSearchParams(
ignore=False,
rescore=True, # 重排恢复精度
oversampling=3.0, # 3x 超采样
),
),
)
results["binary_with_rescore"] = {
"latency_ms": (time.perf_counter() - start) * 1000,
"point_ids": [str(r.id) for r in binary_rescore_results],
}
# 计算召回率(与精确搜索对比)
exact_ids = set(results["exact"]["point_ids"])
for mode in ["binary_no_rescore", "binary_with_rescore"]:
mode_ids = set(results[mode]["point_ids"])
recall = len(exact_ids & mode_ids) / len(exact_ids) if exact_ids else 0
results[mode]["recall_vs_exact"] = f"{recall:.1%}"
print("\nBinary Quantization 性能对比")
print("=" * 60)
for mode, stats in results.items():
print(f"\n{mode}:")
print(f" 延迟: {stats['latency_ms']:.2f} ms")
if "recall_vs_exact" in stats:
print(f" 召回率: {stats['recall_vs_exact']}")
return results
8.3 大规模 RAG 内存架构
100万文档 RAG 系统内存规划(使用 Binary Quantization):
| 组件 | 无量化内存 | Binary Quant 内存 | 备注 |
|---|---|---|---|
| 稠密向量(1024维 float32) | 4 GB | 128 MB | 量化版常驻内存 |
| 原始向量(float32) | 4 GB | 4 GB | 落盘,rescore 时读取 |
| 稀疏向量索引 | ~500 MB | ~500 MB | 无法量化 |
| HNSW 图结构 | ~800 MB | ~800 MB | 无法量化 |
| Payload 存储 | ~2 GB | ~2 GB | 无法量化 |
| RAM 总需求 | ~11.3 GB | ~3.4 GB | 节省约 70% |
配置建议:
- 量化向量(128 MB):
always_ram=True(始终在内存) - 原始向量(4 GB):
on_disk=True(落盘,rescore 时读取) - HNSW + Payload(~3 GB):默认内存
九、性能评估与 RAGAS 测试
9.1 量化对 RAG 召回率的影响测试
import statistics
from typing import Callable
class RAGRecallEvaluator:
"""
评估不同搜索配置对 RAG 召回率的影响
主要关注:Binary Quantization 对端到端 RAG 质量的影响
"""
def __init__(
self,
client: QdrantClient,
collection_name: str,
encoder: FastEmbedRAGEncoder,
):
self.client = client
self.collection_name = collection_name
self.encoder = encoder
def evaluate_retrieval_recall(
self,
test_queries: list[dict],
k: int = 5,
search_configs: dict = None,
) -> dict:
"""
test_queries 格式:
[
{
"query": "查询文本",
"relevant_ids": ["point_id_1", "point_id_2", ...] # 标注的相关文档
}
]
search_configs: 要测试的搜索配置,默认测试三种
"""
if search_configs is None:
search_configs = {
"exact": {"ignore": True},
"binary_no_rescore": {"ignore": False, "rescore": False},
"binary_rescore_3x": {"ignore": False, "rescore": True, "oversampling": 3.0},
}
results = {config_name: [] for config_name in search_configs}
for test_case in test_queries:
query = test_case["query"]
relevant_ids = set(test_case["relevant_ids"])
dense_vec, sparse_vec = self.encoder.encode_query(query)
for config_name, quant_params in search_configs.items():
search_result = self.client.search(
collection_name=self.collection_name,
query_vector=("dense", dense_vec),
limit=k,
search_params=SearchParams(
quantization=QuantizationSearchParams(**quant_params)
),
)
retrieved_ids = set(str(r.id) for r in search_result)
recall = len(relevant_ids & retrieved_ids) / len(relevant_ids) if relevant_ids else 0
results[config_name].append(recall)
# 计算平均指标
summary = {}
for config_name, recalls in results.items():
summary[config_name] = {
"mean_recall": statistics.mean(recalls),
"std_recall": statistics.stdev(recalls) if len(recalls) > 1 else 0,
"min_recall": min(recalls),
"max_recall": max(recalls),
}
return summary
9.2 RAGAS 端到端评估
def run_ragas_evaluation(
rag_chain,
test_dataset: list[dict],
) -> dict:
"""
使用 RAGAS 评估 RAG 质量
RAGAS 指标:
- faithfulness: LLM 回答是否忠实于检索内容(不幻觉)
- answer_relevancy: 回答是否切题
- context_precision: 检索内容是否精准(排名靠前的上下文是否真的有用)
- context_recall: 标准答案所需信息是否被检索到
"""
try:
from ragas import evaluate
from ragas.metrics import (
faithfulness,
answer_relevancy,
context_precision,
context_recall,
)
from datasets import Dataset
except ImportError:
raise ImportError("pip install ragas datasets")
# 准备 RAGAS 数据集格式
questions = []
ground_truths = []
answers = []
contexts = []
for item in test_dataset:
query = item["question"]
ground_truth = item["ground_truth"]
# 执行 RAG 查询
result = rag_chain.invoke({"query": query})
answer = result.get("result", "")
source_docs = result.get("source_documents", [])
questions.append(query)
ground_truths.append([ground_truth])
answers.append(answer)
contexts.append([doc.page_content for doc in source_docs])
# 构造 RAGAS Dataset
ragas_dataset = Dataset.from_dict({
"question": questions,
"ground_truths": ground_truths,
"answer": answers,
"contexts": contexts,
})
# 执行评估
evaluation_result = evaluate(
ragas_dataset,
metrics=[
faithfulness,
answer_relevancy,
context_precision,
context_recall,
],
)
print("\nRAGAS 评估结果")
print("=" * 50)
print(f"忠实度 (Faithfulness): {evaluation_result['faithfulness']:.4f}")
print(f"回答相关性 (Answer Rel.): {evaluation_result['answer_relevancy']:.4f}")
print(f"上下文精度 (Context Prec.): {evaluation_result['context_precision']:.4f}")
print(f"上下文召回 (Context Rec.): {evaluation_result['context_recall']:.4f}")
return evaluation_result
# 对比不同搜索配置的 RAGAS 分数
def compare_search_strategies_with_ragas(
client: QdrantClient,
collection_name: str,
encoder: FastEmbedRAGEncoder,
llm,
test_dataset: list[dict],
):
"""
对比三种搜索策略的端到端 RAGAS 分数:
1. 纯稠密向量搜索(无量化)
2. 混合搜索(稠密 + 稀疏)
3. 混合搜索 + Binary Quantization
这是 Qdrant 独有的测试矩阵,帮助工程师确定最优配置
"""
from langchain_qdrant import QdrantVectorStore
from langchain_community.embeddings import FastEmbedEmbeddings
from qdrant_client.models import Filter, FieldCondition, MatchValue
strategies = {
"dense_only": {
"description": "纯稠密向量,无量化",
"search_type": "similarity",
"extra_kwargs": {},
},
"hybrid_no_quantization": {
"description": "混合搜索(稠密+稀疏),无量化",
"search_type": "mmr",
"extra_kwargs": {"fetch_k": 20, "lambda_mult": 0.7},
},
}
ragas_results = {}
for strategy_name, config in strategies.items():
print(f"\n评估策略: {strategy_name} - {config['description']}")
embeddings = FastEmbedEmbeddings(model_name="BAAI/bge-m3")
vectorstore = QdrantVectorStore(
client=client,
collection_name=collection_name,
embedding=embeddings,
vector_name="dense",
)
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
retriever = vectorstore.as_retriever(
search_type=config["search_type"],
search_kwargs={"k": 5, **config["extra_kwargs"]},
)
prompt = PromptTemplate(
template="基于上下文回答问题。\n\n上下文:{context}\n\n问题:{question}\n\n回答:",
input_variables=["context", "question"],
)
chain = RetrievalQA.from_chain_type(
llm=llm,
retriever=retriever,
chain_type_kwargs={"prompt": prompt},
return_source_documents=True,
)
ragas_result = run_ragas_evaluation(chain, test_dataset)
ragas_results[strategy_name] = ragas_result
# 输出对比表格
print("\n\n搜索策略 RAGAS 对比")
print("=" * 80)
header = f"{'策略':<35} {'Faithfulness':>12} {'AnswerRel':>10} {'CtxPrec':>8} {'CtxRec':>8}"
print(header)
print("-" * 80)
for strategy_name, result in ragas_results.items():
row = (
f"{strategy_name:<35} "
f"{result.get('faithfulness', 0):>12.4f} "
f"{result.get('answer_relevancy', 0):>10.4f} "
f"{result.get('context_precision', 0):>8.4f} "
f"{result.get('context_recall', 0):>8.4f}"
)
print(row)
return ragas_results
9.3 RAG 索引与检索组合示例
"""
Qdrant RAG 索引与检索组合示例,尚未接入完整生成链路
本文涉及的技术包括,以下代码并未全部实现:
- FastEmbed 内置向量化
- BGE-M3 双路向量(稠密 + 稀疏)
- Binary Quantization(内存压缩 32x)
- Payload 过滤(多租户隔离)
- Parent-Child 分块(上下文扩展)
- LangChain 流式输出
"""
import os
from qdrant_client import QdrantClient
def build_production_rag_system(
qdrant_url: str = "http://localhost:6333",
collection_name: str = "prod_rag",
openai_api_key: str = None,
):
"""组合 Qdrant 索引器与租户过滤搜索器,未接入完整生成链路或生产安全控制"""
# 1. 初始化客户端
client = QdrantClient(url=qdrant_url)
# 2. 初始化编码器(FastEmbed,零外部依赖)
encoder = FastEmbedRAGEncoder(
dense_model="BAAI/bge-m3",
sparse_model="Qdrant/BM25",
batch_size=64,
)
# 3. 创建 Collection(含 Binary Quantization)
if not client.collection_exists(collection_name):
from qdrant_client.models import (
VectorParams, Distance, SparseVectorParams,
QuantizationConfig, BinaryQuantization, BinaryQuantizationConfig
)
client.create_collection(
collection_name=collection_name,
vectors_config={
"dense": VectorParams(
size=1024,
distance=Distance.COSINE,
on_disk=True,
quantization_config=QuantizationConfig(
binary=BinaryQuantization(
binary=BinaryQuantizationConfig(always_ram=True)
)
),
)
},
sparse_vectors_config={
"sparse": SparseVectorParams()
},
)
# 创建 Payload 索引
for field, schema in [
("tenant_id", "keyword"), ("source", "keyword"),
("doc_type", "keyword"), ("language", "keyword"),
]:
from qdrant_client.models import PayloadSchemaType
client.create_payload_index(
collection_name=collection_name,
field_name=field,
field_schema=getattr(PayloadSchemaType, field.upper(), PayloadSchemaType.KEYWORD),
)
# 4. 构建索引器
indexer = QdrantRAGIndexer(
client=client,
collection_name=collection_name,
encoder=encoder,
batch_size=100,
tenant_id="company_a", # 与下方使用示例的搜索租户一致;实际应用应由可信身份上下文确定
)
# 5. 构建多租户搜索器
searcher = SharedCollectionRAG(
client=client,
collection_name=collection_name,
encoder=encoder,
)
print("Production RAG system initialized successfully.")
print(f" Collection: {collection_name}")
print(f" Encoder: BGE-M3 (dense) + BM25 (sparse)")
print(f" Quantization: Binary (32x compression)")
return {
"client": client,
"encoder": encoder,
"indexer": indexer,
"searcher": searcher,
}
# 使用示例
if __name__ == "__main__":
system = build_production_rag_system(
qdrant_url="http://localhost:6333",
collection_name="enterprise_knowledge_base",
)
# 索引文档
stats = system["indexer"].index_documents(
file_paths=["./docs/technical_spec.pdf", "./docs/user_guide.md"],
)
print(f"Indexed: {stats}")
# 搜索(带租户隔离)
results = system["searcher"].search(
tenant_id="company_a",
query="如何配置高可用集群?",
k=5,
doc_type="pdf",
)
for i, point in enumerate(results, 1):
print(f"\n[{i}] Score: {point.score:.4f}")
print(f" Source: {point.payload.get('source', 'N/A')}")
print(f" Text: {point.payload.get('text', '')[:150]}...")
总结
Qdrant 在 RAG 场景中的差异化价值可以用四个关键词概括:
零配置(FastEmbed)- FastEmbed 可在客户端本地生成向量,不需要部署独立的 Embedding 服务;完整 RAG 管道仍需 Qdrant 服务、模型文件、解析与框架依赖,以及所选 LLM 的配置。
精准过滤(Payload Filter)- 过滤可参与向量检索过程,而不是简单地对已召回的 Top-K 做后置筛选;多租户 RAG 的检索质量仍需在具体过滤条件、索引和搜索参数下评估。本文未提供 Milvus 对应版本的比较依据,不能据此认定其不具备相关能力。
原生混合(Native Hybrid Search)- 稀疏向量是 Qdrant 的一等公民,无需部署 BM25 服务,本文示例使用 BGE-M3 稠密向量与 Qdrant/BM25 稀疏向量进行双路检索,未实现 BGE-M3 稀疏向量或 ColBERT 多向量检索。
向量表示压缩(Binary Quantization)- float32 到 1-bit 的向量表示理论比例为 32x,不等于整个系统的 RAM 压缩比。配合 rescore 的召回率下降不足 1% 可作为待验证目标;百万级 RAG 系统从 ~11 GB 到 ~3.4 GB 的数字仅保留为待验证容量假设,8.3 的副本计数、RAM 与磁盘口径及分块后向量数量尚未统一,不能作为成本或容量保证。
进阶学习路径
- 深入理解搜索机制:搜索与查询详解
- 通用 RAG 架构模式:RAG 架构设计模式
- 混合搜索工程实践:混合搜索工程实践
- 与 Milvus RAG 对比:Milvus RAG 集成实战
#Qdrant #RAG #向量数据库 #LangChain #LlamaIndex #FastEmbed #混合搜索 #多租户 #BGE-M3 #BinaryQuantization