用小块做向量检索,命中的经常是一句关键细节,生成时却缺前后文。本文用 Node.js 把「子块召回、父块回填」做成一层可测试的索引关系:子块入库、命中后按映射扩成父块,再去重并按 Token 预算裁剪。不承诺某个分数会变好,只保证这层流水线能接到现有切片与召回代码上,也能整段关掉回滚。

问题:检索命中的是句子,回答需要的是段落

结构切片回答的是怎么切开。切完如果直接拿小块做向量检索,问句里的关键词很容易钉死某一句,top-k 里却拼不出完整约束。文档若写「读请求可重试两次,写请求默认不重试」,检索可能只抓住「可重试」,生成就会把写接口也重试掉。这不是切分函数写错了,是检索粒度与生成粒度被绑成了同一段文本。

小块的好处仍然成立:向量更聚焦,细节问句不容易被整节平均值稀释。父块的好处是另一面:条件、例外、前置定义通常写在句子旁边,而不是写在被命中的那几个字里。子块召回加父块回填把两件事拆开——匹配走子块,阅读走父块。它和「把 chunk 一律加大」不是同一件事。加大以后检索会变钝;只检索大块、生成也用大块,细节问句更容易漂。

下面不引入向量数据库,也不绑定某一家 embedding 服务。向量用词袋加余弦,只为了让映射、扩写、去重、预算能单文件跑通。生产环境把 embed 换成真实模型,后面的索引关系可以原样留下。

先把三层 ID 写死

回填依赖的不是「再搜一次大块」,而是入库时留下的引用。少一张表,检索结果里就只剩一段 text,事后无法稳定回到段落。约定三层 ID:

  • docId:源文档。
  • parentId:生成时喂给模型的单位,一般对应段落,或合并后仍短于预算的一小节。
  • childId:入库向量的单位,从父块滑窗切出。

子块记录只保存 childId、parentId、docId、text、vector。父块正文只在父块表里出现一次。不要把父块全文再抄进每条子块,否则改了一边、另一边还在用旧文本。

切分保持笨一点:按空行拆段落,超过长度就新开父块;父块内按字符窗口切子块,带重叠,避免句子卡在窗口边缘后完全检索不到。窗口必须明显小于父块,否则子块召回没有意义。窗口具体多大,跟你现有切片走,本文不把它当成调参对象。

入库:父块登记,子块检索

入库顺序固定:文档 → 父块表 → 子块向量 → childId -> parentId。检索函数只返回子块命中,不在这一层拼父块。这样单测可以分别断言「这句话能搜到哪个 childId」以及「这个 childId 是否指向正确 parentId」。映射坏了也可以只重建引用表,不必重切父块。

下面是一份可直接运行的脚本(Node.js 18+,无第三方依赖):

// parent-child-rag.mjs
// 运行: node parent-child-rag.mjs

function tokenize(text) {
  return text.toLowerCase().match(/[\u4e00-\u9fff]|[a-z0-9]+/g) ?? [];
}

function embed(text) {
  const counts = new Map();
  for (const t of tokenize(text)) {
    counts.set(t, (counts.get(t) ?? 0) + 1);
  }
  let norm = 0;
  for (const c of counts.values()) norm += c * c;
  return { counts, norm: Math.sqrt(norm) || 1 };
}

function cosine(a, b) {
  let dot = 0;
  const [s, l] = a.counts.size <= b.counts.size ? [a, b] : [b, a];
  for (const [t, v] of s.counts) {
    const u = l.counts.get(t);
    if (u) dot += v * u;
  }
  return dot / (a.norm * b.norm);
}

function estimateTokens(text) {
  const cjk = (text.match(/[\u4e00-\u9fff]/g) ?? []).length;
  const words = (text.match(/[A-Za-z0-9]+/g) ?? []).length;
  return cjk + words;
}

function splitParents(docId, text, maxParentChars = 80) {
  const paras = text.split(/\n+/).map((s) => s.trim()).filter(Boolean);
  const parents = [];
  let buf = "";
  let idx = 0;
  const flush = () => {
    if (!buf) return;
    parents.push({ parentId: `${docId}#p${idx}`, docId, text: buf });
    idx += 1;
    buf = "";
  };
  for (const para of paras) {
    if (buf && buf.length + para.length + 1 > maxParentChars) flush();
    buf = buf ? `${buf}\n${para}` : para;
  }
  flush();
  return parents;
}

function splitChildren(parent, windowChars = 32, overlapChars = 12) {
  const { text, parentId, docId } = parent;
  if (text.length <= windowChars) {
    return [{ childId: `${parentId}#c0`, parentId, docId, text }];
  }
  const children = [];
  let start = 0;
  let i = 0;
  while (start < text.length) {
    const end = Math.min(text.length, start + windowChars);
    children.push({
      childId: `${parentId}#c${i}`,
      parentId,
      docId,
      text: text.slice(start, end),
    });
    if (end >= text.length) break;
    start = end - overlapChars;
    i += 1;
  }
  return children;
}

function buildIndex(documents, options = {}) {
  const parents = new Map();
  const children = [];
  const childToParent = new Map();
  for (const doc of documents) {
    const ps = splitParents(doc.docId, doc.text, options.maxParentChars);
    for (const p of ps) {
      parents.set(p.parentId, p);
      for (const c of splitChildren(p, options.windowChars, options.overlapChars)) {
        children.push({ ...c, vector: embed(c.text) });
        childToParent.set(c.childId, c.parentId);
      }
    }
  }
  return { parents, children, childToParent };
}

function searchChildren(index, query, topK = 4) {
  const qv = embed(query);
  return index.children
    .map((c) => ({
      childId: c.childId,
      parentId: c.parentId,
      docId: c.docId,
      text: c.text,
      score: cosine(qv, c.vector),
    }))
    .sort((a, b) => b.score - a.score)
    .slice(0, topK);
}

function expandParents(index, hits, tokenBudget = 120) {
  const best = new Map();
  for (const hit of hits) {
    const parentId = index.childToParent.get(hit.childId);
    if (!parentId) continue;
    const prev = best.get(parentId);
    if (!prev || hit.score > prev.score) {
      best.set(parentId, { parentId, score: hit.score, childId: hit.childId });
    }
  }
  const ranked = [...best.values()].sort((a, b) => b.score - a.score);
  const selected = [];
  let used = 0;
  for (const item of ranked) {
    const parent = index.parents.get(item.parentId);
    if (!parent) continue;
    const cost = estimateTokens(parent.text);
    if (selected.length > 0 && used + cost > tokenBudget) continue;
    selected.push({
      parentId: parent.parentId,
      docId: parent.docId,
      text: parent.text,
      score: item.score,
      fromChildId: item.childId,
      tokens: cost,
    });
    used += cost;
  }
  return { selected, usedTokens: used };
}

const docs = [
  {
    docId: "retry.md",
    text: `超时与重试
接口超时默认 3 秒。读请求可重试 2 次,写请求默认不重试,避免重复下单。
重试间隔使用指数退避,上限 8 秒。熔断器打开后,同一路由在 30 秒内直接失败。`,
  },
  {
    docId: "auth.md",
    text: `鉴权
内部服务使用 HMAC 签名。签名时钟偏移超过 5 分钟视为无效。
网关会把用户 id 写入 X-User-Id。下游不得再解析 Token。`,
  },
];

const index = buildIndex(docs, {
  maxParentChars: 70,
  windowChars: 28,
  overlapChars: 10,
});

const query = "写请求超时后能不能重试?";
const hits = searchChildren(index, query, 4);
const expanded = expandParents(index, hits, 90);

const mapped = hits.every((h) => index.childToParent.get(h.childId) === h.parentId);
const uniqueParents = new Set(expanded.selected.map((p) => p.parentId));
if (!mapped) throw new Error("childToParent 与 hit.parentId 不一致");
if (uniqueParents.size !== expanded.selected.length) throw new Error("父块未去重");

console.log("子块命中:");
for (const h of hits) {
  console.log(`- ${h.childId}  score=${h.score.toFixed(3)}  ${h.text}`);
}
console.log("\n父块回填:");
for (const p of expanded.selected) {
  console.log(`- ${p.parentId}  from=${p.fromChildId}  tokens=${p.tokens}`);
  console.log(`  ${p.text.replace(/\n/g, " / ")}`);
}
console.log(`\nusedTokens=${expanded.usedTokens}`);

跑完应能看到:子块可能只是「写请求默认不重试」附近的一小段,回填后同一 parentId 会带上「读请求可重试 / 写请求默认不重试」整段。脚本里两条断言只检查映射和去重,不检查分数高低。词袋向量能演示流水线,不能拿它当线上效果。

生产里若已有 embedding 接口,保持「输入一段文本、输出向量、再用余弦或内积排序」即可替换 embed / cosine。例如 OpenAI Node SDK 的 embeddings.create(model: "text-embedding-3-small",取 data[0].embedding)形状就是这样。不要把回填写进向量库的 search 里,否则检索层和扩写层无法分开测。

回填后的去重、预算和回滚

命中子块之后只做三步,不要再开一轮检索。

  1. 映射:用 childToParent 找到父块。hit 上即使带了 parentId,也以表为准,避免切片代码事后改了字段名。
  2. 去重:多个子块打中同一父块时只保留一次,分数取这些子块的最高分。去重键是 parentId,不是文本内容;两段字面相同但来自不同文档,应分开保留。
  3. 裁剪:按分数降序累加 estimateTokens,超出预算就跳过该父块。第一块始终留下,避免上下文被裁成空。整段丢弃比从中间截断更可预期——截断会把条件句切没,等于又回到「只抓住一句细节」。

estimateTokens 按汉字计 1、连续英文或数字计 1,只是预算用的粗估,不是某模型的官方 tokenizer。接到线上时换成你实际计费或截断用的计数函数,循环本身不用改。预算是「父块总和」的上限,不是子块总和;回填后文本会变长,所以预算应按扩写后计算。

可回滚的点很窄:生成前选 expanded.selected 还是 hits 的 text。关掉回填不需要重建索引,映射表可以留着。若只想验证引用,不断言生成质量,用上面的 mapped 和 uniqueParents 就够。接到现有代码时,已有切片输出当作父块数组传入 buildIndex;已有召回只要能给出稳定的片段 ID,用同一张表做 expandParents。

常见坑就两个。一是子块窗口和父块一样大,回填变成恒等变换,还多了一层 ID。二是按文档去重而不是按父块去重,一篇长文只剩最先命中的那一节,后面相关段落被扔掉。这两类问题用断言就能拦住,不必先上评测集。

总结

子块召回解决的是「问句钉得住细节」,父块回填解决的是「生成时还能看见约束」。真正要维护的是 childId → parentId → docId 这张引用,以及扩写后的去重和 Token 预算。把检索层和扩写层拆开,这套关系可以单测、可以接到现有切片与召回上,也可以在生成前关掉回填做对照。分数会不会变,留给你自己的评测;流水线先保证可跑、可回滚。