检索增强生成技术,简称RAG,是一种这样的方法:应用程序会检索相关的原始资料,并将其添加到模型提示中,从而使模型能够根据这些信息来生成答案。

在RAG系统中,较大的上下文窗口并不能替代良好的上下文管理机制,尽管它确实能让最终用户的使用体验更加顺畅。这就好比在性能强大的GPU上运行未优化的图形程序:额外的处理能力可以在一定程度上掩盖效率低下的问题,但并不能从根本上解决优化不足的问题。

然而,即使上下文窗口的规模很大,也仍然存在上限。如果不断添加数据元素,最终还是会超出这个限度。在消费级硬件上,这个问题表现得更为明显——由于内存和计算资源有限,可使用的上下文窗口通常也会比较小。

我在一台拥有12GB显存的消费级笔记本电脑上测试本地模型时遇到了这个问题。对于小型测试来说,RAG技术效果还不错,但一旦文档内容变长,系统虽然能够检索到一些有用的信息片段,仍然无法给出准确的答案。

问题并不总是出在信息检索环节。有时候,虽然找到了正确的信息片段,但最终的提示框中并没有足够的空间来显示这些内容。

本文将介绍我为解决这个问题而采用的方法:

文档摘要 → 信息片段摘要 → 原始信息片段 → 最终答案

这种处理方式基于以下三条规则:

  • 使用摘要来进行信息检索。

  • 使用原始信息片段来生成最终答案。

  • 通过设定上下文容量限制,来决定哪些信息能够被传递给模型。

为了保持演示的简洁性,配套代码仓库中提供了使用Python和TypeScript编写的简单示例。这些示例使用了简化的内存存储机制和答案提取工具,因此无需安装复杂的依赖库、下载模型、运行大型语言模型服务器或配置向量数据库,就能了解本文的核心理念。

这样的设置过程本身就可以写成一篇独立的文章,因此本教程重点展示了那些可以直接运行的示例——使用摘要进行检索、利用原始信息片段生成答案,以及如何控制上下文信息的容量。

该代码仓库主要演示了数据流和处理流程,而非生产环境级别的模型质量。在实际应用中,你需要用自己的模型、嵌入存储系统、重新排序工具和分词器来替换这些简化的组件。

目录

你将实现的内容

在本教程中,你将构建一个简单的教育用途的RAG系统,该系统通过三个层次来处理文档,从而克服上下文窗口的限制:

  • 文档摘要用于筛选出可能的目标文档。

  • 文本片段摘要用于从这些文档中挑选出可能的目标文本片段,同时也会保留原始的源文本。

  • 原始上下文数据包含被选中的原始文本片段,这些片段会被纳入固定的字符数量限制范围内。

需要重点注意的是,摘要仅用于确定搜索的方向,并不能作为最终的证据依据。

这一点非常重要,因为摘要在生成过程中会丢失部分信息,可能会遗漏用户问题所需的细节。而原始文本片段虽然体积较大,但能够保留所有的原始内容。

演示程序会为每个查询步骤打印详细的日志记录:

  • 是否找到了文档摘要

  • 是否找到了目标文本片段的摘要

  • 是否包含了原始文本片段

  • 哪些原始文本片段被跳过了

  • 最终的查询结果

这些日志记录实际上就是调试工具,它们可以帮助你了解检索过程是否失败,或者是因为上下文数据量不足而导致有用的信息被遗漏。

先决条件

要跟随本教程进行学习,你需要满足以下条件之一:

  • Python 3.10或更高版本

或者:

  • Node.js 22或更高版本

  • npm

如果你已经熟悉以下内容,那么阅读本文会更加顺利:

  • 基本的Python或TypeScript语法

  • 在终端中执行命令

  • 读取小型数据结构,如列表、映射等

  • 了解大语言模型提示语与上下文窗口的基本概念

  • 掌握RAG的基本原理:检索相关文本、将其添加到提示语中,然后根据这些信息生成答案

你不需要具备使用向量数据库、嵌入API、LangChain、LlamaIndex或本地大语言模型的经验。

示例代码并不需要依赖任何大语言模型服务、嵌入API或向量数据库,它们使用的工具包括:

  • 句子提取功能,用于替代大语言模型的摘要生成功能

  • 词袋模型与余弦相似度计算,用于替代嵌入搜索功能

  • 基于固定字符数量的标记划分方法,用于替代分词工具

我做出这些技术选择,是为了节省你的学习时间,让示例代码更易于上手,同时仍然能够实现最初的设计目标。此外,这样的设计也能让你清楚地看到整个检索过程的运作流程。

为什么在上下文窗口较小时基本RAG方法会失败

基本RAG算法的典型执行步骤如下:

加载文档 → 将文档分割成文本片段 → 对这些片段进行嵌入处理 → 检索出最相关的几个片段 → 将这些检索结果添加到提示语中 → 让模型根据这些提示语生成答案。

这确实是一个不错的起点。但这个表述实际上隐藏了两个不同的问题:“检索出最重要的片段”。

首先,你需要找到相关的材料——这就是检索质量的问题。

其次,你必须判断这些被检索出来的材料是否真正适合用于最终的答案生成中——这就是“上下文预算”的问题。

在大型托管模型上,你可能不会立刻注意到这个问题;但在本地模型或上下文窗口较小的情况下,你会很快发现它。

问题的表现形式如下:

  • 检索系统找出了有用的片段。

  • 答案生成系统试图将这些片段加入最终答案中。

  • 但“上下文预算”已经被用完了。

  • 因此有些片段就被忽略了。

  • 最终模型根本看不到这些被忽略的片段。

  • 结果就是答案不完整,或者干脆显示“我不知道”。

当你检查检索结果时,可能会觉得相关片段已经被找出来了,但事实上,检索系统返回了某个片段,并不等同于模型真正理解了这个片段的意义。

如果在资源有限的硬件上开发RAG系统,这种区别就显得尤为重要了。

摘要路由机制的工作原理

与其直接搜索所有的原始片段,不如先根据这些片段的摘要来创建一个路由层。

在索引阶段:

  1. 加载文档内容。

  2. 将每份文档分割成多个片段。

  3. 对每个片段进行摘要提取。

  4. 将这些片段摘要合并成一份总的文档摘要。

  5. 将这份文档摘要存储在专门的数据库中。

  6. 将每个片段的摘要分别存储在对应的数据库中。

  7. 将原始片段保存在一个查找表中。

索引流程的具体步骤如下:

示意图展示了文档如何被分割成片段、如何生成片段摘要、如何进行摘要合并以及这些数据是如何存储的

在查询阶段:

  1. 首先搜索文档摘要,以确定可能的候选文档。

  2. 然后仅在这些候选文档中继续搜索片段摘要。

  3. 将检索到的片段摘要转换回原始片段的ID。

  4. 根据需要,还可以添加相邻的片段。

  5. 最后将这些原始片段整合到最终的上下文结构中。

  6. 最终答案仅由这些原始片段生成。

  7. 查询过程会先利用摘要来进行路由选择,然后在生成答案时再回到原始片段进行处理:

    示意图展示了问题是如何通过文档摘要、片段摘要以及原始片段查找机制来得到最终答案的

    这种机制具有两个显著的优势:

    • 摘要的生成使信息检索变得更加高效。

    • 原始数据块的保留有助于确保答案的准确性。

    这种设计还便于进行调试。如果系统给出的答案不够准确,可以检查相关日志:文档摘要是否正确匹配?数据块摘要是否一致?原始数据块是否适合被纳入最终结果中?还是因为资源限制而被忽略了呢?

    如何表示文档与数据块

    这些数据结构被设计得相当简洁,因为它们仅包含该处理流程所需的必要信息。在真实的系统中,你可能会添加更多的元数据。

    以下是对应的Python代码示例:

    from dataclasses import dataclass
    
    @dataclass(frozen=True)
    class SearchDocument:
        page_content: str
        metadata: dict[str, str | int]
    
    @dataclass(frozen=True)
    class DocumentRecord:
        doc_id: str
        source: str
        text: str
        summary: str
    
    @dataclass(frozen=True)
    class ChunkRecord:
        chunk_id: str
        doc_id: str
        source: str
        index: int
        text: str
        summary: str
        previous_chunk_id: str | None
        next_chunk_id: str | None
    

    DocumentRecord类用于存储完整的文档内容及其摘要,而ChunkRecord类则用于保存原始数据块、其摘要以及与前一个/后一个数据块的关联信息。

    这些相邻数据块之间的链接非常有用,因为数据块的划分往往是人为设定的。如果检索结果中包含了第4个数据块,那么答案可能实际上始于第3个数据块,或者延续到第5个数据块中。

    index类同时维护着用于搜索的数据结构以及相应的查找映射:

    @dataclass(frozen=True)
    class HierarchicalIndex:
        documents_by_id: dict[str, DocumentRecord]
        chunks_by_id: dict[str, ChunkRecord]
        chunks_by_doc_id: dict[str, list[ChunkRecord]]
        document_summary_store: SimpleVectorStore
        chunk_summary_stores_by_doc_id: dict[str, SimpleVectorStore]
    

    其中最重要的查找操作就是这个:

    chunk = index.chunks_by_id[chunk_hit.metadata["chunk_id"]} 

    这条代码将检索到的摘要转换回用于生成最终答案的原始文本。

    如何将文档分割成原始数据块

    这个演示示例会按照段落来分割Markdown文件,并不断合并这些段落,直到达到预设的字符长度限制:

    CHUNK_SIZE = 420
    
    def split_text(text: str) -> list[str]:
        chunks = []
        current_paragraphs = []
        current_size = 0
    
        for paragraph in re.split(r"\n\s*\n", text.strip()):
            paragraph = paragraph.strip()
    
            if not paragraph:
                continue
    
            if current Paragraphs and current_size + len(paragraph) > CHUNK_SIZE:
                chunks.append("\n\n".join(current_paragraphs))
                currentparagraphs = []
                current_size = 0
    
            currentParagraphs.append(paragraph)
            current_size += len(paragraph)
    
        if current_paragraphs:
            chunks.append("\n\n".join(current Paragraphs))
    
        return chunks
    

    有一点需要特别注意:这种分割工具并不适用于所有场景。它的设计初衷就是便于阅读。

    在生产环境中,你可能会使用能够识别标记符的分割工具、支持Markdown格式的分段功能、语义分析功能,或者父子级分段机制。但无论选择哪种方式,核心理念都是一样的:必须保留原始数据片段作为最终的证据。

    如何总结数据片段与文档

    为了便于演示,本文使用句子提取功能来模拟大语言模型的摘要生成过程。系统会评估那些包含关键检索术语的句子,并保留排名靠前的句子。

    def summarize_text(text: str, max_sentences: int = 2) -> str:
        sentences = [
            sentence.strip()
            for sentence in re.split(r"(?<=[.!?])\s+", " ".join(text.split()))
            if sentence.strip()
        ]
    
        if len(sentences) <= max两个句子:
            return " ".join(sentences)
    
        scored_sentences = []
    
        for position, sentence in enumerate(sentences):
            sentence_words = words(sentence)
            term_score = sum(3 for word in sentence_words if word in IMPORTANT_TERMS)
            first_sentence_bonus = 1 if position == 0 else 0
            scored两个句子.append((term_score + first_sentence_bonus, position, sentence))
    
        selected = sorted(scored_sentences, key=lambda item: (-item[0], item[1]))[:max两个句子]
        selected.sort(key=lambda item: item[1])
    
        return " ".join(sentence for _score, _position, sentence in selected)
    

    在真实的系统中,这个函数会调用一个本地的小型模型或托管的模型。提示指令可能会如下所示:

    • 总结这段内容以便后续检索。

    • 请保留名称、约束条件、决策结果、错误信息、数字以及领域特定的术语。

    • 不要回答用户提出的问题。

    需要注意的是,摘要生成的目的并不是取代原始数据片段,它的唯一作用就是帮助提高检索效率。

    如何递归地简化摘要

    一个常见的错误是试图将所有数据片段的摘要合并成一个统一的提示信息来生成文档摘要:

    combined = "\n\n".join(chunk_summaries)
    document_summary = summarize(combined)
    

    这种方法对于少量数据片段来说确实有效,但当数据片段数量达到数百个时,这种做法就不可行了。这样做只会把“上下文窗口”问题从答案生成阶段转移到索引阶段而已。

    一种更好的方法是分批处理摘要的简化工作:

    数据片段摘要 → 分批处理 → 批量摘要 → 更高级别的摘要 → 最终的文档摘要。

    具体的简化流程如下所示:

    示意图显示数据片段摘要如何被分组处理、逐步简化为更高级别的摘要,最终生成最终的文档摘要

    以下是按照预算进行打包的功能实现:

    def pack_summaries_by_token_budget(
        summaries: list[str],
        token_budget: int,
    ) -> list[list[str]]:
        batches = []
        current_batch = []
        current_tokens = 0
    
        for summary in summaries:
            summary_tokens = approximate_tokens(summary)
    
            if current_batch and current_tokens + summary_tokens > token_budget:
                batches.append(current_batch)
                current_batch = []
                current_tokens = 0
    
            current_batch.append(summary)
            current_tokens += summary_tokens
    
        if current_batch:
            batches.append(current_batch)
    
        return batches
    

    而以下则是递归缩减的功能实现:

    def recursively_reduce_summaries(summaries: list[str]) -> str:
        if not summaries:
            return "没有可处理的摘要信息。"
    
        current_summaries = summaries
        level = 1
    
        while len(current_summaries) > 1:
            batches = pack_summaries_by_token_budget(
                current_summaries,
                SUMMARY_REDUCTION_INPUT_TOKEN_BUDGET,
            )
    
            if len(batches) == len(current_summaries):
                batches = force_summary_reduction_progress(current_summaries)
    
            print(
                f"正在将 {len(current_summaries)} 条摘要信息缩减为 "
                f"{len(batches)} 组摘要信息,当前处理层级为 {level}"
            )
    
            current_summaries = [reduce_summary_batch(batch) for batch in batches]
            level += 1
    
        return summarize_text(current_summaries[0], max_sentences=3)
    

    其中,“回退机制”非常重要:

    if len(batches) == len(current_summaries):
        batches = force_summary_reduction_progress(current_summaries)
    

    如果某条摘要的信息量过大,无法与其他摘要合并处理,那么单纯的预算打包机制将无法使缩减过程继续进行;因此,通过将这些摘要配对在一起,才能迫使缩减操作继续进行。

    如何实现分层索引

    一旦你获得了文档记录和数据块记录,就需要创建两种存储结构:

    • 一种用于存储文档摘要的信息

    • 另一种用于按文档分类存储数据块摘要的信息

    以下是用于存储文档摘要的结构实现:

    document_summary_store = SimpleVectorStore(
        [
            SearchDocument(
                page_content=record.summary,
                metadata={"doc_id": record.doc_id, "source": record.source},
            )
            for record in document_records
        ]
    )
    

    接下来,需要按文档对数据块进行分类存储:

    chunks_by_doc_id: dict[str, list[ChunkRecord]] = {}
    
    for chunk in chunk_records:
        chunks_by_doc_id.setdefault(chunk.doc_id, []).append(chunk)
    

    最后,需要为每份文档创建一个专门用于存储其数据块摘要的结构:

    chunk_summary_stores_by_doc_id = {}
    
    for doc_id, doc_chunks in chunks_by_doc_id.items():
        chunk_summary_stores_by_doc_id[doc_id] = SimpleVectorStore(
            [
                SearchDocument(
                    page_content=chunk.summary,
                    metadata={
                        "chunk_id": chunk.chunk_id,
                        "doc_id": chunk.doc_id,
                        "source": chunk.source,
                        "chunk_index": chunk.index,
                    },
                )
                for chunk in doc_chunks
            ]
        )
    

    正是这种机制使得信息检索过程具有层次性:第一次搜索会选出相关的文档,而第二次搜索则只在这些选定的文档内部进行查找。

    如何通过摘要进行检索

    在回答问题时,应首先搜索文档的摘要:

    document_hits = index.document_summary_store.similarity_search(
        question,
        k=min(DOC_RETRIEVAL_K, len(indexdocuments_by_id)),
    )
    

    在这些搜索中,k决定了系统应返回多少条排名靠前的结果。

    接下来,在每份选定的文档内部再搜索其对应的片段摘要:

    chunk_hits = []
    seen_chunk_ids = set()
    
    for document_hit in documenthits:
        doc_id = str(document_hit.metadata["doc_id"])
        chunk_store = index.chunk_summary_stores_by_doc_id[doc_id]
        doc_chunk_count = len(index.chunks_by_doc_id[doc_id])
        per_docHits = chunk_store.similarity_search(
            question,
            k=min(CHUNK_RETRIEVAL_K_PER_DOC, doc_chunk_count),
        )
    
        for chunk_hit in per_doc_hits:
            chunk_id = str(chunk_hit.metadata["chunk_id"])
    
            if chunk_id in seen_chunk_ids:
                continue
    
            chunk Hits.append(chunk_hit)
            seen_chunk_ids.add(chunk_id)
    

    需要注意的是,这里检索到的其实都是摘要信息。

    虽然摘要中包含了chunk_id,但最终的答案仍然会使用与该ID对应的原始片段文本,因为原始片段能够保留那些摘要可能已经省略的原始词汇和细节。

    如何实现带有预算限制的原始上下文系统

    在获取到所有片段摘要之后,需要将这些摘要转换回原始的片段形式。

    演示代码中还包含了查找相邻片段的功能:

    def candidate_raw_chunks(
        chunk_hits: list[SearchDocument],
        index: HierarchicalIndex,
    ) -> list[ChunkRecord]:
        candidates = []
        seen_chunk_ids = set()
    
        for chunk_hit in chunkhits:
            chunk = index.chunks_by_id[str(chunk_hit.metadata["chunk_id"]])
            related_chunk_ids = [chunk.chunk_id]
    
            if EXPAND_NEIGHBORChunks:
                related_chunk_ids.extend([chunk.next_chunk_id, chunk.previous_chunk_id])
    
            for chunk_id in related_chunk_ids:
                if chunk_id is None or chunk_id in seen_chunk_ids:
                    continue
    
                candidates.append(index.chunks_by_id[chunk_id])
                seen_chunk_ids.add(chunk_id)
    
        return candidates
    

    最后,还需要应用预先设定的上下文使用预算限制:

    def build_raw_context(
        chunk_hits: list[SearchDocument],
        index: HierarchicalIndex,
    ) -> tuple[str, list[tuple[ChunkRecord, int]], list[tuple[ChunkRecord, int]]]:
        included_chunks = []
        skippedchunks = []
        used_tokens = 0
    
        for chunk in candidate_raw_chunks(chunk_hits, index):
            raw_context_part = format_raw_chunk(chunk)
            raw_context_tokens = approximate_tokens(raw_context_part)
    
            if used_tokens + raw_context_tokens > RAW_CONTEXT_TOKEN_BUDGET:
                skipped_chunks.append((chunk, raw_context_tokens))
                continue
    
            included_chunks.append((chunk, raw_context_tokens))
            used_tokens += raw_context_tokens
    
        included_chunks.sort(key=lambda item: (item[0].source, item[0].index))
    
        context = "\n\n---\n\n".join(
            format_raw_chunk(chunk)
            for chunk, _tokens in included_chunks
        )
    
        return context, included_chunks, skippedchunks
    

    在这个步骤中,许多与RAG相关的问题就会显现出来。

    如果系统检索到了有用的信息片段,但由于提示框的空间已满而无法显示这些内容,那么问题其实并不出在文档搜索功能上,而是出在“上下文容量限制”这一因素上。

    如何运行演示

    配套代码仓库中包含了同一个示例的两个版本。

    从配套代码仓库的根目录开始,可以分别运行Python版本和TypeScript版本:

    cd python
    python3 -m small_context_rag_solution --question "为什么当上下文容量太小时,RAG会失败?"
    

    运行TypeScript版本的方法如下:

    cd typescript
    npm install
    npm run demo
    

    如果你想以交互式的方式运行这两个示例,可以不使用`--question`参数。输入`q`、`quit`或`exit`即可退出交互模式。

    对于Python版本:

    python3 -m small_context_rag_solution
    

    对于TypeScript版本:

    npm run build
    npm start
    

    系统默认设置的原始上下文容量是较小的,其配置值为`RAW_CONTEXT_TOKEN_BUDGET=250`。这样的设置使得那些被跳过的信息片段能够被显示出来。

    如何理解250与1200这两个数值的含义

    使用这两种不同的上下文容量设置,来运行同一个示例吧。

    对于Python版本:

    RAW_CONTEXT_TOKEN_BUDGET=250 python3 -m small_context_rag_solution --question "为什么当上下文容量太小时,RAG会失败?"
    RAW_CONTEXT_TOKEN_BUDGET=1200 python3 -m small_context_rag_solution --question "为什么当上下文容量太小时,RAG会失败?"
    

    对于TypeScript版本:

    RAW_CONTEXT_TOKEN_BUDGET=250 npm run demo
    RAW_CONTEXT_TOKEN_BUDGET=1200 npm run demo
    

    当上下文容量设置为250个标记时,原始上下文构建器只会包含以下两个信息片段:

    • doc-003-large_rag_notes-chunk-004(约110个标记)

    • doc-003-large_rag_notes-chunk-005(约121个标记)

    同时,还有另外五个信息片段会被跳过:

    • doc-003-large_rag_notes-chunk-003(约117个标记)

    • doc-003-large_rag_notes-chunk-001(约116个标记)

    • doc-003-large_rag_notes-chunk-002(约120个标记)

    • doc-001-context_window_notes-chunk-001(约131个标记)

    • doc-001-context_window_notes-chunk-002(约73个标记)

    而当上下文容量设置为1200个标记时,所有被选中的信息片段都能被包含进来:

    • doc-001-context_window_notes-chunk-001(约131个标记)

    • doc-001-context_window_notes-chunk-002(约73个标记)

    • doc-003-large_rag_notes-chunk-001(约116个标记)

    • doc-003-large_rag_notes-chunk-002(约120个标记)

    • doc-003-large_rag_notes-chunk-003(约117个标记)

    • doc-003-large_rag_notes-chunk-004(约110个标记)

    • doc-003-large_rag_notes-chunk-005(约121个标记)

    没有选中的原始片段会被跳过。

    该图表展示了两种上下文预算之间的差异:

    对比图:一种包含250个词条的原始上下文预算会跳过5个片段,而另一种包含1200个词条的预算则不会跳过任何片段

    对于实际系统而言,1200个词条的限制仍然属于非常小的上下文范围,但这一数字显然比250要大得多。在这个例子中,你可以清楚地看到:当提示生成器拥有更多的空间时,相同的检索路径会产生不同的结果。

    这就是为什么我喜欢同时显示被包含在内的片段以及被跳过的片段——这样做有助于解答一个实际的调试问题:

    是检索过程遗漏了相关证据,还是提示生成过程中丢失了这些信息?

    这个演示使用了简化的答案生成流程,因此不必过分关注最终答案的具体表述。在真实的LLM系统中,你会包含诸如以下的指令:

    • 仅使用下面的原始片段来生成答案。

    • 如果这些原始片段中包含了多个相关理由,请全部包括进来。

    • 对于多部分的答案,使用简洁的列表形式会更好。

    • 如果原始片段中的证据不足,请明确说明这一点。

    更多的上下文信息并不一定能使答案变得更好。提示生成系统仍然需要告诉模型如何利用这些额外的信息。

    这种方法与现有的RAG技术有何关联

    这种模式并非全新的研究成果,而是对RAG生态系统中已有的几种理念进行实际组合后形成的。

    LangChain在其ParentDocumentRetriever中使用了类似的技术:该工具会先搜索较小的子片段,然后返回与这些子片段相关联的父文档。

    这种技术也与LlamaIndex文档摘要索引有关——后者利用文档摘要来筛选相关文档,然后再检索这些文档的具体内容。

    从概念上来看,它还与RAPTOR这类检索方法有所相似:RAPTOR通过递归地对文本进行聚类和摘要处理来构建信息结构。

    不过本文中介绍的版本在实现上更为简单:

    • 不使用任何聚类算法。

    • 不需要依赖任何特定的框架。

    • 演示过程中也不需要使用向量数据库。

    • 我们并不认为文档摘要就足以生成最终答案。

    我们的目标是要展示一种结构清晰、易于理解的方法——这种方法可以让你根据自己的需求进行调整,而无需依赖复杂的框架。在我的本地模型开发工作中,这种分离机制确实非常有用。

    • 用于检索的摘要

    • 用于生成答案依据的原始文本片段

    • 用于调试的预算分配信息

    何时使用这种模式

    在以下情况下,这种模式非常有用:

    • 当你使用内存有限的本地模型进行训练时

    • 当你的上下文窗口规模较小或获取成本较高时

    • 当你拥有大量文档,但每个问题只与其中少数文档相关时

    • 当你需要能够被仔细检查的检索过程记录时

    • 当你既需要用于搜索的摘要,又需要用于生成答案的原始文本时

    • 当你在索引和回答过程中都需要避免使用过长的提示语时

    而在以下情况下,这种模式的效用就会降低:

    • 当你的源文档本身体积就很小时

    • 当你整个语料库都能完全容纳在提示语中时

    • 当使用精确的关键词搜索就足够满足需求时

    • 当你不需要进行多文档之间的信息关联处理时

    • 当你能够承受直接检索并重新排序大量原始文本片段的成本时

    此外,这种模式还会增加一定的索引工作量:

    • 生成文本片段的摘要

    • 进行递归式的摘要优化处理

    • 生成文档的摘要

    • 创建额外的查找映射表

    • 但对于文档辅助工具、研究工具、内部知识库,以及那些只需要进行一次索引操作但后续会频繁进行查询的地方来说,这种开销通常是可以接受的。

      结论

      不要将RAG模式简单地理解为“只是检索一些文本片段然后将其插入到提示语中而已”。

      对于那些上下文范围较小的系统而言,检索过程确实需要进行信息关联处理和资源分配。即使是在配备高性能硬件、拥有较大上下文窗口的系统里,良好的系统设计也是确保系统高效运行的关键因素。

      这种模式其实可以归结为三条实用原则:

      • 摘要有助于帮助用户找到相关的原始资料。

      • 原始文本片段是生成答案的基础。

      • 对上下文资源的合理分配决定了哪些信息会被传递给模型。

      这种解决方案帮助我在资源有限的硬件环境下开发出了更加可靠的本地RAG系统。同时,它也使得故障排查变得更加容易,因为我可以清楚地了解哪些摘要被匹配到了、哪些原始文本片段被选中了、哪些又被忽略了。

      无论你是在本地运行RAG系统,还是使用托管模型,如果你使用的模型规模较小、上下文窗口范围有限,或者需要严格控制提示语的长度,那么在花费更多资金来扩大上下文窗口之前,尝试这种模式绝对是值得的。

Comments are closed.