跨文档问「人」和「事」时,最贵的一步往往不是向量检索,而是让模型在当次请求里临时连读切片、现场拼关系。把实体、别名和共现边放进入库流水线离线织好,在线只用子图收窄候选,再跑 BM25 或带过滤的向量检索,路径更短,也更容易回滚。下面用 Node.js 标准库给出一套可落盘、可版本化的织图与约束召回示例,不引入虚构的图谱框架。

当场连读为什么不适合当主路径

多跳问题的典型形态是:答案不在单篇文档里。例如「谁主导了某次迁移,后来又和哪条产品线有关」——人名出现在事故复盘,产品线出现在组织公告,两边只靠同一项目、同一值班表或同一时间窗口发生共现。若把希望寄托在当次请求里检索若干切片、再让模型把它们通读一遍并现场推断关系,Demo 往往能跑通,线上却会重复付三笔账。

第一笔是费用和延迟。通读轮次一加,上下文随切片线性变长,超时和截断都会出现。第二笔是稳定性。模型对「两段文字是否构成同一条人事关系」没有一致标准,同样的两段有时看成同事,有时看成路人,评测集上一问一答很难复现。第三笔是关系不落盘。下一问还得再读一遍,缓存的是生成结果而不是可复用的约束。

更稳的做法是把约束前移到入库。切片写入检索索引的同时,抽出实体、合并别名、按共现加边。在线阶段先根据问题里的实体走有限跳数,得到允许进入检索的 chunkId 集合,再在这个子集上跑 BM25,或把同一批 ID 当作向量索引的 filter。图在这里不是知识的最终真相,只是召回阶段的硬过滤。抽错了可以改别名、回收边;文档改了可以按切片撤证据。这比给智能体再加一轮通读更容易评测,也更容易回滚。

需要克制预期:共现只说明「一起出现过」,不说明「汇报关系」或「因果关系」。把它当作候选收窄器,而不是把图谱直接当成答案。

离线织图:实体、别名与共现边

实体用稳定 ID,表面词放在别名表。共现边做成无向边,权重用共同出现过的切片数,边上保存 evidence(chunkId 列表)。同一切片内实体两两相连即可;窗口切得太大,整篇文档所有实体完全图化,约束会失效。别名表与图分离:合并「徐康」和 Xu Kang 只改别名,不必重抽全部共现。

抽取不必一上来上模型。词典最长匹配可复现、可回滚,错误形态也清楚。生产环境可以换成已有的抽取服务,但落盘结构和边回收逻辑建议保持不变:节点是 ID,边带证据,快照带版本。

将下面保存为 graph-recall.mjs,执行 node graph-recall.mjs。脚本会织第一版图、做一次约束召回、演示按切片删边,并把当前指针回滚到 v1。

import {
  mkdirSync,
  writeFileSync,
  readFileSync,
  existsSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const root = dirname(fileURLToPath(import.meta.url));
const dataDir = join(root, "data");

const documents = [
  {
    chunkId: "c1",
    text: "徐康负责支付迁移。迁移期间与林舟共同值班,处理对账差异。",
  },
  {
    chunkId: "c2",
    text: "林舟随后接手商户平台,并在季度复盘中同步了支付迁移的残留问题。",
  },
  {
    chunkId: "c3",
    text: "商户平台的发票能力由陈楠设计,与支付迁移无直接人员重叠。",
  },
];

const aliasTable = [
  { id: "e:xukang", names: ["徐康", "Xu Kang"] },
  { id: "e:linzhou", names: ["林舟"] },
  { id: "e:chennan", names: ["陈楠"] },
  { id: "e:pay-migration", names: ["支付迁移"] },
  { id: "e:merchant", names: ["商户平台"] },
];

function buildAliasIndex(table) {
  const surfaceToId = new Map();
  const names = [];
  for (const ent of table) {
    for (const name of ent.names) {
      surfaceToId.set(name, ent.id);
      names.push(name);
    }
  }
  names.sort((a, b) => b.length - a.length);
  return { surfaceToId, names };
}

function extractEntities(text, index) {
  const found = new Map();
  let i = 0;
  while (i < text.length) {
    let hit = null;
    for (const name of index.names) {
      if (text.startsWith(name, i)) {
        hit = name;
        break;
      }
    }
    if (hit) {
      const id = index.surfaceToId.get(hit);
      if (!found.has(id)) found.set(id, { id, name: hit });
      i += hit.length;
    } else {
      i += 1;
    }
  }
  return [...found.values()];
}

function addUndirectedEdge(adj, a, b, chunkId) {
  if (a === b) return;
  const [x, y] = a < b ? [a, b] : [b, a];
  const key = `${x}|${y}`;
  if (!adj.has(key)) {
    adj.set(key, { source: x, target: y, evidence: new Set() });
  }
  adj.get(key).evidence.add(chunkId);
}

function weave(docs, table) {
  const index = buildAliasIndex(table);
  const chunkText = {};
  const chunkEntities = {};
  const adj = new Map();

  for (const doc of docs) {
    chunkText[doc.chunkId] = doc.text;
    const ents = extractEntities(doc.text, index);
    chunkEntities[doc.chunkId] = ents.map((e) => e.id);
    for (let i = 0; i < ents.length; i += 1) {
      for (let j = i + 1; j < ents.length; j += 1) {
        addUndirectedEdge(adj, ents[i].id, ents[j].id, doc.chunkId);
      }
    }
  }

  const edges = [...adj.values()].map((e) => ({
    source: e.source,
    target: e.target,
    weight: e.evidence.size,
    evidence: [...e.evidence],
  }));

  return {
    version: 1,
    parent: null,
    aliasTable: table,
    chunkText,
    chunkEntities,
    edges,
  };
}

function applyDeletes(graph, deletedChunkIds) {
  const deleted = new Set(deletedChunkIds);
  const chunkText = { ...graph.chunkText };
  const chunkEntities = { ...graph.chunkEntities };
  for (const id of deleted) {
    delete chunkText[id];
    delete chunkEntities[id];
  }
  const edges = [];
  for (const e of graph.edges) {
    const evidence = e.evidence.filter((id) => !deleted.has(id));
    if (evidence.length === 0) continue;
    edges.push({ ...e, evidence, weight: evidence.length });
  }
  return {
    ...graph,
    version: graph.version + 1,
    parent: graph.version,
    chunkText,
    chunkEntities,
    edges,
  };
}

function saveSnapshot(graph) {
  mkdirSync(dataDir, { recursive: true });
  const file = `graph-v${graph.version}.json`;
  writeFileSync(join(dataDir, file), JSON.stringify(graph, null, 2), "utf8");
  writeFileSync(
    join(dataDir, "current.json"),
    JSON.stringify({ version: graph.version, file }, null, 2),
    "utf8",
  );
  return file;
}

function loadCurrent() {
  const ptr = JSON.parse(readFileSync(join(dataDir, "current.json"), "utf8"));
  return JSON.parse(readFileSync(join(dataDir, ptr.file), "utf8"));
}

function rollback(version) {
  const file = `graph-v${version}.json`;
  if (!existsSync(join(dataDir, file))) {
    throw new Error(`missing ${file}`);
  }
  writeFileSync(
    join(dataDir, "current.json"),
    JSON.stringify({ version, file }, null, 2),
    "utf8",
  );
}

function neighbors(graph, entityId, minWeight) {
  const out = [];
  for (const e of graph.edges) {
    if (e.weight < minWeight) continue;
    if (e.source === entityId) out.push(e.target);
    else if (e.target === entityId) out.push(e.source);
  }
  return out;
}

function expand(graph, seedIds, hops = 1, minWeight = 1) {
  let frontier = new Set(seedIds);
  const seen = new Set(seedIds);
  for (let h = 0; h < hops; h += 1) {
    const next = new Set();
    for (const id of frontier) {
      for (const n of neighbors(graph, id, minWeight)) {
        if (!seen.has(n)) {
          seen.add(n);
          next.add(n);
        }
      }
    }
    frontier = next;
  }
  return seen;
}

function chunksForEntities(graph, entityIds) {
  const allow = [];
  for (const [chunkId, ents] of Object.entries(graph.chunkEntities)) {
    if (ents.some((id) => entityIds.has(id))) allow.push(chunkId);
  }
  return allow;
}

function tokenize(text) {
  const units = [];
  let buf = "";
  const flush = () => {
    if (buf) {
      units.push(buf.toLowerCase());
      buf = "";
    }
  };
  for (const ch of text) {
    if (/[a-zA-Z0-9]/.test(ch)) buf += ch;
    else {
      flush();
      if (/[\u4e00-\u9fff]/.test(ch)) units.push(ch);
    }
  }
  flush();
  const grams = [];
  for (let i = 0; i < units.length; i += 1) {
    grams.push(units[i]);
    if (i + 1 < units.length) grams.push(units[i] + units[i + 1]);
  }
  return grams;
}

function bm25(query, chunkIds, chunkText, k1 = 1.2, b = 0.75) {
  const docs = chunkIds.map((id) => ({
    id,
    tokens: tokenize(chunkText[id] || ""),
  }));
  const nDocs = docs.length || 1;
  const avgdl = docs.reduce((sum, d) => sum + d.tokens.length, 0) / nDocs;
  const df = new Map();
  for (const d of docs) {
    for (const t of new Set(d.tokens)) {
      df.set(t, (df.get(t) || 0) + 1);
    }
  }
  const qTokens = tokenize(query);
  const scored = docs.map((d) => {
    const tfMap = new Map();
    for (const t of d.tokens) tfMap.set(t, (tfMap.get(t) || 0) + 1);
    let score = 0;
    const dl = d.tokens.length || 1;
    for (const t of qTokens) {
      const tf = tfMap.get(t) || 0;
      if (!tf) continue;
      const n = df.get(t) || 0;
      const idf = Math.log((nDocs - n + 0.5) / (n + 0.5) + 1);
      score +=
        idf *
        ((tf * (k1 + 1)) / (tf + k1 * (1 - b + b * (dl / avgdl))));
    }
    return { id: d.id, score: Number(score.toFixed(4)) };
  });
  scored.sort((a, b) => b.score - a.score);
  return scored;
}

function constrainedRecall(graph, query, hops = 1) {
  const index = buildAliasIndex(graph.aliasTable);
  const seeds = extractEntities(query, index).map((e) => e.id);
  const expanded = expand(graph, seeds, hops, 1);
  const allowed = chunksForEntities(graph, expanded);
  const ranked = bm25(query, allowed, graph.chunkText);
  return { seeds, expanded: [...expanded], allowed, ranked };
}

const v1 = weave(documents, aliasTable);
saveSnapshot(v1);

const query = "徐康后来和哪条产品线有关";
console.log("v1 recall", constrainedRecall(v1, query, 1));

const v2 = applyDeletes(v1, ["c3"]);
saveSnapshot(v2);
rollback(1);
console.log("current pointer", loadCurrent().version);

语料很小,示例里边的最低权重取 1;真实库应提高到 2 或 3,避免一次误抽把两个无关实体焊死。跑完后 ./data/graph-v1.json 与 current.json 会留下,下一次在线服务只读指针,不现场织图。

在线:子图收窄后再检索

查询侧必须用同一张别名表。问句里有多个实体时,先取并集再向外扩,避免只命中中心节点、漏掉桥接文档。跳数默认 1,最多 2:再往外走,候选会迅速接近全库,约束等于没做。收窄规则是「扩展后的实体集合」映射回切片,而不是要求查询里的所有实体出现在同一篇文档——后者会直接把多跳打回单跳。

上面的例子里,问句只抽出 徐康。一跳后进入 林舟 与 支付迁移,于是 c1、c2 进入候选,c3 被丢掉。这正是约束召回要的效果:桥接人留下,无关的发票设计离开集合。随后 BM25 只在允许的切片上打分。中文用单字加邻接二元,不依赖外部分词器;若已有词法索引,把 allowed 当成 filter 即可,不必换打分公式。

向量检索走同一条路径:子图产出 chunkId,再交给现有索引做 ID 过滤。不要在当次请求里为了「补关系」去把过滤前的长尾切片塞进模型。生成阶段需要通读时,读的应是约束之后的前几条,而不是用通读来发现该读哪篇。

评测不要看模型是否把故事讲圆。固定图版本与查询集,对比「全库 BM25」和「子图约束后 BM25」的命中切片、漏召回和空结果率。约束过死会丢桥接文档,过松则退回全库;跳数、最低权重、是否用查询实体并集,都应变成可调参数,而不是写死在提示词里。

抽错、文档变更与回滚

词典和模型都会抽错。不要让错误边永久污染图,准备三层补丁。第一,别名表可单独发版:一次错误的字符串对齐,用别名合并或拆分修复,不必重跑全部共现。第二,边必须带证据;删除或改写某篇文档时,从所有边的 evidence 里去掉对应 chunkId,权重改为剩余证据数,证据为空则删边。第三,在线扩跳忽略低权重边,把一次性共现挡在召回之外。

文档变更不要原地改文件。织图任务写成「读旧快照 + 变更集 → 写新快照」:输入是切片的 upsert/delete 列表,输出是 graph-vN.json 和指针。示例里的 applyDeletes 只覆盖删除;更新应先按旧 chunkId 删边,再把新文本当作新切片织进去,避免同一 ID 上残留过期共现。父版本写入快照,是为了出问题时知道从哪一版来。

在线服务只认 current.json。回滚就是改指针,而不是在图数据库里做一堆难以撤销的 DELETE。抽取质量突然下降时,先停织图任务、回滚指针,再修词典。图版本应和向量索引版本对齐,避免「图还认为这篇在、倒排里已经没了」的空窗。任务本身要可重入:同一变更集打两次,应得到同一版结果,而不是边权重被加两次。

若一定要给边加类型(同事、从属、前后任),把它当作可选标注,不要挡在召回主路径上。类型抽错的代价高于共现抽错:前者会让过滤条件在语义上看起来很强,实际上更脆。

总结

多跳召回的稳定性,来自把关系从「当场编」改成「入库时记账」。实体 ID、别名、带证据的共现边、版本化快照,这四件套就够支撑约束召回和回滚。在线路径保持短:抽实体、子图收窄、子集上 BM25 或向量过滤。通读留给最终生成,而不是用来发现该读哪篇文档。先把可回滚的织图任务和固定版本的召回评测跑通,再考虑要不要上更重的关系类型或有向边。