跨标题、摘要和文本块的多重表示检索

时间:45分钟难度:中等输出:GitHubOpen In Colab

单次嵌入(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_titlesparse_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_titledense_abstractsparse_title 在该论文的所有块中是相同的。

A point in the arxiv_multi_repr collection showing all four named vectors

检索

推荐的流程是利用倒数排名融合(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 上提问。我们很期待了解您的数据形态。

相关阅读

此页面有用吗?

感谢您的反馈!🙏

很遗憾听到这个消息。😔 您可以在 GitHub 上 编辑 此页面,或者 创建 一个 GitHub issue。