首页 / 文章

法律文本的混合RAG技术:分块、BM25、BGE与重排序

基于《印度宪法》的法律RAG处理流程:父子级分块、BM25与BGE检索、排名融合、交叉编码器重排及查询扩展,已在基准测试中得到验证。

发布者:REACTAPP.TOP阅读约 20 分钟4590 词

来源: Beyond Basic RAG: Building a Production-Style Legal Research Assistant (Part-I)

法律文本对任何检索系统而言都是严峻的考验。以印度宪法为例:它包含数百条条款与子条款,还有附加规定、附表、修正案、表格以及复杂的相互引用关系。回答问题的那段文字可能只有一句话,但要正确理解它却需要参考周围的条款、相关的例外规定或该句所指向的其他条文。因此,一个实用的系统必须在精确性与上下文之间取得平衡,既要找到准确的段落,又不能使其脱离赋予其意义的整体文本。

许多入门级的检索增强生成(RAG)系统都遵循简单的流程:从PDF中提取文本,将其分割成固定大小的片段,对这些片段进行嵌入处理,然后返回最匹配的结果。这确实是一个不错的起点,但实际生产系统通常需要更多功能。它们必须保留文档结构,同时支持精确搜索和语义搜索,对候选结果进行重新排序,获取上下文信息,追踪每个结果的来源,并判断是否真正检索到了所需的证据。

本文介绍了一个研究项目,该项目以印度宪法作为首个语料库,重现了这种生产级工作流程,并能够逐阶段进行检验,因为该宪法的结构暴露出了普通向量搜索的诸多缺陷。这并非一个成品,而是一次尝试,旨在研究生产级RAG流程中的各个常规组件是如何组合在一起的、它们会在何处出现故障,以及如何通过评估来指导后续的设计决策。

1. 数据摄取:从法律PDF到结构化内容

在考虑分块、嵌入或检索之前,源PDF必须先转化为软件能够可靠处理的内容。PDF记录的是文档的呈现形式,而非程序应如何读取它。标题、段落、脚注、列表、表格以及页面边界对人类来说可能一目了然,但在初步提取后仍会变成相互分离的文本。如果在数据导入阶段就丢失了这些结构,后续生成的分块就会出现阅读顺序错误、标题缺失或表格损坏的问题。

该项目使用MinerU将宪法文档转换为Markdown格式,同时保留了重要的布局信息。选择该工具是因为在作者的对比中,它处理表格和列表的能力比其他尝试过的提取方法更为可靠。目标不仅仅是收集每一个单词,还要保留单词之间的关联。最终提取出的内容保留了:

  • 标题的嵌套方式,
  • 每个段落和分句的起始与结束位置,
  • 项目列表与编号列表,
  • 表格,
  • 各内容来自哪一页,以及
  • 脚注及其周围的结构。

Markdown作为中间格式非常适合这一用途。人们可以在手动检查时阅读它,同时它还包含Python能够处理的结构标记。像## 基本权利这样的标题日后可用于指导父级块的创建,而列表和表格则可以被识别并视为独立的块来处理。

文本提取是在 Google Colab 中完成的,而非本地机器上。OCR 和版面分析任务往往十分耗资源,尤其是对于包含大量表格和复杂格式的长文档而言,硬件配置较低的笔记本电脑很难应对这样的工作量。Colab 提供了具备更强计算能力的隔离环境,因此如果您的本地硬件条件一般,可以考虑在当前阶段使用它。笔记本中使用的命令为:

!mineru -p c.pdf -o output --dump-content-list -b pipeline -l en

2. 分块

在将宪法内容转换为结构化 Markdown 后,接下来的问题是如何对其进行划分以便检索。常见的解决方案是固定大小分块:按照设定的字符数或标记数进行切割,通常会存在一定程度的重叠。这种方法简单且适用于结构均匀的散文,但法律文件是按含义而非长度来组织的。固定的分割边界可能会导致:

  • 将标题与其下的正文分开,
  • 将某一条款与其限定条件割裂开来,
  • 在列表项中间将其截断,
  • 将表格拆分成不完整的片段,以及
  • 仅仅因为内容能容纳在同一个标记窗口内就合并无关的条文。

较大的分块能保留更多上下文,但会使检索结果更加模糊;较小的分块虽然匹配度更高,却可能丢失重要的周边信息。因此该项目采用了两级策略:

Structured document
        ↓
Heading-aware parent chunks
        ↓
Semantic child chunks

为何两级结构更有帮助

搜索是在子节点上进行的,因为它们较为有限的内容能为BM25算法提供更清晰、更有效的检索信号。选定某个子节点后,它所携带的parent_id会指向其所属的更大层级父节点部分:

Query
  ↓
Retrieve focused child
  ↓
Read parent_id
  ↓
Return complete parent context

这就将两项职责分开了。子块负责提升检索精度,而父块则确保上下文的完整性。

考虑标题信息的父块

父块划分算法依赖于在数据摄入过程中生成的两种结果:

data/processed/cr.md
data/extracted/auto/blockpage.json

Markdown文件保存了处理后的文本及其可见结构。blockpage.json文件则存储了在提取过程中生成的有序布局块,其中包括标题层级信息。Markdown仍是父文本的主要来源,而JSON则作为有序的结构参考,用于将Markdown标题与提取出的文档对应起来。该实现全部包含在同一个脚本中:

code/chunking_parent.py

该算法按固定顺序运行。

第一步:加载两个文件。脚本会读取处理过的Markdown内容以及布局块的JSON文件:

md_text = MARKDOWN_PATH.read_text(encoding="utf-8")
with BLOCKPAGE_PATH.open("r", encoding="utf-8") as file:
    blockpage = json.load(file)

使用这两者可以防止分块工具将 Markdown 视为普通的、无结构的文本。Markdown 负责提供内容,而 JSON 则有助于保持提取过程中各标题的顺序。

步骤 2:将 Markdown 分割成多个块。 空行用作分隔符:

for raw_block in md_text.split("\n\n"):

每个数据块随后会被归类为几种类型中的一种:

heading
paragraph
list
table
superscript
blank

以 #、## 或其他标题级别符号开头的文本会被识别为标题:

HEADING_RE = re.compile(r"^(#{1,6})\s+")

列表、表格和上标内容都有各自的检测规则,这能确保它们与所属章节保持关联。

步骤3:跟踪当前活动标题。当出现一个标题时,其文本会被添加到当前的heading_path中。脚本还会在blockpage.json中查找对应的标题,按顺序遍历JSON文件,而不会每次都重新开始扫描:

def find_heading(heading_text: str):
    nonlocal search_pos
    for index in range(search_pos, len(blockpage)):
        block = blockpage[index]        if (
            block.get("type") == "text"
            and block.get("text") == heading_text
            and "text_level" in block
        ):
            search_pos = index + 1
            return block

仅向前扫描可保持标题的原有顺序,避免匹配到更早出现的相同标题。

第4步:收集相关内容。段落、列表、表格及类似结构会被添加到当前的父级栈中。当在正文内容已收集完毕之后又有新标题出现时,当前父级栈会被关闭,并开启一个新的父级栈。从概念上来看是这样的:

Heading
  ├── Paragraph
  ├── Clause
  ├── List
  └── Table
        ↓
     Parent chunk

第5步:分配稳定的元数据。 每个处理完成的父节点都会保存有序号、文档ID、标题路径以及完整文本:

{
    "parent_id": f"parent_{len(chunks):04d}",
    "doc_id": DOC_ID,
    "heading_path": heading_path.copy(),
    "text": "\n\n".join(stack),
}

简化后的父元素如下所示:

{
  "parent_id": "parent_0164",
  "doc_id": "constitution_of_india",
  "heading_path": ["PART XII"],
  "text": "# PART XII\n\nFINANCE, PROPERTY, CONTRACTS AND SUITS..."
}

单亲父母可享受多项相关福利,例如:

PART XII — Finance
Article 264 — Interpretation
Article 265 — Taxes not to be imposed without authority of law
Article 266 — Consolidated Funds and public accounts
Article 267 — Contingency Fund

该过程生成了333个父块。

语义子块

父块能够保持上下文完整,但有些块过大而无法进行精确匹配,因此较大的块会被拆分成更小的语义子块。相关处理内容记录在笔记本中:

code/semantic-children.ipynb

根据笔记本中约400个标记的粗略标准,较短的父文本会作为一个整体单元保留,因为将本已足够紧凑、可视为独立单元的内容再拆分并不会带来任何好处。较长的文本则由能够识别结构并比较含义的分块工具来处理。

第一步:将父文本拆分为多个块。 父文本会被划分为标题、段落、列表和表格等部分。HTML表格会通过特定的模式被识别出来:

table_pattern = re.compile(
    r"(<table[\s\S]*?</table>)",
    re.IGNORECASE
)

普通文本会以空行作为分隔,并通过 Markdown 标题和列表标记进行分类,这样散文、列表和表格就可以分别以适合它们的方式处理,而不必假设某种结构适用于所有内容。

第二步:将标题与对应内容关联起来。标题绝不能单独成为子元素。分块工具会暂时保存每个标题,然后将其添加到后面的内容块开头:

if pending_heading:
    block.text = pending_heading + "\n\n" + block.text
    pending_heading = None

这样,检索到的子项会保留标识其主题的标题。在进行语义比较之前,连续的列表块也会被分组处理,这有助于保持子句和枚举项的逻辑顺序。

步骤3:比较相邻段落。 对于相邻的段落块,笔记本会生成BGE嵌入向量:

embedder = SentenceTransformer(
    "BAAI/bge-base-en-v1.5"
)

然后,该笔记本会计算每段文字与前面一段文字之间的余弦相似度:

similarity = cosine_similarity(
    emb1,
    emb2
)[0][0]

如果两个块的含义足够接近,它们可以保留在同一个子节点中;而相似度下降则表明下一个块开启了新主题。所设置的阈值如下:

Similarity threshold: 0.40

不过相似度只是其中一个输入因素,块类型和大小也会被考虑在内。

第4步:应用尺寸与结构规则。语义分块器的默认设置如下:

Preferred minimum size: 800 characters
Maximum combined size: 1,600 characters
Tiny-block threshold: 150 characters

当当前子内容仍低于建议的最小长度、相邻段落足够相似、某个块过小不宜单独存在,或是连续的块属于同一列表或表格时,可以将其添加到当前子内容中。合并后的子内容通常需保持在1,600个字符以内。不会仅仅为了达到最小长度就将表格硬加到普通段落中:

if (
    current_type == BlockType.TABLE
    and block.type != BlockType.TABLE
) or (
    block.type == BlockType.TABLE
    and current_type != BlockType.TABLE
):
    is_under_min = False

过大的列表可以在其项目标记处进行分割,而单个过大的非列表块则作为一个整体结构单元保留,不会被随意切割。因此,这种分割方式既非纯粹基于语义,也非纯粹基于大小,而是将两者结合在一起:

Document structure
        +
Semantic similarity
        +
Minimum and maximum sizes

第5步:保留父链接。每个生成的子项都会拥有自己的ID,同时保留其父项的ID:

{
    "parent_id": parent["parent_id"],
    "child_id": f"{parent['parent_id']}_child_{index}",
    "doc_id": parent.get("doc_id", ""),
    "heading_path": parent.get("heading_path", []),
    "text": chunk["text"],
    "type": chunk.get("type", "mixed"),
    "embedding": encode_text(chunk["text"])
}

以财务模块作为示例案例:

parent_0164
│
├── parent_0164_child_1
│   Article 264 and introductory Finance context
│
├── parent_0164_child_2
│   Articles 265 and 266 concerning taxation and public funds
│
└── parent_0164_child_3
    Article 267 concerning the Contingency Fund

三个孩子都保留着:

{
  "parent_id": "parent_0164"
}

存储后的子元素如下所示:

{
  "child_id": "parent_0164_child_2",
  "parent_id": "parent_0164",
  "doc_id": "constitution_of_india",
  "heading_path": ["PART XII"],
  "type": "list",
  "text": "265. Taxes not to be imposed save by authority of law...",
  "embedding": [0.012, -0.034, 0.021]
}

最终,该语料库包含了871个与333位父母相关的儿童记录。

3. 混合检索:从查询到父母上下文

在有了871个儿童记录后,问题转变为如何为用户查询找到合适的证据。检索是在儿童片段层面进行的,而非在父母层面,因为较短的片段更容易与某篇特定的文章、句子或概念相匹配。只有确定了最相关的儿童记录后,才会去查找规模较大的父母记录。

为何单一方法不够用

法律相关问题有多种表现形式。诸如“第23条是怎么规定的?”这样的查询包含了原文中也出现的条款编号,因此关键词检索非常适合这类问题。用户也可能会用不同的方式询问相同的内容:“国家能否要求某人履行强制服务?”这种表述并未提及第23条,因此系统必须将“要求某人履行服务”与为公共目的而进行的强制服务的概念联系起来。这两种情况需要不同的处理能力:

  • 针对条款编号、法律短语、名称及特殊术语,采用字面匹配;
  • 当问题表述与原文有所偏差时,则采用基于含义的匹配。

系统不会在两者之间进行选择,而是让同一个子语料库通过两种不同的检索方式进行处理:BM25稀疏检索和BGE密集检索。

稀疏分支:BM25

BM25根据查询词与各子节点的匹配程度进行排序。在建立索引时,标题路径和子文本会被转为小写并分词处理,索引会记录每个术语出现在哪些子节点中、出现频率以及在整个语料库中的稀有程度。有三个关键因素:查询词在子节点中出现的频率、该词在语料库中的稀有程度,以及子节点长度与平均值的对比。稀有术语比常见词更具重要性,正因如此BM25在处理文章编号和特定法律表述时表现尤为出色。

例如,对“Article 23 compulsory service”的搜索会优先匹配包含“23”、“compulsory”和“service”的文档。BM25无需理解更广泛的意义,因为这些词汇的直接出现本身就是强烈的信号。它的缺点是,当查询语句与文档内容使用不同的词汇时,相关段落可能会得到较低的评分。

密集型分支:BGE与FAISS

密集型分支是通过语义而非仅靠词汇重叠来进行搜索的。每个文档片段由其标题路径和文本内容共同表示:

embedding_text = heading_path + child_text

加入标题可以提供上下文信息,有助于在嵌入空间中将相关段落和查询聚集在一起。

通过基准测试选择嵌入模型

该项目的初始版本使用了 nomic-ai/nomic-embed-text-v1.5,但作者希望确认它是否最适合该语料库。一项受控的密集检索基准测试对比了四种配置:不使用前缀的Nomic、使用其推荐的查询和文档前缀的Nomic、BAAI/bge-base-en-v1.5以及intfloat/e5-base-v2。

所有模型都针对相同的871个固定子块集进行搜索,这些子块对应着383个经过自动映射的评估问题。

  • MRR:当第一个相关子块位于更靠上的位置时,该指标会给予更高分值。
  • Recall@10和Recall@20:在排名前10或20的结果中包含相关子项的题目比例。
  • All-recall@10:排名前10的结果中包含所有相关子项的比例。
  • Mean coverage@10:各题目中相关子项出现在排名前10的比例的平均值。
  • BGE在MRR、Recall@10、Recall@20以及mean coverage@10这几个指标上的表现最佳。MRR从Nomic时的0.624上升至0.652,Recall@20则从0.877提升到0.911。这一点很重要,因为后续的融合与重排序操作会使用有限的候选项池,而那些在密集检索中被遗漏的相关子项将无法再被找回。

    Nomic在All-recall@10指标上的得分略高(0.347对比0.334),这意味着它在处理多子节点问题时,能够更准确地检索出所有标记过的子节点。即便如此,在较早阶段找到至少一个重要的子节点仍对恢复父节点上下文更为关键。查询延迟几乎相同。因此,存储的Nomic子节点嵌入被替换为BGE嵌入,同时保留了原有的语义边界,使得嵌入变化与之前的分块策略相互独立。

    子节点以纯文本形式嵌入,不带任何指令前缀。而查询则采用BGE推荐的检索指令:

    query_text = (
        "Represent this sentence for searching relevant passages: "
        + user_query
    )
    

    同一个BGE模型同时包含查询项和子向量。这些向量经过L2归一化处理,因此内积值表现为余弦相似度,分数越高表示相似度越大。

    FAISS索引

    经过归一化的子向量会被输入到FAISS分层可导航小世界索引中:

    index = faiss.IndexHNSWFlat(
        embedding_dimension,
        32,
        faiss.METRIC_INNER_PRODUCT,
    )
    
    index.hnsw.efConstruction = 200
    index.hnsw.efSearch = 64
    index.add(embeddings)
    

    HNSW将嵌入向量组织成可导航的图结构,这样搜索过程只需在潜在的邻近向量中进行,而无需与所有向量进行比较。即便要对871个子向量进行全面比较,也是可以处理的。选择HNSW是为了确保在语料库中新增更多法规、规章、判决及类似文件时,系统仍能正常运行。这些子向量是通过code/reembed_children_bge.py重新嵌入的,索引则通过code/rebuild_dense_index.py重新构建的。基准测试准备、模型定义、查询语句及结果均保存在code/dense_benchmark/v2/目录下。

    合并两种排序结果

    BM25与密集检索会分别生成不同的排序列表。某个文档可能在两种排序中都排名靠前,或在其中一种中排名极高,而在另一种中则不存在。由于BM25得分与向量相似度处于不同的量级,无法直接将原始分数相加。这两种排序通过互逆排名融合算法进行合并:

    RRF score = Σ 1 / (k + rank + 1)
    

    该流程使用 k = 60。每个候选者获得的分数取决于其在各列表中的位置,而非其原始得分。在两个分支中排名都较高的候选者会同时获得两方面的分数加成,通常排名会上升;而在某个分支中表现优异的候选者仍有可能留在候选名单中。简化的融合阶段如下:

    sparse_results = bm25_search(query)
    dense_results = dense_search(query)
    
    fused_candidates = reciprocal_rank_fusion(
        sparse_results,
        dense_results,
    )
    

    RRF能够生成大量的候选项。它提高了有用证据被保留下来的概率,但并不会详细地将整个查询与每段文本进行对比。那是重排器的任务。

    交叉编码器重排

    由于查询和子项是分别编码的,密集搜索的成本较低,因此可以一次性计算出子项嵌入向量,并在每个查询中重复使用。但这种独立编码的方式限制了模型对各个词之间权重分配的精确度。而交叉编码器则采用另一种方法,同时接收查询和候选子项:

    pairs = [
        (query, child_text),
        ...
    ]
    
    scores = reranker.predict(pairs)
    

    由于查询和文本作为单个输入一起被提交,模型可以直接分析其标记之间的关联关系,这通常能比BM25或密集搜索得出的结果给出更精准的相关性判断。

    candidates = fused_candidates[:30]
    reranked_children = cross_encoder.rank(query, candidates)
    

    重新排序器会为每个候选项重新评分并再次进行排序。之前的BM25、dense和RRF算法仅用于生成候选列表,最终顺序则由交叉编码器的评分决定。系统随后会查看重新排序后的前20个候选项,保留那些评分至少为0.30的选项:

    selected_children = [
        child
        for child in reranked_children[:20]
        if child.score >= 0.30
    ]
    

    该阈值是针对特定模型与处理流程的过滤策略,不应被视为通用的相关性概率值;若更换重排器,则需重新对其进行评估与校准。对于表格内容需格外注意,因为有些表格的字符数超过了重排器512个标记的输入上限。在分词处理之前,工作流程会标记出过长的表格对,从而避免出现索引错误或导致相关内容被悄悄忽略。

    将子项扩展到父上下文中

    重排器仍会处理子项,因为它们是便于进行精准比较的单位。不过,仅返回这些子项可能会遗漏周围的从句、条件、例外情况或相关内容。因此,每个子项都会保留分块处理时分配的parent_id:

    {
      "child_id": "parent_0082_child_1",
      "parent_id": "parent_0082",
      "text": "23. Prohibition of traffic in human beings and forced labour..."
    }
    

    一旦完成基于阈值的过滤,系统就会从每个被选中的子项中获取parent_id,然后在父节点集合中查找对应的整个父节点:

    parent_id = selected_child["parent_id"]
    parent = parent_lookup[parent_id]
    

    若干选中的子项可以共享同一个父项,若反复返回该父项会导致上下文重复,因此这些子项会按parent_id进行分组。每个唯一的父项只会被返回一次,并附带其对应的子项,而最高分值的子项将成为该父项源的得分:

    Selected child 1 ──┐
    Selected child 2 ──┼── parent_0082
    Selected child 3 ──┘
    

    包含第23条相关语言的子查询可能会关联到parent_0082,其父节点则会提供更完整的“反对剥削权”部分内容,包括相关条款及第24条的相关内容。这样就能保持职责划分的清晰:子查询部分负责判断哪些证据是相关的,而父节点部分则提供这些证据出现的上下文。

    返回的父节点源数据还会保留其文档ID和标题路径、匹配到的子查询的ID、它们的块类型以及重排得分,因此可以通过该源数据追溯整个处理流程,了解为何选择它。混合检索实验的代码位于code/hybridrag.ipynb中。

    4. 查询扩展与多跳查询:缩小词汇差距

    当用户的表述与宪法原文高度相似时,混合检索流程表现良好。像“第21条是什么?”这样的直接问题包含了精确的引用,这为BM25和密集检索提供了强烈的信号。

    概念性问题则更为棘手。用户往往使用判例、教科书或法律评论中的术语来描述某一原则,而非直接使用宪法本身的语言。关于“基本结构原则”的问题,即便“基本结构”这一表述并未出现在第13条和第368条中,也可能需要参考这两条内容。换句话说,用户理解的法律概念是正确的,只是相关条文使用了不同的表述。

    评估结果

    该问题在Kaggle笔记本中进行了测试,其中包括直接提问以及与预期宪法条款对应的更复杂的概念性问题。这项实验发生在BGE迁移之前。当时,871个儿童的数据使用了nomic-ai/nomic-embed-text-v1.5模型生成的嵌入向量,查询也采用同一模型进行编码且不添加检索前缀。这一细节很重要,因为该实验旨在探究重新表述查询会对结果产生何种影响,而非比较不同嵌入模型的性能,因此每种概念性问题的两个版本都由相同的基于Nomic的混合处理流程来处理。

    只有当检索到的父上下文包含与目标文章相关的所有内容时,该问题才被视为通过,因此所显示的百分比反映的是对全部目标内容的检索覆盖率,而非任何由大语言模型生成的答案的事实准确性。具有参考意义的比较在于这两组概念集合。通过查询扩展,检索通过率从56.0%提升至74.5%,提升了18.5个百分点;它使另外37个问题能够检索到全部目标内容,失败率也从88降到了51。

    实验结果表明,许多失败案例并非由文档缺失或分块边界不当所致。相关证据其实存在于语料库中,只是原始查询未能为检索提供找到这些证据所需的词汇。

    在不改变意图的情况下进行扩展

    扩展版本会用原文更可能出现的词汇重新表述该概念性问题。假设问题在于探讨什么因素限制了议会改变宪法的本质属性,那么扩展后的表述可能会涉及基本结构原则——即宪法中不可修改的核心内容、与基本权利相冲突的法律、第13条,以及第368条规定的修改权。用户的需求并未改变,但更长的文本增加了诸如以下之类的检索信号:

    • 该原则的通用名称,
    • 相关的宪法术语,
    • 相同概念的其他表达方式,
    • 相关的制度或结构表述,以及
    • 在能够明确推断出的情况下,具体的条款编号。

    同样的分析涵盖了可分割性、核心实质、司法能动性,以及基本权利与指导原则之间的关系。

    在Kaggle竞赛中,扩展后的查询语句是提前写好的,甚至还列出了可能的条款,因此这应被视为一种受控的查询重述实验,而非对自动扩展模型的评估。它展示了当查询包含与源文本一致的语言时,检索效果能提升多少,但若没有目标标签,实时的查询规划器则需要单独进行评估。后续的密集型基准测试用BGE替换了Nomic,因为在固定的子语料库上BGE的表现更好,不过Kaggle的测试结果依然影响了设计思路:更好的嵌入模型能提升语义检索效果,而查询扩展则旨在解决另一个问题,即用户与文档之间的词汇差异。

    扩展查询与子查询

    当某个信息需求以生疏或间接的方式表达时,扩展功能有助于形成更丰富的查询语句。而多跳问题则不同,因为它们包含多个可能指向文档不同部分的信息需求。以比较第32条和第226条规定的救济措施为例,若将其作为一个整体查询进行搜索,各相关信号会相互竞争:第32条的优质结果可能会占据候选列表的大部分位置,而第226条的结果则显得不足,即便对所有候选结果重新排序,也无法保证两条规定都能在列表中保留。

    多跳问题可以拆分为多个针对性的子查询:

    1. 第32条规定了哪些救济措施及管辖权?
    2. 第226条规定了哪些权力及管辖权?
    3. 从宪法层面来看,第32条和第226条有何不同?

    每个子查询都会经历整个检索流程(BM25与密集检索、排序融合、交叉编码器重新排序,以及父节点扩展),最终得到的父节点会被合并并去重,形成综合证据。这样就能避免复杂问题中的某个部分占用本应分配给其他部分的资源,同时由于每个分支都对应特定的需求,使得检索过程更易于检查。

    最终的路由策略

    评估结果表明,并不存在一种通用的查询转换方式能适用于所有问题。直接型问题本身已包含较强的特征信号,应保持原样;概念型问题则可从词汇扩展中受益;而多跳型问题则需要拆分,以便每个需求都有独立的处理分支:

    • 直接型事实类或针对特定文章的问题:使用原始查询进行搜索。
  • 存在词汇不匹配的概念性问题:需将其扩展为一个与源数据匹配的查询。
  • 多跳、比较型或多篇文章相关的问题:应拆分为具体的子查询,分别对每个子查询进行处理。
  • 这种路由方式既能避免不必要的简单查询修改,又能为复杂查询提供所需的结构。最初的Kaggle评估代码位于code/hybrid_eval.ipynb中,而后期的扩展与分支实验则体现在code/hybridrag.ipynb中。

    项目带来的启示

    最重要的教训是,更出色的RAG系统很少依赖于某一个完美的模型。检索质量取决于提取、分块、嵌入、候选项组合、重排序以及上下文恢复等环节的协同决策。评估过程也改变了多项选择:在保持语义边界不变的前提下,密集型基准测试用BGE取代了Nomic;Kaggle实验表明,概念性问题更适合使用与原文更接近的术语;而多跳检索失败则提示需要采用针对性的子查询和独立的检索路径。

    接下来的自然步骤是将该流程封装在具备结构化后端和用于提问及查看来源的前端的聊天机器人应用中,然后使用 Docker 进行打包并部署到云平台。在云平台上,模型加载、内存占用、延迟、持久化索引、日志记录、扩展性以及成本等问题都会出现。该系统仍在不断发展中,一些故障仍未解决,而这正是这项工作的目的:确保每个阶段都可被检查,找出证据丢失的环节,并以评估结果指导后续改进。