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 | 编排整个补语境流程,产出 chunks 与 chunks_to_embed | dsrag/add_document.py:46 |
get_document_title | LLM 生成文档标题 | dsrag/auto_context.py:71 |
get_document_summary | LLM 生成一句话文档摘要 | dsrag/auto_context.py:97 |
get_section_summary | LLM 生成一句话章节摘要(逐节) | 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-65 的 auto_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:18、80)——即默认不生成章节摘要,只补文档级语境。开启后逐节调 get_section_summary(详见 §3.2 并行部分)。
这三个函数长得几乎一样(dsrag/auto_context.py):都是"截断超长正文 → 按语言决定是否加多语种指令 → 填 prompt → 调 make_llm_call"。区别只在 prompt 与截断阈值,见下表:
| 函数 | 输入 | 截断上限 | prompt 常量 | 位置 |
|---|---|---|---|---|
get_document_title | 整篇文档正文 | 4000 tokens | DOCUMENT_TITLE_PROMPT | auto_context.py:71 |
get_document_summary | 整篇文档正文 | 8000 tokens | DOCUMENT_SUMMARIZATION_PROMPT | auto_context.py:97 |
get_section_summary | 单个 section 正文 | 不截断 | SECTION_SUMMARIZATION_PROMPT | auto_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:129、196 | knowledge_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)分两步:
- 找变体: 对每个标准词的别名列表,调
find_all_term_variations分批(每批 15 个 chunk)喂给 LLM,让它返回文本里出现的、和目标词模糊匹配的实际字符串(custom_term_mapping.py:30-41、6-16的FIND_TARGET_TERMS_PROMPT)。允许拼写/大小写变化——prompt 里就举例 "capital of France" 能匹配到 "Capitol of Frances"。 - 注入: 对每个 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_embeddings | chunks_to_embed(带头) | 向量在"带全局语境"的文本上算出,表征更准(add_document.py:150) |
add_chunks_to_db | chunks(正文 + 语境字段) | ChunkDB 存纯正文 chunk_text 与各语境字段,分开存(add_document.py:158-178) |
add_vectors_to_db | chunks + 向量 | 向量库里 chunk_text 存正文,chunk_header 现拼一份存元数据(add_document.py:180-207) |
为什么要分开? 因为头是"检索用的辅助表征",不是文档内容。
- Embedding/rerank 需要头 —— 这样"苹果 2023 可穿戴"才进得了向量,召回才准。
- 展示/喂给回答 LLM 时不该带写入侧的头 —— 用户要看的是原文,回答 LLM 要引用的也是原文;写入侧那段冗长的双层头会污染上下文。所以正文单独存,查询时另用更轻的
get_segment_header(§3.3)按需临时拼。
一句话:头是"入库时给 embedding 戴的眼镜",摘下来正文还是干净的原文。 正文与语境字段分列入库(add_chunks_to_db 的 chunk_text / document_title / section_summary 等各占一列),既能重建任意粒度的头,又不让头污染正文。
4. 巧妙之处(可借鉴的技术)
-
表征与存储解耦。 同一个 chunk,"拿去算向量的样子"(带头)和"落库/展示的样子"(纯正文)是两份。检索质量和内容纯净度两头都要,靠
chunksvschunks_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:46 | auto_context |
| 章节摘 要并行任务包装 | dsrag/add_document.py:16 | process_section_summary |
拼 chunks_to_embed(头+正文) | dsrag/add_document.py:126-138 | auto_context(拼头循环) |
| 算向量(只吃带头文本) | dsrag/add_document.py:150 | get_embeddings |
| 正文与语境字段入库 | dsrag/add_document.py:158 | add_chunks_to_db |
| 向量+元数据入库 | dsrag/add_document.py:180 | add_vectors_to_db |
| 文档标题生成 | dsrag/auto_context.py:71 | get_document_title |
| 文档摘要生成 | dsrag/auto_context.py:97 | get_document_summary |
| 章节摘要生成 | dsrag/auto_context.py:124 | get_section_summary |
| 截断长正文 | dsrag/auto_context.py:65 | truncate_content |
| 写入侧拼头(文档+章节) | dsrag/auto_context.py:136 | get_chunk_header |
| 查询侧拼头(仅文档) | dsrag/auto_context.py:147 | get_segment_header |
| prompt 常量 / 多语言指令 | dsrag/auto_context.py:4-63 | DOCUMENT_TITLE_PROMPT、DOCUMENT_SUMMARIZATION_PROMPT、SECTION_SUMMARIZATION_PROMPT、TRUNCATION_MESSAGE、LANGUAGE_ADDENDUM |
| 术语注入编排 | dsrag/custom_term_mapping.py:54 | annotate_chunks |
| 单 chunk 注解(逆序插入) | dsrag/custom_term_mapping.py:43 | annotate_chunk |
| 找术语变体(LLM 分批) | dsrag/custom_term_mapping.py:30 | find_all_term_variations |
| 摄取管线里调用 AutoContext | dsrag/knowledge_base.py:512 | KnowledgeBase.add_document(auto_context 调用点) |
| 查询侧 segment header 调用 | dsrag/knowledge_base.py:762 | _get_segment_header |
相邻章节: dsRAG 总览 · 摄取管线:从文件到 chunk(上游,产出本章吃的 sections/chunks) · RSE 相关段落抽取(查询主算法,用到 get_segment_header) · 可插拔组件与持久化 · 对话层:AutoQuery 与带引用回答