RAG 检索很准,答案还是烂?把检索块和上下文块拆开
最近几篇在写 RAG:上一篇写了分块策略怎么选,但那篇有个默认假设——检索用什么块,就返回什么块。这篇把这个假设拆掉:检索命中了相关内容,LLM 的回答却单薄、答不出前因后果,多数时候不是检索的锅,是你把"检索用的块"和"给模型的块"当成了同一个。
一、问题场景
知识库按小块切好,问“关羽为何千里走单骑”:
- 检索精准命中“辞别曹操、单人匹马出发”那一小块——只有结果,没有前因
- 模型拿着这一小段去回答,只能复述结果,答不出“为何”
小块检索精准,但回答需要完整上下文。这两个要求天然冲突:块小了检索准但上下文断,块大了上下文全但检索稀释。
解法是把两层拆开:子块只参与检索(短、聚焦),命中后沿映射返回父块(完整上下文)给模型。关键字段不是向量,是 parent_id 映射——映射不稳,就会出现“命中 A 返回 B”的事故。
二、完整代码(单文件,直接跑)
# parent_child.py — 父子分块:小块检索,大块回答 # 依赖:无(纯标准库) import re def split_long(text, max_size): """按句拆(保留标点),塞不下的单句硬切兜底""" chunks, buf = [], "" for s in re.split(r"(?<=[。;,])", text): if not s: continue if len(buf) + len(s) <= max_size: buf += s else: if buf: chunks.append(buf) while len(s) > max_size: chunks.append(s[:max_size]) s = s[max_size:] buf = s if buf: chunks.append(buf) return chunks def build_chunks(text, child_size=120, parent_size=400): """父块:段落聚合到 parent_size;子块:父块内按句细切。 返回 children {child_id: (text, parent_id)} 和 parents {parent_id: text}""" parents, children = {}, {} pid, buf = 0, "" def flush(): nonlocal pid, buf if not buf: return parents[pid] = buf # 父块:回答用,信息完整 for c in split_long(buf, child_size): children[len(children)] = (c, pid) # 子块:检索用,短而聚焦 pid += 1 buf = "" for para in text.split("\n\n"): para = para.strip() if not para: continue if buf and len(buf) + len(para) + 2 > parent_size: flush() # 父块满了:先落盘再开新块 buf = f"{buf}\n\n{para}" if buf else para flush() return children, parents def retrieve(query, children, parents, k=2): """玩具检索:重叠字符打分。换向量库只改这个函数。返回父块""" hits = sorted(children.values(), key=lambda cp: -sum(w in cp[0] for w in query))[:k] out, seen = [], set() for _, pid in hits: if pid not in seen: # 同父块去重 seen.add(pid) out.append(parents[pid]) return out三、事故自检:命中 A 返回 B
父子分块最容易出的严重事故:映射错位后,检索命中了 A,返回的却是 B——检索照常出结果,答案答非所问,不报错。自检把这条事故直接写成断言:
if __name__ == "__main__": text = "\n\n".join( f"段落{i}:讲的是主题{i}。" + "细节内容。" * 8 for i in range(30) ) children, parents = build_chunks(text) # 1) 映射完整:每个子块都能回到父块,且父块包含该子块 for txt, pid in children.values(): assert txt in parents[pid], f"命中 A 返回 B:父块不含子块 {txt[:10]}…" # 2) 父块拼回还原原文(无丢字) assert "".join(parents[i] for i in sorted(parents)).replace("\n\n", "") \ == text.replace("\n\n", ""), "父块丢字" # 3) 检索返回的父块必须包含命中内容 hits = retrieve("主题7 的细节", children, parents, k=2) assert hits and "主题7" in hits[0], "最相关命中没排到第一" print(f"{len(children)} 子块 / {len(parents)} 父块 self-check ok")三条断言各管一类事故:映射完整(命中 A 返回 B)、父块无丢字、检索命中真的排第一。分块参数怎么改,这三条都得过。
四、三个踩坑
- 映射在运行时重算:parent_id 入库时定死就别动,检索服务里重算段落边界(比如调了分块参数),映射全错——命中 A 返回 B 就是这么来的。子块和父块同一次切、同一份映射入库
- 父块不设上限:父块无限聚合,token 直接爆炸。本例 parent_size 兜底,实际按模型上下文预算倒推
- 换了检索就把映射也重写了:玩具重叠检索换成向量库时,只改 retrieve 一个函数,children/parents 两层结构不动——映射层和检索层分开,才好单测
五、什么时候不用父子分块
FAQ、知识卡片这类条目式内容,条目本身就是天然父块(一条问答就是完整上下文),直接整条入库更省事。父子分块解决的是正文文档的检索精度与上下文完整的矛盾——条目式内容套上去是白加一层间接。
总结
铁律压成三句:
- 检索层和上下文层分开:子块管准,父块管全
- parent_id 入库时定死,运行时只读不改
- 三条断言(映射完整 / 无丢字 / 命中排第一)是父子分块的硬标准