实现 RAG 流水线的检索模式

已完成

Tip

有关更多详细信息,请参阅 “文本和图像 ”选项卡!

检索扩充生成(RAG)通过提供数据的相关上下文来增强语言模型响应。 检索组件搜索你的知识库,并返回 LLM 用于生成准确、基于事实答案的文档。 带 pgvector 的 Azure Database for PostgreSQL 可作为高效的检索器,存储文档嵌入并执行相似性查询,为生成步骤提供输入。

本单元介绍为 RAG 工作负载设计架构、实现区块检索查询、生成文档引入管道、返回引文元数据以及评估检索质量。

了解 RAG 体系结构和检索器角色

RAG 系统遵循三步模式:

  1. 查询嵌入: 使用嵌入模型将用户的问题转换为矢量
  2. 检索: 在矢量存储中搜索类似于查询嵌入的文档
  3. 生成: 将检索的文档作为上下文传递给 LLM,这将生成响应

PostgreSQL 充当此体系结构中的检索器。 检索质量直接影响生成质量。 如果检索器返回不相关的文档,则 LLM 缺少正确回答所需的上下文,并且可能会生成不正确的信息。 如果检索器错过相关文档,答案可能不完整。

对于法律研究助理来说,有效检索意味着找到解决律师问题的案件先例、法规和合同条款。 然后,LLM 将这些来源整合为一个连贯的答案,并附上引文。

为 RAG 工作负载设计架构

RAG 应用程序通常使用文档区块而不是整个文档。 长文档超出了 LLM 上下文限制,并且可能包含与特定查询无关的部分。 分块将文档拆分为可以独立检索的较小重点段。

分隔文档和区块

使用两个表:一个用于源文档,一个用于嵌入的区块:

CREATE TABLE source_documents (
    id SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    source_url TEXT,
    document_type TEXT,
    ingested_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE document_chunks (
    id SERIAL PRIMARY KEY,
    document_id INTEGER REFERENCES source_documents(id) ON DELETE CASCADE,
    chunk_index INTEGER NOT NULL,
    content TEXT NOT NULL,
    embedding vector(1536),
    token_count INTEGER,
    start_char INTEGER,
    end_char INTEGER,
    UNIQUE (document_id, chunk_index)
);

此设计提供以下几个优势:

  • 灵活检索: 可以检索单个区块或重新构造文档部分
  • 源跟踪: 每个块都可以追溯到其引文来源
  • 高效的存储: 文档元数据存储一次,每个区块不重复
  • 轻松更新:替换文档意味着删除其块并重新摄取

添加用于筛选和上下文的元数据

包括有助于筛选和上下文重建的元数据:

CREATE TABLE document_chunks (
    id SERIAL PRIMARY KEY,
    document_id INTEGER REFERENCES source_documents(id) ON DELETE CASCADE,
    chunk_index INTEGER NOT NULL,
    content TEXT NOT NULL,
    embedding vector(1536),
    token_count INTEGER,
    section_title TEXT,
    page_number INTEGER,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

节标题和页码可帮助用户验证检索到的信息,并帮助 LLM 了解每个区块的上下文。

为 RAG 查询模式创建索引

矢量相似性索引(HNSW 或 IVFFlat)支持快速嵌入查找,如本模块前面所述。 RAG 工作负载还受益于 B 树索引,这些索引可以加速区块检索过程中独特的联接和筛选。 检索区块时,经常将引文元数据联接回源文档,并按文档内的区块位置筛选或排序。 如果没有这些索引,PostgreSQL 会扫描整个区块表以查找每个检索查询。

-- B-tree index for document lookups (speeds up JOINs to source_documents)
CREATE INDEX document_chunks_document_id_idx
ON document_chunks (document_id);

-- Composite index for chunk ordering (supports context window queries)
CREATE INDEX document_chunks_doc_chunk_idx
ON document_chunks (document_id, chunk_index);

对上下文窗口检索而言,复合索引 (document_id, chunk_index) 尤其重要,可在其中提取相邻区块以提供周围的上下文。 如果没有它,PostgreSQL 必须扫描文档的所有区块才能查找邻居。

为上下文生成实现区块检索

RAG 查询检索的块成为 LLM 的上下文。 选择的检索模式会影响答案质量和令牌效率。 你需要平衡三个相互竞争的问题:检索足够的相关内容、保持令牌限制,并为 LLM 提供足够的上下文来了解每个区块。

最简单的方法直接检索 top-k 最相似的区块。 当区块自包含且问题与单个区块完全匹配时,这很有效。 但是,法律文档、技术规范和叙述内容通常会将概念传播到多个段落中。 区块可能会引用“上述条件”或“如上所述”,而不包含引用的文本。

对于这些情况,上下文窗口检索会随每个匹配检索相邻块。 如果区块 47 最类似于查询,则还会检索区块 46 和 48。 这会增加令牌使用量,但会降低未完成答案的风险。 权衡取决于内容结构:具有清晰节边界的高度结构化文档需要比流动的叙述文本更少的周围上下文。

令牌限制添加另一个约束。 LLM 具有固定的上下文窗口,你需要为系统提示、用户问题和生成的响应留出空间。 如果您检索 10 个块,每个块平均有 500 个标记,那么在 LLM 写出一个字之前,您已经使用了 5,000 个标记。 在检索期间跟踪累积令牌计数,以保持在预算范围内。

以下查询合并了这些模式。 它检索最相似的块及其邻居,跟踪累计令牌,并包含引用元数据:

WITH matched_chunks AS (
    -- Find the most similar chunks
    SELECT id, document_id, chunk_index, embedding <=> $1 AS distance
    FROM document_chunks
    ORDER BY embedding <=> $1
    LIMIT 3
),
context_window AS (
    -- Expand to include adjacent chunks for context
    SELECT DISTINCT dc.id, dc.document_id, dc.chunk_index, dc.content,
           dc.token_count, mc.distance
    FROM matched_chunks mc
    JOIN document_chunks dc ON dc.document_id = mc.document_id
        AND dc.chunk_index BETWEEN mc.chunk_index - 1 AND mc.chunk_index + 1
),
token_limited AS (
    -- Track cumulative tokens to respect LLM context limits
    SELECT cw.*, sd.title AS source_title, sd.source_url,
           SUM(cw.token_count) OVER (ORDER BY cw.distance, cw.chunk_index) AS cumulative_tokens
    FROM context_window cw
    JOIN source_documents sd ON cw.document_id = sd.id
)
SELECT id, content, source_title, source_url, distance
FROM token_limited
WHERE cumulative_tokens <= 3000
ORDER BY distance, chunk_index;

根据用例调整参数。 例如,对于回答特定条款问题的法律研究助手,可能只检索五个匹配且不使用上下文窗口。 对于一个回答有关产品文档开放式问题的客户支持机器人,可以检索到三个匹配结果,每个匹配结果包含两个相关的上下文段落。

处理文档引入管道

必须先处理新文档,然后才能进行搜索。 引入管道将文档拆分为区块、生成嵌入内容,并将所有内容存储在 PostgreSQL 中。

拆分文档的方式直接影响检索质量。 正确的分块策略取决于内容结构和查询模式:

  • 固定大小的区块: 拆分每个 N 个字符或令牌。 这种方法实现简单,可生成可预测的令牌数量,但可能在思路中途切断句子或段落。 当内容缺少明确的结构边界或需要一致的区块大小进行令牌预算规划时,请使用固定大小的分块。

  • 语义区块: 在自然边界(如段落、节或句子)处拆分。 这会保留每个区块中的含义,但会生成可变大小。 对结构化文档使用语义分块,其中部分表示完整的想法,例如法律条款、API 文档或常见问题解答条目。

  • 重叠区块: 包括相邻区块中的文本,以在边界处保留上下文。 例如,在 1,000 个字符的区块之间,200 个字符的重叠可确保跨越区块边界的概念至少出现在一个完整的区块中。 当查询可能与区块边界附近的内容匹配时,请使用重叠。

对于法律研究助理情境,在段落或章节边界进行语义分块非常有效,因为法律文本被组织成离散的子句和论点。 每个区块代表一个完整的法律概念,可以在 LLM 的上下文中独立。

一旦您对文档进行分块并生成嵌入,可以使用批处理操作高效地插入它们。 双表设计要求先插入源文档以获取其 ID,然后在单个多行 INSERT中插入所有区块。 此方法可最大程度地减少到数据库的往返,并使文档到区块关系保持不变。 大多数嵌入 API 也接受每个请求的多个文本,因此可以在插入前为一个 API 调用中整个文档的区块生成嵌入内容。

文档更新需要做出决定:是否对文档进行版本控制或替换? 对于大多数 RAG 应用程序,替换更简单。 删除现有区块( ON DELETE CASCADE 约束会在删除源文档时自动处理此区块),然后重新引入更新的内容。 此方法可确保检索结果始终反映当前文档状态。 如果需要版本历史记录,请在查询时按版本筛选,在version旧区块旁边添加一列source_documents并保留旧区块。

HNSW 索引能够高效处理删除操作,而无需重新生成。 IVFFlat 索引可能会在发生重大更改后累积碎片,这需要定期重新生成以保持查询性能。

使用引文实现检索

引文将 RAG 从黑匣子转换为透明研究工具。 当法律研究助理引用合同条款时,律师需要将该引文与原始文件进行核实。 如果没有引文元数据,用户必须盲目信任 LLM 的输出,这是在准确性至关重要的法律、医疗或财务环境中的重要责任。

有效引用需要的不仅是块内容。 检索查询应在可用时返回源文档标题、URL 或文档 ID、节标题和页码。 该元数据使你的应用能够格式化引用,用户可以实际追溯到源文档。 距离分数也很有帮助:可以将高置信度引用突出显示,同时标记低置信度匹配以供用户验证。

当来自同一文档的多个区块与查询匹配时,会出现一个常见的难题。 如果“就业协议模板”中的区块 12、15 和 18 全部显示在最上面的结果中,则将它们列为三个单独的引文会杂乱响应。 相反,按源文档对区块进行分组,并将它们作为单个引文显示,其中包含多个相关的摘录。 此方法生成更简洁的输出,并帮助用户查看每个源的完整上下文。

以下查询演示了文档分组检索。 它会在每个文档中对区块进行排名,限制为每个文档三个区块,以避免上下文压倒性,并聚合内容,以便进行更简洁的引文格式设置:

WITH ranked_chunks AS (
    SELECT
        dc.*,
        sd.title AS document_title,
        sd.source_url,
        dc.embedding <=> $1 AS distance,
        ROW_NUMBER() OVER (PARTITION BY dc.document_id ORDER BY dc.embedding <=> $1) AS rank_in_doc
    FROM document_chunks dc
    JOIN source_documents sd ON dc.document_id = sd.id
    WHERE dc.embedding <=> $1 < 0.5
)
SELECT
    document_id,
    document_title,
    source_url,
    array_agg(content ORDER BY chunk_index) AS chunks,
    MIN(distance) AS best_distance
FROM ranked_chunks
WHERE rank_in_doc <= 3
GROUP BY document_id, document_title, source_url
ORDER BY best_distance
LIMIT 5;

然后,应用程序可以将此格式格式化为用户友好的引文:

“雇主可能会以 30 天的通知终止本协议。” — 雇佣协议模板,第 4.2 节,第 3 页

评估和提高检索质量

检索质量决定了 RAG 的有效性。 无论 LLM 的能力如何,检索效果差都会导致生成不佳。 如果检索器返回不相关的区块,则 LLM 会在无用文本上浪费上下文窗口容量。 如果检索器错过了相关区块,LLM 缺少正确答案所需的信息。 与生成质量分开测量检索质量有助于诊断 RAG 管道需要改进的位置。

三个指标捕获检索性能的不同方面:

  • 精度: 实际相关的检索区块的分数。 低精度意味着 LLM 接收不相关的上下文,这可能会混淆它或导致它生成不正确的信息。 通过收紧距离阈值或减少检索的区块数来提高精度。

  • 召回率:被检索到的相关块的比例。 低召回率意味着 LLM 错过重要信息,导致答案不完整或不正确。 通过松散距离阈值、增加检索的区块计数或调整索引参数(例如 ef_search)来提高召回率。

  • 平均倒数排名(MRR):第一个相关结果在排名列表中的位置高低。 MRR 很重要,因为 LLM 对早期上下文权重更高,用户在浏览引用时会首先注意顶部结果。 通过优化嵌入模型或查询预处理来改进 MRR。

若要度量这些指标,需要一个评估数据集:一组具有代表性的查询,与人为判断哪些区块相关。 构建此数据集需要前期工作(有人必须运行示例查询并标记结果),但它通过让你进行数据驱动的改进而不是猜测来得到回报。 从 20-50 个查询开始,这些查询表示用户实际提出的问题类型。 对于每个查询,检索前 10-20 个区块,并让域专家以简单的规模对相关性进行评分(无关、有点相关、高度相关)。

设置此评估集后,可以在不同的截止点(precision@5、recall@10)测量精度和召回率,并跟踪管道的更改如何影响这些指标。 针对评估集运行检索查询,将结果与人为判断进行比较,并计算指标。 大多数团队会将其自动化为一个评分脚本,每当更改分块策略、嵌入模型或索引参数时,该脚本就会运行。

当指标低于目标时,系统地对影响精度-召回权衡的参数进行实验:

  • 分块大小: 较小的分块通过返回更集中的内容来提高精度,但可能会导致相关信息分散在多个分块中,从而影响召回。 较大的区块可提高召回率,但稀释相关性。

  • 区块重叠: 更多的重叠可跨边界保留上下文,帮助与区块边缘附近的内容匹配的查询。 但是,重叠会增加存储要求,并可能会返回冗余内容。

  • 嵌入模型: 不同的模型捕获不同的语义关系。 基于法律文本训练的模型可能优于法律研究的通用模型。 如果常规模型性能不佳,请考虑特定于域或微调的模型。

  • 索引参数:较高的 ef_search(HNSW)或 probes(IVFFlat)通过搜索更多候选项提高召回率,但会增加查询延迟。 从默认参数开始,仅当召回不足时才会增加。

  • 距离阈值: 通过排除边际匹配项,更严格的阈值可以提高精度。 松散的阈值通过包括更多的候选人来提高召回率。 使用评估数据集查找平衡两个用例指标的阈值。