
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_size120, parent_size400): 父块段落聚合到 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, k2): 玩具检索重叠字符打分。换向量库只改这个函数。返回父块 hits sorted(children.values(), keylambda 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, k2) 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 入库时定死运行时只读不改三条断言映射完整 / 无丢字 / 命中排第一是父子分块的硬标准