跳到主要内容

AutoContext:给每个 chunk 补全上下文头

30 秒导读: RAG 把长文档切成小块(chunk)后,每一块都变成了"没头没尾的孤儿"——一段讲"其营收下降了 12%"的文字,离开原文就不知道"其"是谁、这是哪家公司哪一年的报告。AutoContext 是 dsRAG 最关键的写入侧创新:入库前用 LLM 给每个 chunk 自动生成一段"上下文头"(文档标题 + 文档摘要 + 章节标题 + 章节摘要),把它前置到切片正文再去做 embedding 和 rerank。切片因此带着全局语境被表征,召回质量明显改善。


1. 这是什么(零基础也能懂)

  • 一句话定义: AutoContext 在文档切片入库前,自动给每个 chunk 拼一段简短的"上下文头"(contextual chunk header),说明这段文字来自哪个文档、文档讲什么、属于哪一节、这一节讲什么。

  • 它要解决的问题(白话): RAG 的标准做法是把文档切成几百字的小块,分别算向量(embedding)存进向量库,查询时按语义相似度召回。问题是:切片一旦离开原文就丢了语境

    举个例子。假设某份年报里有一段:

    "该部门本季度营收下降了 12%,主要受汇率波动影响。"

    这段单独拿出来看,"该部门"是谁?哪家公司?哪一年?embedding 模型只能看到这几十个字,算出来的向量既模糊又容易和别的公司、别的季度的类似句子撞在一起。用户搜"苹果 2023 年智能手表业务表现",这段本该命中的文字很可能排不进前列——这就是孤立切片导致的表征失真与召回下降

  • AutoContext 的思路: 既然切片丢了语境,那就在它前面补回去。上面那段如果变成:

    Document context: the following excerpt is from a document titled 'Apple Inc. 2023 Form 10-K'. This document is about: the financial performance and operations of Apple Inc. during fiscal year 2023. Section context: this excerpt is from the section titled 'Wearables, Home and Accessories'. This section is about: revenue trends of Apple's wearables business.

    该部门本季度营收下降了 12%,主要受汇率波动影响。

    现在 embedding 模型看得到"苹果""2023""可穿戴设备"这些关键锚点,向量表征立刻变得具体、可区分,召回自然变好。

  • 一句话直觉/类比: 就像给一张从相册里抽出来的散照片,在背面写上"2023 年·夏威夷·全家福"。照片本身没变,但配上这行字,你(和检索系统)一眼就知道它是什么、属于哪一组。

本节不涉及底层代码。记住一件事:AutoContext = 入库前,用 LLM 给每个孤立切片补一段全局语境头。


2. 顶层全景(它大概怎么转)

AutoContext 是整条摄取管线的一步:上游 dsparse 摄取管线 把文件解析并切成 sections(章节)和 chunks(切片)后,交给 auto_context();它给每个 chunk 补齐语境,产出两样东西送进后面的 embedding 与入库步骤。

怎么读下面这张图: 从上到下是数据流。左边一列是"生成语境"(调 LLM),右边分叉出的关键点是——语境头只拼进要去 embedding 的那份文本,chunk 正文和头分开存库

sections + chunks (来自 dsparse)


┌─────────────────────────┐
│ auto_context() │ add_document.py:46
│ │
│ ① 文档标题 get_document_title
│ ② 文档摘要 get_document_summary
│ ③ 各节摘要(并行)get_section_summary
│ ↑ ThreadPoolExecutor
│ ④ 写回每个 chunk 的字段
│ ⑤ (可选)术语注入 annotate_chunks
│ ⑥ 拼头 get_chunk_header
└───────────┬─────────────┘

┌─────────┴──────────┐
▼ ▼
chunks (正文, chunks_to_embed
带 title/summary 字段) (头 + 正文 拼好的字符串)
│ │
▼ ▼
add_chunks_to_db get_embeddings ──► add_vectors_to_db
(ChunkDB 存正文) (只对拼头文本算向量)

各部件一句话职责:

部件干什么位置
auto_context编排整个补语境流程,产出 chunkschunks_to_embeddsrag/add_document.py:46
get_document_titleLLM 生成文档标题dsrag/auto_context.py:71
get_document_summaryLLM 生成一句话文档摘要dsrag/auto_context.py:97
get_section_summaryLLM 生成一句话章节摘要(逐节)dsrag/auto_context.py:124
process_section_summary并行任务包装,供线程池调用dsrag/add_document.py:16
get_chunk_header拼装写入侧的上下文头(文档级 + 章节级)dsrag/auto_context.py:136
get_segment_header拼装查询侧的上下文头(只有文档级)dsrag/auto_context.py:147
annotate_chunks(可选)把自定义术语注入切片正文dsrag/custom_term_mapping.py:54
get_embeddings / add_chunks_to_db / add_vectors_to_db算向量、切片正文入库、向量入库dsrag/add_document.py:150/158/180

主线走一遍(高层): sections + chunks 进来 → 生成标题/文档摘要/各节摘要 → 把这些写回每个 chunk → 拼出 chunk_header 前置到正文得到 chunks_to_embed只对 chunks_to_embed 算向量,而 chunk 正文与各元信息分字段落进 ChunkDB / 向量库。


3. 核心原理(逐个机制,由浅入深)

3.1 生成三层语境:标题、文档摘要、章节摘要

要解决的小问题: "上下文头"里要写什么?dsRAG 选了三样能用一两句话概括、又足以定位切片的信息:文档标题、文档摘要、章节标题+章节摘要。前两样文档级,后两样章节级。

① 文档标题。 如果调用方没传标题、且配置允许生成(use_generated_title,默认 True),就调 LLM 生成;否则退回用 doc_id 当标题。

真实实现见 dsrag/add_document.py:54-65auto_context:

if not document_title and auto_context_config.get("use_generated_title", True):
document_title = get_document_title(...) # 调 LLM
elif not document_title:
document_title = doc_id # 兜底:用文档 id 当标题

这段决定标题从哪来:优先用传入的,其次让 LLM 生成,最后退回 doc_id

② 文档摘要。get_document_summary 配置控制(默认 True),调 get_document_summary 产出一句话(add_document.py:67-77)。

③ 章节摘要。get_section_summaries 控制,注意它默认是 False(见 add_document.py:1880)——即默认不生成章节摘要,只补文档级语境。开启后逐节调 get_section_summary(详见 §3.2 并行部分)。

这三个函数长得几乎一样(dsrag/auto_context.py):都是"截断超长正文 → 按语言决定是否加多语种指令 → 填 prompt → 调 make_llm_call"。区别只在 prompt 与截断阈值,见下表:

函数输入截断上限prompt 常量位置
get_document_title整篇文档正文4000 tokensDOCUMENT_TITLE_PROMPTauto_context.py:71
get_document_summary整篇文档正文8000 tokensDOCUMENT_SUMMARIZATION_PROMPTauto_context.py:97
get_section_summary单个 section 正文不截断SECTION_SUMMARIZATION_PROMPTauto_context.py:124

关键细节 · prompt 设计。 摘要 prompt 强约束只输出一句话,并规定固定句式便于后续拼头。例如文档摘要要求以 "This document is about: X" 开头(DOCUMENT_SUMMARIZATION_PROMPT,auto_context.py:20-38),章节摘要要求 "This section is about: X"(auto_context.py:44-61)。标题 prompt 则强调"只返回标题,别的都不要"(auto_context.py:4-18)。

关键细节 · 截断。 长文档不可能整篇塞进 prompt,truncate_content 用 tiktoken 编码后按 token 上限硬截(auto_context.py:65-69):

def truncate_content(content: str, max_tokens: int):
TOKEN_ENCODER = tiktoken.encoding_for_model('gpt-3.5-turbo')
tokens = TOKEN_ENCODER.encode(content, disallowed_special=())
truncated_tokens = tokens[:max_tokens]
return TOKEN_ENCODER.decode(truncated_tokens), min(len(tokens), max_tokens)

返回截断后的文本 + 实际 token 数。若发生截断,还会往 prompt 里插一句 TRUNCATION_MESSAGE(auto_context.py:40-42),明确告诉模型"这只是文档开头的前 ~N 词,但你的回答要针对整篇"。注意 num_words 是写死在调用处的提示值(标题给 3000、摘要给 6000,auto_context.py:78/104),与 token 上限不是同一个数。

关键细节 · 多语言。 当知识库语言不是英语时(language != "en"),三个生成函数都会把 LANGUAGE_ADDENDUM 拼进 prompt(auto_context.py:63),强制模型用文档原语言输出整段回答:

YOU MUST use the same language as the document for your entire response...(auto_context.py:63)

这保证中文文档拼出的是中文语境头,而不是把中文摘要混成英文。语言值从知识库元数据取,默认 "en"(knowledge_base.py:521 传入 self.kb_metadata.get("language", "en"))。


3.2 并行生成各节摘要(ThreadPoolExecutor)

要解决的小问题: 一份长文档可能有几十个 section,逐个串行调 LLM 会非常慢。章节摘要之间互不依赖——天然适合并行。

思路: 用线程池并发发起 LLM 请求,谁先回来就先写谁。

真实实现: auto_context 在开启 get_section_summaries 时建线程池(add_document.py:80-106)。worker 数取 llm_max_concurrent_requests(默认 5)与 section 数的较小值:

max_concurrent_workers = auto_context_config.get("llm_max_concurrent_requests", 5)
with ThreadPoolExecutor(max_workers=min(max_concurrent_workers, len(sections))) as executor:
future_to_section = {
executor.submit(process_section_summary, section, ..., i): (i, section)
for i, section in enumerate(sections)
}
for future in as_completed(future_to_section): # 谁先完成先处理
i, section = future_to_section[future]
sections[i]["summary"] = future.result()

每个 future 记住自己对应的 i,as_completed 乱序返回也能按 i 写回正确的 section。

关键细节 · 容错。 单个 section 摘要失败不会拖垮整篇文档:future.result() 抛异常时被 try/except 捕获,记 error 日志并把该节摘要置为空串(add_document.py:103-106)。空摘要不影响后续拼头(拼头时空字符串只是少一句话)。

关键细节 · 并行任务包装。 process_section_summary(add_document.py:16)是给线程池调用的薄包装:再判一次 get_section_summaries 开关,调 get_section_summary,顺带记一条 debug 日志(耗时 + 摘要文本);关掉时直接返回空串。


3.3 拼头规则:写入侧 vs 查询侧的差异

要解决的小问题: 语境信息生成好后,怎么拼成一段头?而且——写入时(embedding/rerank)和查询时(把召回结果喂给回答 LLM)需要的语境详略不同。dsRAG 为此准备了两个拼头函数。

第一步:把生成的语境写回每个 chunk。 auto_context 遍历所有 chunk,按 section_index 认领自己所属章节的标题与摘要(add_document.py:113-119):

for chunk in chunks:
chunk["document_title"] = document_title
chunk["document_summary"] = document_summary
section_index = chunk["section_index"]
if section_index is not None:
chunk["section_title"] = sections[section_index]["title"]
chunk["section_summary"] = sections[section_index]["summary"]

这一步让每个 chunk 自带四个语境字段,后面拼头和入库都靠它们。

第二步:写入侧拼头 get_chunk_header 它拼两层——文档级 + 章节级(auto_context.py:136-145):

def get_chunk_header(document_title="", document_summary="", section_title="", section_summary=""):
chunk_header = ""
if document_title:
chunk_header += f"Document context: the following excerpt is from a document titled '{document_title}'. {document_summary}"
if section_title:
chunk_header += f"\n\nSection context: this excerpt is from the section titled '{section_title}'. {section_summary}"
return chunk_header

注意都是 if 保护:没有标题就不拼那一层,所以关掉章节摘要时头里自然只剩文档级一段。

查询侧拼头 get_segment_header(auto_context.py:147-154)只拼文档级一层,故意丢掉章节级信息。源码注释给了理由:查询时召回的"段"(segment,见 RSE 相关段落抽取)要么已经足够大、不需要章节语境,要么它就是那个"章节语境和查询无关"的孤立切片——两种情况下章节级信息都是噪声。

两个拼头函数一眼对比:

get_chunk_header(写入侧)get_segment_header(查询侧)
用在哪embedding / rerank 前,前置到切片召回后,前置到 segment 再喂回答 LLM
含文档级(标题+摘要)
含章节级(章节标题+摘要)
调用点add_document.py:129196knowledge_base.py:769(_get_segment_header)

一句话记法:入库时"文档+章节"都补,查询时只补"文档",因为章节语境此刻多半是噪声。


3.4 (可选)custom_term_mapping:术语注入

要解决的小问题: 有些领域词有大量别名/拼写变体(如 "苹果公司" / "Apple" / "AAPL" / "苹果")。用户搜其中一种,文档里写的是另一种,embedding 未必能把它们拉到一起。custom_term_mapping 让作者显式声明"这些词都指同一个标准词",并把标准词注入切片正文

思路: 用户给一个映射 {标准词: [别名1, 别名2, ...]}。dsRAG 先用 LLM 在所有切片里找出这些别名的模糊变体,再在每处别名后面括注上标准词。

真实实现: annotate_chunks(dsrag/custom_term_mapping.py:54)分两步:

  1. 找变体: 对每个标准词的别名列表,调 find_all_term_variations 分批(每批 15 个 chunk)喂给 LLM,让它返回文本里出现的、和目标词模糊匹配的实际字符串(custom_term_mapping.py:30-416-16FIND_TARGET_TERMS_PROMPT)。允许拼写/大小写变化——prompt 里就举例 "capital of France" 能匹配到 "Capitol of Frances"。
  2. 注入: 对每个 chunk,把找到的每个变体后面括注标准词(annotate_chunk,custom_term_mapping.py:43-52):
for term in terms:
instances = re.finditer(re.escape(term), chunk)
for instance in reversed(instances): # 从后往前插,避免打乱前面的下标
end = instance.end()
chunk = chunk[:end] + f" ({key})" + chunk[end:]

从后往前插入是个小技巧:先改后面的位置,前面已找到的下标才不会失效。

关键细节 · 注入点。 术语注入覆盖的是 chunk 正文本身,不是拼在头里。auto_context 在拼头循环里,如果开了 custom_term_mapping,就用注解版正文替换原正文(add_document.py:135-137):

if auto_context_config.get("custom_term_mapping", None):
chunk["content"] = annotated_chunks[i] # 用注解后的正文覆盖原正文
chunk_to_embed = f"{chunk_header}\n\n{chunk['content']}"

这意味着注解会跟着正文一起落库(而不像上下文头那样只在 embedding 时临时前置)——见 §3.5。


3.5 关键点:头只在 embedding 时前置,正文与头分开落库

这是 AutoContext 最容易被忽略、却最重要的设计:上下文头不进正文,只进"要算向量的那份文本"。

auto_context 最终返回两样东西(add_document.py:148):

  • chunks —— 切片对象,content正文(若开了术语映射,是注解后的正文),另带 document_title/document_summary/section_title/section_summary 四个语境字段
  • chunks_to_embed —— 一批字符串,每条是 f"{chunk_header}\n\n{chunk['content']}",即头 + 正文拼好的完整文本(add_document.py:126-138)。

后面三步各取所需:

步骤吃谁结果
get_embeddingschunks_to_embed(带头)向量在"带全局语境"的文本上算出,表征更准(add_document.py:150)
add_chunks_to_dbchunks(正文 + 语境字段)ChunkDB 存纯正文 chunk_text 与各语境字段,分开存(add_document.py:158-178)
add_vectors_to_dbchunks + 向量向量库里 chunk_text 存正文,chunk_header 现拼一份存元数据(add_document.py:180-207)

为什么要分开? 因为头是"检索用的辅助表征",不是文档内容。

  • Embedding/rerank 需要头 —— 这样"苹果 2023 可穿戴"才进得了向量,召回才准。
  • 展示/喂给回答 LLM 时不该带写入侧的头 —— 用户要看的是原文,回答 LLM 要引用的也是原文;写入侧那段冗长的双层头会污染上下文。所以正文单独存,查询时另用更轻的 get_segment_header(§3.3)按需临时拼。

一句话:头是"入库时给 embedding 戴的眼镜",摘下来正文还是干净的原文。 正文与语境字段分列入库(add_chunks_to_dbchunk_text / document_title / section_summary 等各占一列),既能重建任意粒度的头,又不让头污染正文。


4. 巧妙之处(可借鉴的技术)

  • 表征与存储解耦。 同一个 chunk,"拿去算向量的样子"(带头)和"落库/展示的样子"(纯正文)是两份。检索质量和内容纯净度两头都要,靠 chunks vs chunks_to_embed 双产物实现(add_document.py:148)。

  • 写入侧厚、查询侧薄。 入库时不惜成本补齐文档+章节双层语境(get_chunk_header);查询时反而故意只留文档级(get_segment_header),并在源码注释里论证"章节级此刻是噪声"(auto_context.py:147-154)。同一信息在不同环节按需裁剪。

  • 固定句式的摘要。 强约束 LLM 输出 "This document is about: X" / "This section is about: X"(auto_context.py:20-61),让下游拼头无需解析、直接字符串拼接。

  • 从后往前插注解。 annotate_chunk 逆序插入避免下标漂移(custom_term_mapping.py:49)——处理"就地修改字符串的多个位置"的经典手法。

  • 默认省钱。 章节摘要默认 False(add_document.py:18/80):多花 N 次 LLM 调用,只在需要时开;开了也并行摊平延迟。


5. 边界与局限

  • AutoContext 是花钱的。 每篇文档至少 1~2 次 LLM 调用(标题+摘要),开章节摘要后还要 N 次;custom_term_mapping 又要按批扫全部 chunk 找变体(custom_term_mapping.py:30-41)。语境质量换算力与延迟。

  • 摘要基于文档开头。 标题看前 4000 tokens、文档摘要看前 8000 tokens(auto_context.py:73/99),靠 prompt 提醒模型"要针对整篇"来弥补。长文档结尾的独有主题可能没进摘要,这是硬截断的固有代价。

  • 章节摘要不截断。 get_section_summary 不做 truncate_content(对比 §3.1),超长 section 有超 LLM 上下文窗口的风险——依赖上游 dsparse 把 section 切得别太大。

  • 术语注入依赖 LLM 找变体。 变体识别本身是 LLM 调用(find_target_terms_batch),会漏、会误标;re.escape 精确定位后括注,匹配不到就不注入。

  • 头质量 = 摘要质量。 上下文头由 LLM 摘要拼成,摘要跑偏,前置的语境也跟着偏——这是把语境交给模型生成的代价。


6. 代码地图(导航索引)

主题文件路径符号名
AutoContext 主编排dsrag/add_document.py:46auto_context
章节摘要并行任务包装dsrag/add_document.py:16process_section_summary
chunks_to_embed(头+正文)dsrag/add_document.py:126-138auto_context(拼头循环)
算向量(只吃带头文本)dsrag/add_document.py:150get_embeddings
正文与语境字段入库dsrag/add_document.py:158add_chunks_to_db
向量+元数据入库dsrag/add_document.py:180add_vectors_to_db
文档标题生成dsrag/auto_context.py:71get_document_title
文档摘要生成dsrag/auto_context.py:97get_document_summary
章节摘要生成dsrag/auto_context.py:124get_section_summary
截断长正文dsrag/auto_context.py:65truncate_content
写入侧拼头(文档+章节)dsrag/auto_context.py:136get_chunk_header
查询侧拼头(仅文档)dsrag/auto_context.py:147get_segment_header
prompt 常量 / 多语言指令dsrag/auto_context.py:4-63DOCUMENT_TITLE_PROMPTDOCUMENT_SUMMARIZATION_PROMPTSECTION_SUMMARIZATION_PROMPTTRUNCATION_MESSAGELANGUAGE_ADDENDUM
术语注入编排dsrag/custom_term_mapping.py:54annotate_chunks
单 chunk 注解(逆序插入)dsrag/custom_term_mapping.py:43annotate_chunk
找术语变体(LLM 分批)dsrag/custom_term_mapping.py:30find_all_term_variations
摄取管线里调用 AutoContextdsrag/knowledge_base.py:512KnowledgeBase.add_document(auto_context 调用点)
查询侧 segment header 调用dsrag/knowledge_base.py:762_get_segment_header

相邻章节: dsRAG 总览 · 摄取管线:从文件到 chunk(上游,产出本章吃的 sections/chunks) · RSE 相关段落抽取(查询主算法,用到 get_segment_header) · 可插拔组件与持久化 · 对话层:AutoQuery 与带引用回答