大多数 RAG 不是输在检索算法,是文档进库前就已经碎了。
大多数 RAG 失败不在检索算法,而在更前面——文档进库前已经碎了。PDF 两栏错位、表格被 chunk 拦腰斩断、跨文档的实体关系根本没建立。于是 vanilla RAG 能答「第 3.2 条写了什么」,却答不了「整个合同集里有哪些重复义务」——这类全局 sensemaking 问题是 top-k 检索的结构性盲区:答案不在任何单个 chunk,而在全局分布里。GraphRAG 就是为这类问题造的:先用 LLM 把语料抽成实体知识图谱,再对图的 community 预生成摘要,查询时 local(实体邻域)/ global(社区摘要 rollup)双路。代价是索引一次百万 token 的语料要烧掉数十倍于 vanilla 建库的 LLM 成本。这一期讲知识库工程最被忽视的四层——文档解析、表格/图表、图谱构建、GraphRAG 的取舍,以及它什么时候是杀器、什么时候是过度工程。
多数 RAG demo 用 PyPDF2 / pdfplumber 直接抽文本流,对单栏 born-digital PDF 还行,对真实文档(两栏论文、带页眉页脚的合同、扫描件)会产生三类 silent 损伤:(1)阅读序错乱——两栏按 y 坐标交错读成一行;(2)页眉/页脚/水印混入正文,污染 embedding;(3)表格被拍平成无结构 token 流。布局感知解析器(Docling、unstructured、LlamaParse)先做 layout detection(检测 title / paragraph / table / figure 的 bounding box),再按阅读序重组,把不同 block 类型分流。Docling 用 RT-DETR 系版面检测模型,输出带类型与阅读序的结构化文档(IBM, arXiv 2408.09869)。工程要点:解析不是一次性 ETL,是 RAG pipeline 里和 chunking 同级的一等公民——版面错了,chunk 边界全错。
from docling.document_converter import DocumentConverter
conv = DocumentConverter()
doc = conv.convert("contract.pdf").document
# 按结构导出,而不是裸文本流——保留标题层级与表格
md = doc.export_to_markdown() # 表格→md table,标题→#
# 关键:按「阅读序 + block 类型」切 chunk,而非按字符数
for item in doc.iterate_items():
if item.label == "section_header":
section = item.text # 给每个 chunk 带上 section 路径
elif item.label == "table":
yield serialize_table(item) # 表格单独成 chunk,见 §2
elif item.label == "text":
yield {"text": item.text, "section": section}
pdfplumber 够用且快约 10×。
表格的语义在二维结构里:一个单元格的意义由它的行表头 + 列表头共同决定。拍平成文本后,「152.3」失去了「它是 2023 Q3 北美营收」这个 binding。两条工程路线:(1)结构序列化——转成 markdown / HTML / 每行一条 record 的 JSON,让 LLM 生成时仍看得到行列对齐;(2)视觉路线——对本就无文本的图表(柱状/折线)用 vision model 生成结构化描述或做 multimodal embedding。实践中混合最稳:小表序列化成 markdown 进文本索引;大表按行拆 record 并给每行注入表头与表标题;图表走 vision-caption 再连同 caption 一起索引。这与 Anthropic 的 Contextual Retrieval 同源——chunk 前注入上下文(表标题 / 章节),召回显著提升。
# 表格→每行一条带上下文的 record(防止行列关系丢失)
def serialize_table(table, doc_title, section):
headers = table.header_row # ["季度","地区","营收(M)"]
for row in table.body_rows:
cells = dict(zip(headers, row))
# 关键:每行自带表头 + 表标题 + 章节 → 检索回来仍可读
ctx = f"[文档:{doc_title}|章节:{section}|表:{table.caption}] "
yield ctx + "; ".join(f"{h}={v}" for h,v in cells.items())
# "...表:季度营收] 季度=2023Q3; 地区=北美; 营收(M)=152.3"
# 图表(无文本)→ vision model 生成结构化描述再索引
desc = vision_model("描述坐标轴/趋势/极值,输出 JSON", image=chart_png)
图谱构建三步:(1)实体 / 关系抽取——逐 chunk 让 LLM 输出 (entity, type, desc) 和 (source, relation, target);(2)实体消歧 / 合并(entity resolution)——「OpenAI」「OpenAI Inc.」「the company」要 resolve 成同一节点,否则图碎成孤岛;(3)社区检测——用 Leiden 等算法把稠密连接节点聚成 community,为 global search 预生成摘要。两个工程现实:抽取成本随语料线性增长,百万 token 语料的索引成本能到检索成本的几十上百倍;schema 选择决定一切——open extraction 召回广但噪声大、节点爆炸,schema-guided(给定类型白名单)精度高但漏域外。增量更新是另一痛点:LightRAG(HKUDS, arXiv 2410.05779)的增量算法避免全量重建,而早期 Microsoft GraphRAG 改一个文档近乎要重跑社区检测。
# schema-guided 抽取(给类型白名单,控噪声与节点爆炸)
EXTRACT = """从文本抽取实体与关系,仅用以下类型:
实体: [人物, 组织, 产品, 技术, 金额]
关系: [收购, 投资, 发布, 合作, 隶属]
输出 JSON: {"entities":[{"name","type","desc"}],
"relations":[{"source","relation","target"}]}
文本: {chunk}"""
# 实体消歧:抽完不要直接建图,先 resolve 别名
def resolve(raw):
# 1) 规范化(大小写/后缀) 2) embedding 近邻聚类 3) LLM 仲裁边界 case
clusters = cluster_by_embedding(normalize(raw), threshold=0.9)
return [merge(c) for c in clusters] # 别名合并成一节点
GraphRAG(Edge et al., arXiv 2404.16130)的核心洞察:RAG 擅长「局部 retrieval」(答案在某几个 chunk 里),但对「What are the main themes?」这类需要纵览整个语料的 query 结构性失败——答案不在任何单个 chunk,而在全局分布。它的解法是两路检索:local search(从查询实体 fan-out 到邻居 + 关系 + 所属社区,适合「X 和 Y 什么关系」)和 global search(把问题问向每个 community summary,再 map-reduce rollup 成总答,适合「主要主题 / 趋势」)。代价极高:索引要对百万级 token 逐 chunk 抽取 + 社区摘要,global search 一次查询要扫多个社区。决策框架:语料小 / 查询是事实型 / 预算敏感 → vanilla(或 hybrid)RAG;语料大且查询是 thematic、多跳、需要 connect-the-dots → 上图谱。中间地带看更省的变体:LightRAG(增量、双层检索)、HippoRAG(Personalized PageRank 单步多跳,比迭代检索快 6–13×、便宜 10–20×,NeurIPS 2024 / arXiv 2405.14831)、Microsoft 的 DRIFT(融合 local + global)。
# 路由:先判断 query 类型,别无脑全用 GraphRAG
def route(query):
qtype = classify(query) # factoid / relational / global
if qtype == "factoid":
return vanilla_rag(query) # top-k 向量检索,最便宜
if qtype == "relational":
return graphrag.local_search(query) # 实体邻域 fan-out
return graphrag.global_search(query) # 社区摘要 rollup
# global search 本质 = map-reduce over community summaries
# map: 每个社区摘要独立生成「部分答案 + 相关性打分」
# reduce: 按分数筛选 → 合并成最终答(这步决定了它贵)
做完这一套,你以后看任何「知识库 / GraphRAG 产品」都会先剥皮——它的解析层多干净?图谱有没有做 entity resolution?查询是 local 还是 global?——而不是被「图谱增强」营销词唬住。