跨标题、摘要和文本块的多重表示检索
| 时间:45分钟 | 难度:中等 | 输出:GitHub |
|---|
单次嵌入(embedding)很难完整地表示一份文档。一篇论文包含标题、摘要、正文片段和分类标签,每一部分都承载着不同的信号。如果将这四者视为同一个稠密向量,标题信息会被平均化(averaged out),用于后续推理的“文本块级接地”(chunk-level grounding)能力也会随之消失。
本教程将构建一种有针对性地使用每种表示的检索系统:为每种表示使用命名向量(named vectors),通过 Query API 进行融合,并在展示时将其归并回文档级别。
本教程假设您已经构建过混合(稠密加稀疏)搜索,并且熟悉 命名向量、Query API、倒数排名融合(RRF)以及最佳匹配 25 (BM25)。如果混合搜索对您来说是新的概念,请先阅读 文本搜索指南中的混合搜索部分。
如果您更喜欢通过代码学习,可以查看 配套笔记本,它逐步讲解了整个流程,并在每个阶段提供了评估数据。
设置
安装本教程中使用的 Python 包
pip install qdrant-client datasets
数据集
您将使用来自 gfissore/arxiv-abstracts-2021 数据集的 20,000 篇 ML/CS arXiv 论文(2018 年及以后)。每篇论文都有标题、摘要和分类标签。一旦摘要被分块,您将获得三种文本表示,以及可作为过滤条件的分类元数据。
arXiv 摘要适合任何稠密模型的上下文窗口,因此对于该数据集而言,分块并非严格必要。我们之所以进行分块,是因为这种流程(块级检索,文档级分组)正是生产环境中处理整篇论文时所采用的模式。本例采用固定长度的句子分块器以简化逻辑;正确的策略应取决于您的文档结构。
from datasets import load_dataset
ML_CATEGORIES = {"cs.LG", "cs.CV", "cs.CL", "cs.AI", "stat.ML"}
# Non-streaming so HF caches the parquet locally; first run downloads ~2.5 GB, re-runs are instant.
dataset = load_dataset("gfissore/arxiv-abstracts-2021", split="train")
papers = []
# IDs are roughly chronological; iterate from the end to land on 2021/2020/2019 papers first.
for i in range(len(dataset) - 1, -1, -1):
if len(papers) >= 20000:
break
row = dataset[i]
if not row["abstract"] or not row["title"]:
continue
# categories arrive as space-joined strings (e.g. ["cs.LG cs.CV"]); split each entry.
cats = [tok for entry in row["categories"] for tok in entry.split()]
if not any(c in ML_CATEGORIES for c in cats):
continue
# Year lives in the YYMM prefix of new-format arXiv IDs ("2104.01234" -> 2021).
arxiv_id = row["id"]
if "/" in arxiv_id or "." not in arxiv_id:
continue # skip pre-2007 IDs like "math/0506001"
if 2000 + int(arxiv_id[:2]) < 2018:
continue
papers.append({
"arxiv_id": arxiv_id,
"title": row["title"].strip(),
"abstract": row["abstract"].strip(),
"tags": cats,
})
集合架构(Collection Schema)
在编写任何查询之前,请先设计集合。数据点的粒度是“文本块”:每篇论文的每个块都成为一个数据点。标题和摘要的嵌入被存储在每个块上,这样 Query API 就可以在单个请求中进行跨表示融合,而无需额外的查询。
from qdrant_client import QdrantClient, models
# Replace url and api_key with your own from https://cloud.qdrant.io
client = QdrantClient(
url="https://xyz-example.qdrant.io:6333",
api_key="<your-api-key>",
cloud_inference=True,
)
# 384 is the output dimension of sentence-transformers/all-minilm-l6-v2, used below for every dense vector.
client.create_collection(
collection_name="arxiv_multi_repr",
vectors_config={
"dense_chunk": models.VectorParams(size=384, distance=models.Distance.COSINE),
"dense_title": models.VectorParams(size=384, distance=models.Distance.COSINE),
"dense_abstract": models.VectorParams(size=384, distance=models.Distance.COSINE),
},
sparse_vectors_config={
"sparse_title": models.SparseVectorParams(
modifier=models.Modifier.IDF,
),
},
)
# Index 'document_id' so the Query API can group by it; index 'tags' so we can filter on category.
client.create_payload_index(
collection_name="arxiv_multi_repr",
field_name="document_id",
field_schema=models.PayloadSchemaType.KEYWORD,
)
client.create_payload_index(
collection_name="arxiv_multi_repr",
field_name="tags",
field_schema=models.PayloadSchemaType.KEYWORD,
)
每个向量覆盖不同的信号:dense_chunk 用于文本块内容,dense_title 和 sparse_title 用于标题(分别为稠密和词法表示),dense_abstract 用于作为整体的摘要。
分类信息存储在带有 关键字索引 的 tags 载荷(payload)中,以便查询可以按分类进行预过滤。
标题和摘要向量在同一文档的每个块中重复存储。这种设计以存储空间换取了查询的简便性:一个集合、一次 Query API 调用、从任何一个点都可以触达所有表示。对于 20,000 篇文档(每篇 24 个块),重复向量增加了约 1.4 GB 的空间;如果存储在侧挂集合(sidecar collection)中,每份文档只存一次,则约为 60 MB。请参阅 计算 RAM 大小 以了解如何为您的语料库定价,并参阅 分组查找(Lookup in groups) 以了解如何分离大型字段并在分组时重新连接。
数据摄入(Ingestion)会为每个块创建一个数据点,并复用标题和摘要的嵌入。
DENSE_MODEL = "sentence-transformers/all-minilm-l6-v2"
BM25_MODEL = "qdrant/bm25"
def chunk_sentences(text, target_len=2):
"""Split text into ~2-sentence chunks; fall back to the full text if it doesn't split cleanly."""
sentences = [s.strip() for s in text.split(". ") if s.strip()]
return [". ".join(sentences[i:i + target_len])
for i in range(0, len(sentences), target_len)] or [text]
points = []
for paper in papers:
chunks = chunk_sentences(paper["abstract"])
# Title, abstract, and sparse docs are reused across every chunk of this paper; only the chunk text varies.
# Cloud Inference embeds each Document on the server, so you don't need a client-side embedding library.
title_doc = models.Document(text=paper["title"], model=DENSE_MODEL)
abstract_doc = models.Document(text=paper["abstract"], model=DENSE_MODEL)
# avg_len is the average word count of the indexed text.
# Default is 256 (document-length); setting it to the actual field length (~10 here) improves BM25 scoring accuracy.
sparse_doc = models.Document(
text=paper["title"],
model=BM25_MODEL,
options={"avg_len": 10.0},
)
for i, chunk in enumerate(chunks):
points.append(models.PointStruct(
id=len(points),
vector={
"dense_chunk": models.Document(text=chunk, model=DENSE_MODEL),
"dense_title": title_doc,
"dense_abstract": abstract_doc,
"sparse_title": sparse_doc,
},
payload={
"document_id": paper["arxiv_id"],
"title": paper["title"],
"tags": paper["tags"],
"chunk_index": i,
"chunk_text": chunk,
},
))
client.upload_points(collection_name="arxiv_multi_repr", points=points, batch_size=256, parallel=2)
上传完成后,在 Qdrant Cloud UI 中打开任何一个点,您都会看到四个附加到该块的命名向量。dense_chunk 携带块本身的嵌入,而 dense_title、dense_abstract 和 sparse_title 在该论文的所有块中是相同的。

检索
推荐的流程是利用倒数排名融合(RRF)融合四个预取(prefetch)结果,并按文档对结果进行分组。一次 Query API 调用即可涵盖检索、融合和分组。
def retrieve(query, limit=10, group_size=3, tags=None):
dense_query = models.Document(text=query, model=DENSE_MODEL)
sparse_query = models.Document(text=query, model=BM25_MODEL)
# Optional category filter. When tags is provided, Qdrant pre-filters candidates
# to points whose 'tags' payload includes any of the given values.
query_filter = (
models.Filter(must=[models.FieldCondition(key="tags", match=models.MatchAny(any=tags))])
if tags else None
)
# query_points_groups runs the prefetches, fuses with RRF, applies the filter, and groups results by document_id.
# You may need to adjust the per-prefetch limit based on the number of chunks per document; grouping only sees what the prefetch returns.
return client.query_points_groups(
collection_name="arxiv_multi_repr",
prefetch=[
models.Prefetch(query=dense_query, using="dense_chunk", limit=100),
models.Prefetch(query=dense_query, using="dense_title", limit=100),
models.Prefetch(query=dense_query, using="dense_abstract", limit=100),
models.Prefetch(query=sparse_query, using="sparse_title", limit=100),
],
query=models.FusionQuery(fusion=models.Fusion.RRF),
query_filter=query_filter,
group_by="document_id",
group_size=group_size,
limit=limit,
).groups
预取什么内容
只有当某种表示携带与其他表示独立的信号时,它才值得进行单独的预取。上述四种表示是有意进行互补设计的:
dense_chunk携带正文内容。dense_title携带主题命名信息。在此架构中,标题不是块或摘要文本的一部分,因此如果查询匹配了标题中存在但正文中从未出现的词汇,就需要此预取来呈现文档。dense_abstract将摘要视为一个完整的语义单元,而dense_chunk则索引文档本身的较小部分。sparse_title携带标题中的词法命中信息,这些信息通常会被稠密嵌入所平均化:如罕见实体名称、行话、特定模型或论文名称。
使用哪种融合方式
倒数排名融合(RRF)使用文档排名而非原始分数来结合预取结果,从而避免了因稠密和稀疏分数处于不同量级而带来的问题。这是本教程的默认配置,也是一个很好的起点。在某些场景下,以下变体可能会产生更好的结果。
当 RRF 不够用时
- 加权 RRF。 在排名融合公式中为每个预取设置权重。当某个检索路径在您的数据上表现稳定且优越时使用;请根据验证集调整权重,因为对某类查询有效的权重通常会损害另一类查询。
- 基于分布的分数融合 (DBSF)。 在求和之前将每个预取的分数标准化到统一范围。当预取来自同一模型系列且分数分布稳定时,这能保留 RRF 所丢弃的分数大小信息。
- 通过
FormulaQuery使用自定义公式。 完全控制分数结合的方式。
query=models.FormulaQuery(
formula=models.SumExpression(sum=[
models.MultExpression(mult=[1.0, "$score[0]"]),
models.MultExpression(mult=[0.5, "$score[1]"]),
models.MultExpression(mult=[0.4, "$score[2]"]),
models.MultExpression(mult=[0.3, "$score[3]"]),
]),
defaults={"$score[1]": 0.0, "$score[2]": 0.0, "$score[3]": 0.0},
),
在此公式中,$score[i] 是来自预取 i 的分数,因此 prefetch= 列表的顺序很重要。defaults 映射为未在所有预取中出现的候选者提供回退值(此处为 0.0),以便公式能够计算。
其他两种融合策略为您处理了这个问题:RRF 完全丢弃了分数,而 DBSF 在求和前对每个预取进行了标准化。使用自定义公式时,您必须自己标准化分数,通常使用 衰减函数。完整的 FormulaQuery 语法位于 分数提升(Score Boosting) 参考资料中。
有关 RRF 与 DBSF 的选择建议,请参阅 混合搜索常见问题解答。
何时提升,何时重排序
分数提升(Score boosting)用于表达仅凭检索分数无法捕获的排名偏好:时效性、来源权威性、地理邻近度、内容类型。请使用引用载荷字段的 FormulaQuery。对于基于 published_at 字段的时间衰减,请使用 衰减函数 参考中的 exp_decay 表达式。
当您的偏好是“这比那更相关”,但您难以用封闭形式公式表达原因时,请使用重排序器(reranker)。公式既廉价又确定;而重排序器虽然成本较高,但能学习到您无法阐述的复杂逻辑。
有关构建此设计的分步演进过程(稠密基线、添加稀疏、添加标题预取、分组、提升)以及显示每一步提升的评估数据,请参阅 配套笔记本。关于针对单一文档表示进行三种不同查询的互补视角,请查看 混合检索的通用查询演示。
总结
多重表示检索是一个模式(schema)决策,而非模型决策。一旦每种表示都有自己的命名向量,Query API 就会在查询时对其进行组合:每个表示进行预取,用 RRF 融合,并按文档进行分组展示。当您需要对评分进行更精细的控制时,请使用 FormulaQuery 或加权融合。
真实的语料库很少能在每份文档中拥有完整的元数据。如果您的数据存在缺失,且不确定如何调整此流程,请在 Discord 上提问。我们很期待了解您的数据形态。
相关阅读