Skip to content
kscarrot edited this page Jul 31, 2026 · 2 revisions

RAG

这篇文档讲的是:怎么让 AI 只根据你们自己准备的资料 来回答问题,还尽量少胡说、每句话都能对回原文。

行业里把这件事叫 RAG(Retrieval-Augmented Generation,检索增强生成)——先翻资料,再开口;本仓库里对应的是知识库(KN)问答。

可以把它想成开卷考试:先把课本整理进书架(入库);考试时先把题目读清楚再翻页(检索);最后只依据翻到的段落作答,并标注出处(生成 + 引文)。

在线答题这一截,学界常概括成三步 RRR(Rewrite-Retrieve-Read):先 改写 把口语题变成更好搜的问法,再 检索 去书架捞材料,最后 阅读生成——读材料、写答案。相对更早的「Retrieve-then-Read」(上来就搜),RRR 承认用户原话和检索引擎之间往往有一道缝,与其只改检索器或只改生成模型,不如先把查询本身对齐。本仓库的 kbGraph 正是按这条主轴编排的;后文的精排、兜底、引文校验,都是把 Retrieve / Read 做扎实的加固,而不是另起炉灶。


一、整体链路

一次靠谱的知识库回答,至少要过三关:

  1. 找得全:不漏掉真正相关的段落;
  2. 答得准:不拿检索结果当借口瞎编;
  3. 说得清出处:答案里的关键事实能对回原文。

如果做成最简版「向量搜一下 → 把结果塞给大模型 → 让它写答案」,通常不够用——那正是朴素 Retrieve-then-Read 的短板。原因很实际:搜索会带回噪声;大模型爱把片段脑补成故事;用户又常问得很口语或缺主语(「怎么开票」——谁开?个人还是公司?)。把改写单独拉出来,整条链路才变成流畅的 RRR:题意先对齐,再翻书,再答题。

网上多数科普还会先画清两条流水线——本仓库也是同一套。离线 / 入库负责整理课本:转格式、切段、算指纹、放进书架,对应 ingest/*、Qdrant 与 markitdown;在线 / 问答才走 RRR 开卷:改写、检索、精排、生成、再校验引文,对应 retrieve/* 与 kbGraph。

那为什么用 RAG,而不是去「微调」大模型把资料背进权重里?资料天天变时,重训太贵太慢,向量库只要重新 ingest;需要出处时,RAG 手里还拿着「翻到的那几页」,微调后的参数很难指着某一段原文说「因为这里」;企业私有制度、产品说明也不必硬塞进通用模型。微调更适合改「说话口吻、固定格式、领域术语习惯」这类行为风格;「今天这份 PDF 里写了什么」仍然更适合 RAG。

所以本系统把在线阶段落成下面这条工序——左侧是 RRR 骨架,右侧是落地加固:

用户问题
  → 查询改写(rewrite)           【Rewrite】先把题意说清楚
  → 混合召回(retrieve)           【Retrieve】语义 + 关键词两路找材料
  → 重排(rerank)                【Retrieve】仔细挑出最相关的几段
  → 兜底判断(fallback)           【Retrieve】材料太差就拒答/追问/扩大搜索
  → 生成(generate)              【Read】只根据选中片段作答,并标 [n]
  → 引文校验(validateCitations) 【Read】核对 [n] 是否真的靠谱
  → 流式回复 + 引文事件

为什么改写要放在召回前、兜底要放在生成前?

  • 这正是 RRR 相对 Retrieve-then-Read 的核心主张:检索对「问句质量」极敏感,原问缺关键信息时,翻到的页必然容易偏;
  • 就算搜完又重排了,仍可能没有任何一段够相关——这时硬进入 Read,最容易出现「读起来通顺、其实跑题」的答案。兜底拦在生成之前,等于 Read 开卷前多验一次「材料够不够硬」。

二、目录结构

代码按「职责分房间」摆放:基础设施负责存与转格式;packages/kb 是检索算法库;图编排、HTTP API、前端注入分散在 graph / server / client。

2.1 infra

本地用 Docker 起两个服务:向量书架(Qdrant)和文档翻译官(markitdown)。业务身份、会话等仍在 PostgreSQL 里;知识库检索这件事拆成独立服务,是为了「专柜专用」。

Qdrant:为什么要单独一座「段落书架」

向量检索可以粗想成:每段文字先变成一串数字指纹(embedding),问句也变成指纹,再找「距离最近」的那几段。

这件事当然也可以塞进已有的 PostgreSQL,靠扩展 pgvector 存向量、做近邻搜索。本项目最终仍单独引入 Qdrant,主要出于下面几条:

  1. 混合检索是一等公民:知识库要同时走「意思像」(dense)和「字面对得上」(sparse / BM25)。Qdrant 原生支持在同一 collection 里挂稠密向量 + 稀疏向量,并内置 BM25;对「语义 + 关键词」这条主路径更省事。
  2. 为检索而生的 API:按 collection / payload 过滤、批量 upsert、快照备份等,都是检索场景的常用动作,不用硬把 SQL 拧成向量工作流。
  3. 和业务库解耦:账号、会话、checkpoint 继续留在 Postgres;知识库向量可以独立扩缩、独立清空重建,炸了也不会拖垮事务库。
  4. 本地一条 docker compose 就能起:开发机与 devops 脚本统一按服务启停(pnpm devops infra up kb)。

和 pgvector 怎么比?(选型对照)

维度 Qdrant(本项目所选) pgvector(Postgres 扩展)
角色 专职向量库 在关系库里「顺便」存向量
混合检索 dense + sparse / BM25 原生好用 稠密向量成熟;稀疏 / BM25 往往要自建或外挂全文检索
运维心智 多一个容器,但边界清晰 少一个组件,数据和业务表挤在一起
适合场景 RAG、大规模近邻、独立扩缩 向量量不大、强事务一致性、想极简栈
本仓库现状 KB 检索走 Qdrant Postgres 已用于 auth / 会话等,不再叠 pgvector,避免一把梭

一句话:不是 pgvector不行,而是本项目的目标是「语义 + 关键词」混合 RAG,再加「检索与业务库分开」——Qdrant 更贴这条路径。

markitdown:为什么入库前先统一成 Markdown

用户丢进来的材料五花八门:PDF、Word、PPT……直接拿原始二进制去切段,等于每个格式写一套解析器,又脏又容易丢标题结构。

因此引入 MarkItDown(微软开源)做成独立小服务:任何支持的格式先翻成 Markdown,后面的清洗、按标题切块、embedding 只认这一种「中间语」。

引入思路与优点:

  1. 格式战争交给专人:office / PDF 解析坑很多,自研不如复用成熟工具。
  2. 中间格式统一:Markdown 既是纯文本又保留标题层级,很适合「标题树 + 滑窗」切 chunk。
  3. ingest 管道变薄:packages/kb 的入库链路可以固定为「转 MD → cleaner → chunker → embed」,不必关心文件后缀。
  4. 独立容器、按需启动:查询链路其实不用它(只连 Qdrant + embedding + rerank);只有导入文档时才依赖 markitdown,职责干净。
infra/
├── qdrant/
│   ├── docker-compose.yml      # qdrant:latest,6333/6334
│   ├── .env.example
│   ├── qdrant_data/            # 持久化(gitignore)
│   ├── snapshots/              # 快照挂载点
│   └── README.md
└── markitdown/
    ├── docker-compose.yml      # 端口 8200
    ├── Dockerfile + server.py  # GET /health,POST /convert
    ├── .env.example
    └── README.md

统一启停:pnpm devops infra up kb(同时起 qdrant + markitdown)。

2.2 packages/kb

知识库的「算法实验室」都在这里:怎么入库、怎么混合检索、怎么重排、怎么兜底、怎么校验引文。

packages/kb/src/
├── qdrant/           # `Qdrant` 客户端与集合约定(建库、`upsert`、按 id 查询)
├── embedding/        # 稠密向量编码(硅基流动 `BGE-M3`,把文本变成指纹)
├── rerank/           # 精排客户端 + 低分兜底决策(reject / clarify / retry_wider)
├── sparse/           # 稀疏检索抽象:默认 `BM25`,预留 `BGE-M3` learned `sparse`
├── ingest/           # 入库流水线:转 MD → 清洗 → 切段 →(可选摘要)→ 写入向量库
├── retrieve/         # 问答检索侧:改写、混合召回、重排封装、引文构建与校验
├── utils/            # 图内辅助 `LLM` 调用(原生 `fetch`,避免中间结果泄漏到前端流)
├── types.ts          # 知识库公共类型(`chunk`、引文等)
└── index.ts          # 包对外导出入口

2.3 graph / server / client

  • graph:把上面能力串成一条可运行的对话图(kbGraph)
  • server:对外提供导入 / 查询等 HTTP 接口,并把图挂到 agent
  • client:对话里注入当前知识库 id(kbId)
packages/graph/src/
├── kbGraph.ts              # `rewrite` → `retrieve` → `generate`
└── kbGraph.test.ts

packages/proto/src/
└── kb.ts

apps/server/src/
├── routes/kb.ts            # /kb/ingest, /ingest/path, /query, /manage
├── handlers/kb.ts
├── service/kb.ts
└── agent/graphAgents.ts    # kb agent + resolveConfigurable(kbId)

apps/client/src/components/copilot/
├── ConversationChat.tsx    # kb 时挂载 KbAgentState;禁用 Copilot 复制按钮
└── KbAgentState.tsx        # 注入 agent state.kbId(默认 kb_default)

三、核心环节:动机、对比与实现

把在线链路记成 RRR 最省事:Rewrite → Retrieve → Read。下表只作导航——跳到哪一节,源码摘录就在那一节里;离线的切块增强不在三拍内,但决定后两拍能翻到什么。

RRR 环节 详见
(离线) 切块 / 入库与增强 §3.0
Rewrite 查询改写 §3.1
Retrieve 混合召回 §3.2
Retrieve 重排 §3.3
Retrieve 兜底判断 §3.4
Read 生成 §3.5
Read 引文校验 §3.6

3.0 入库切块(Chunking)——离线侧最容易被低估的一步

科普文里几乎都会强调:很多 RAG「答得差」,根因不在模型,而在切块切坏了。切太大,一段里塞进多个主题,指纹会糊成「平均值」;切太碎,一句话丢掉前后语境,检索能命中却答不全。

固定长度「每 N 字一切」最省事,也最容易砍断语义,本仓库只把它当作窗口上限(maxChars)。真正的主策略是先跟着 Markdown 大纲走:按 # 标题拆成 section,让每一块尽量落在同一章节里;section 太长时再在体内做滑动窗口,并留默认 120 字 overlap,免得关键句刚好卡在两窗的接缝处丢一半。默认约 800 字一块,量级接近科普常提的「几百 token + 10%~20% 重叠」,切出来的每块还带上 heading_path,生成答案时才能标出「财务 > 发票」这类面包屑。语义切分或 Parent-Child(小块检索、大块喂给模型)更精,也更贵,当前没做,留给后续演进。

// packages/kb/src/ingest/chunker.ts(节选)
export function chunkMarkdown(markdown: string, options: ChunkerOptions): KbChunk[] {
  const maxChars = options.maxChars ?? 800
  const overlapChars = options.overlapChars ?? 120
  const sections = splitByHeadings(markdown) // 先沿 # 标题拆开
  // 每个 section 体内再 slidingWindows(maxChars, overlapChars)
}

切好块之后,还可以叠三种常见优化

只靠「切段 + 向量」有时仍不够。离线侧常会再叠三层增强——本仓库对它们的支持程度并不一样,但思路都值得单独讲清。

1. 先过滤再检索:用标签、权限与时间框定范围

向量相似度不管「这份材料你有没有权限看」「问的是今年政策还是去年旧规」。给每个 chunk / 文档挂上标签、归属(owner)、虚拟目录(vdir),以及导入或生效时间,检索时先按元数据收窄范围,再在剩余集合里做语义 / 关键词召回——像进图书馆先刷门禁、再按架号找书,而不是全馆地毯式翻。本仓库 ingest 已接受 tags / vdir / owner,Qdrant 的 payload 也建了对应索引字段;时间戳过滤与前端按标签筛库仍偏弱,是自然的演进方向。

2. 先看目录再下钻:给长文一张带篇幅估计的地图

长文若只存碎块,模型(或路由层)缺少一张「全局地图」。一种做法是:沿着 Markdown 目录树生成紧凑索引——每个标题节点记下本节大约占用多少 token(或字符),整张 TOC 本身也很短,可以常驻在上下文或先检索 TOC 再决定下钻哪一章。这相当于给书架做目录卡:先看目录估计篇幅与位置,再翻具体页,避免一上来就把几百个 chunk 全塞进 prompt。本仓库切块时已保留 heading_path,enricher 也会产出文档级 toc 字符串列表;把各节 token 量写进 TOC 节点、并在检索前先查目录,则是在此之上的完整形态,尚未做成独立模块。

3. 预生成高频问答:把口语问法做成检索捷径

许多用户问题本就是「这份说明里反复被问到的事」。入库时让 LLM 根据正文提炼若干组问答(通常 0~3 条就够),可作为单独可检索条目写入向量库,或只存成元数据供改写 / 兜底时参考。好处是:问法贴近真实口语,命中路径比硬啃政策长句更短。本仓库的 enrichDocument 已按此设计生成 faq(外加 summary / keywords / toc);E2E、seed 常设 skipEnrich: true,避免种子阶段狂烧 token——线上正式导入再打开 enrich 即可。

// packages/kb/src/ingest/enricher.ts(节选)
// 输出 JSON:summary / keywords / toc / faq[]
'根据文档内容生成:语义摘要、关键词(5-10个)、目录条目、FAQ(0-3组)。'

三句话收束:过滤解决「该不该看到」;目录地图解决「先往哪一章找」;预生成问答解决「高频口语有没有捷径」。它们都不替代切块与混合检索,而是叠在离线流水线之上的增益层。

3.1 查询改写(rewrite)

痛点:用户常说「怎么开票」这种省略句——缺主体、缺场景。拿这种原句直接 Retrieve,书架上相关页很容易被盖过;问句和知识库用语之间的缝,正是 RRR 要先 Rewrite 的理由。

方案:先让大模型把问题改写成「更适合检索」的说法,并且 最多 2 条(含原问)。规矩很严:

  • 优先只保留 1 条、不改原意;
  • 仅当明显缺实体 / 时间时,才补 1 条;
  • 绝不擅自发明用户没说过的细节(比如乱加「税务 UKey」「手机 App」)。

对比:为什么不一口气生成 4~5 条查询?

多条查询可以减少漏检,但每多一条,就要多跑一遍「编码 → 检索 → 重排」,钱和时间都近似线性涨;乱补的查询还会带进噪声。实践里 1~2 条对多数「缺一点点信息」的问题已经够用。

源码里用 Zod 把模型输出卡死在 queries: string[](长度 1~2),失败则回退成原问;最后再强制把原问并进结果,并截断到 2 条——即使模型「好心多写」,也不会越界:

// packages/kb/src/retrieve/queryRewrite.ts
const RewriteSchema = z.object({
  queries: z.array(z.string()).min(1).max(2),
})

export async function rewriteQuery(userQuery: string): Promise<string[]> {
  const trimmed = userQuery.trim()
  if (!trimmed)
    return []

  const parsed = await chatCompletionJson({
    system: [
      '你是知识库查询改写助手。',
      '保留用户原意,优先输出 1 条查询;仅在问题明显缺实体/时间时最多补 1 条。',
      '不要臆造用户未提及的场景(如税务 UKey、手机 App 等)。',
      '仅输出 JSON:{"queries":["查询1"]}',
    ].join('\n'),
    user: trimmed,
    schema: RewriteSchema,
    fallback: { queries: [trimmed] },
  })

  const queries = parsed.queries.map(q => q.trim()).filter(Boolean)
  const unique = [...new Set([trimmed, ...queries])]
  return unique.slice(0, 2)
}

图里的 rewrite 节点只是薄封装:读出用户最后一条消息 → 调 rewriteQuery → 并把重试计数等状态清零:

// packages/graph/src/kbGraph.ts — rewriteNode
const rewrittenQueries = await rewriteQuery(userQuery)
return {
  rewrittenQueries,
  routeRejected: false,
  citationRetries: 0,
  retrievedChunks: [],
  citations: [],
}

3.2 混合召回(retrieve)

痛点:

  • 只用向量检索:擅长「意思像」,但可能对精确词(如 SKU-9001)发飘;
  • 只用关键词:擅长「字面对得上」,但不懂「退款」和「退货」其实很近。

方案:两路并行,再合成一份总榜。

  • dense(稠密向量):把问句用 BGE-M3 编成数字指纹,在 Qdrant 里找「语义邻居」——像按意思找相似段落。
  • sparse(稀疏 / BM25):按关键词打分(Qdrant 内置 BM25)——像传统搜索引擎抓专有名词。
  • RRF(Reciprocal Rank Fusion)融合:看每条材料在两份榜单里的名次,贡献分 1/(k + rank)(k = 60),再求和。两路都出现的段落通常会往前冲——互补,而不是互斥。

取舍(为什么没用「更理想」的 learned sparse):

BGE-M3 模型本身能同时产出 dense + sparse。但当前使用的 硅基流动 /v1/embeddings 标准接口只返回 dense。因此 sparse 通道改走 Qdrant 内置 BM25(不用再起模型服务),并用可替换的 SparseProvider 接口留出将来切回 learned sparse 的口子(默认 QdrantBm25Provider,BgeM3SparseProvider 仍是 stub)。代价大致是:标准中文长文召回差约 5~10%(经 RRF 后通常 <5%),可接受。

为什么 RRF 的 k 取 60? 这是 RRF 论文的常用默认值:k 越大,名次差距对分数的影响越温和(更强调「有没有出现」,而不是「具体第几名」)。这里沿用常识默认,不是本项目单独炼丹出的魔法数字。

并行双路 + RRF 融合,就在 hybridRetrieve:

// packages/kb/src/retrieve/hybridRetriever.ts
const RRF_K = 60

export async function hybridRetrieve(options: HybridRetrieveOptions) {
  const recallK = options.recallK ?? env.KB_RECALL_K
  const sparseProvider = options.sparseProvider ?? defaultSparseProvider

  const [denseVector, sparseHits] = await Promise.all([
    embedQuery(options.query), // BGE-M3 → dense 指纹
    sparseProvider.search({    // 默认 Qdrant BM25
      kbId: options.kbId,
      query: options.query,
      limit: recallK,
    }),
  ])

  const denseHits = await denseSearch(options.kbId, denseVector, recallK)
  return rrfFusion([denseHits, sparseHits], recallK)
}

export function rrfFusion(rankedLists: RetrievedChunk[][], topK: number, k = RRF_K) {
  const scores = new Map<string, { chunk: RetrievedChunk, score: number }>()
  for (const list of rankedLists) {
    list.forEach((chunk, index) => {
      const rank = chunk.rank ?? index + 1
      const contribution = 1 / (k + rank) // 名次越前,加分越多
      const key = `${chunk.source_doc_id}:${chunk.chunk_id}`
      // …累加 contribution,再按总分排序截断 topK
    })
  }
  // …
}

sparse 一侧没有自建倒排索引,而是直接让 Qdrant 用内置 qdrant/bm25 模型查:

// packages/kb/src/sparse/QdrantBm25Provider.ts
const result = await client.query(collectionName, {
  query: {
    text: options.query,
    model: 'qdrant/bm25',
  },
  using: SPARSE_VECTOR_NAME,
  limit: options.limit,
  with_payload: true,
})

3.3 重排(rerank)

痛点:向量召回像「先各自拍照,再比谁更像」——快,但粗糙,容易混进「数字指纹近、读起来却不对题」的段落。

方案:用 Cross-Encoder(本项目用 BGE-Reranker-v2-m3)把 问句和候选段落拼在一起 再打相关性分,留下 TopN(例如 5 条)。

对比:为什么不一开始就全库用 Cross-Encoder?

因为那是「逐对细看」:有 N 段就要推理 N 次,慢且贵。向量检索则是「问句编码一次 + 大量快速比对」。所以流水线是两段式:

双塔粗筛几十条 → Cross-Encoder 精排 TopN

数字怎么定,比「挑模型」更常被人忽视。本仓库默认 KB_RECALL_K = 20:混合召回先放宽一点,宁可多捞几张「可能有用」的卡片;再交给精排压到 KB_RERANK_TOPK = 5,只把真正相关的少量段落送进生成提示。粗筛太窄容易漏真材料,精排后仍塞十几二十段,则成本、延迟一起涨,答案质量还不一定更好。

更关键的是模型读长上下文时的偏见:头尾更显眼,中间容易被糊过去——业界把这种现象叫 Lost in the Middle。把弱相关片段硬塞进 prompt,不仅增加噪声,还可能把真正该引的句子挤到「中间无人区」,模型读完仍像没看见。所以精排截断不只是为了省钱,更是在主动控窗口、把证据摆到模型更愿意认真读的位置;「宁可五段硬通货,不要二十段大杂烩」。

真正打分走硅基流动的 /v1/rerank:

// packages/kb/src/rerank/client.ts
export async function rerankDocuments(
  query: string,
  documents: RerankDocument[],
  topK = env.KB_RERANK_TOPK,
) {
  const response = await fetch(`${baseUrl}/v1/rerank`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      model, // 默认 BGE-Reranker-v2-m3
      query,
      documents: documents.map(doc => doc.text),
      top_n: Math.min(topK, documents.length),
      return_documents: false,
    }),
  })
  // …把 results 映射成 { id, relevance_score }[]
}

对外真正被图节点调用的是一层胶水 retrieveAndRerank:先 hybridRetrieve,再 rerankDocuments,最后按阈值决定要不要走兜底:

// packages/kb/src/retrieve/reranker.ts(节选)
const recalled = await hybridRetrieve({ kbId, query, recallK })
const reranked = await rerankDocuments(
  query,
  recalled.map(chunk => ({
    id: `${chunk.source_doc_id}:${chunk.chunk_id}`,
    text: chunk.raw_text,
  })),
  env.KB_RERANK_TOPK,
)
// …按 rerank 结果重排 topChunks …
const top1Score = topChunks[0]?.rerank_score ?? 0
if (top1Score < env.KB_RERANK_MIN_SCORE) {
  const fallback = await llmFallbackDecision(query, topChunks)
  return { chunks: topChunks, fallback }
}
return { chunks: topChunks }

3.4 兜底判断(fallback)

痛点:若重排后第一名分数仍低于阈值(例如 0.3),等于手里没有「够硬」的材料。硬塞给大模型,它往往会写出听起来很专业、其实跑偏的答案——比老实说「我不知道」更危险。

方案:低分时请大模型从三种策略里选一条:

决策 含义 常见例子
reject 知识库里根本答不了 材料全不沾边 → 拒答
clarify 问题太含糊 「发票」没说清哪一种 → 追问
retry_wider 可能只是搜窄了 把召回数量 recallK × 2 再搜一轮

对比:为什么不是简单的「能答 / 拒答」两档?

很多低分其实来自问法不清;追问一次比直接关门更友好。另一些低分是初始召回窗口太小、真材料被挤在外面——放宽一次检索有时能救回来。三种决策对应三种不同病因:「不能答 / 先问清楚 / 再找找」。

决策函数同样走「原生 fetch + Zod」的 JSON 通道(不进 LangChain 流,避免中间推理闪到用户屏幕上):

// packages/kb/src/rerank/llmFallback.ts
return chatCompletionJson({
  system: [
    '你是知识库检索质量评估助手。',
    '当 rerank 最高分过低时,判断应如何处理用户问题。',
    '仅输出 JSON:{"decision":"reject"|"clarify"|"retry_wider","message":"…"}',
  ].join('\n'),
  user: [
    `用户问题:${query}`,
    `top1 rerank 分数:${topChunks[0]?.rerank_score ?? 0}(阈值 ${env.KB_RERANK_MIN_SCORE})`,
    '候选片段预览:',
    contextPreview || '(无)',
  ].join('\n'),
  schema: FallbackSchema,
  fallback: {
    decision: 'reject',
    message: '知识库中未找到足够相关的内容,请换个问法或补充更多背景。',
  },
})

图侧看到 retry_wider 会立刻再跑一轮「双倍 recallK」;看到 clarify 则直接变成一条助手消息并结束:

// packages/graph/src/kbGraph.ts — mergeRetrieveResult(节选)
const result = await retrieveAndRerank(kbId, query)
if (result.fallback?.decision === 'clarify')
  return { clarifyMessage: result.fallback.message }

if (result.fallback?.decision === 'retry_wider') {
  const wider = await retrieveAndRerank(kbId, query, { widerRecall: true })
  // …把 wider.chunks 并入 chunkMap
}

3.5 生成(generate)

痛点:大模型天生爱「补全故事」;若不加约束,很容易写检索材料里没有的情节,而且读者也分不清哪句来自哪段原文。

方案:先把精选片段排成带编号的开卷材料,再在系统提示里写死规矩:

  • 只能依据这些片段回答;
  • 关键事实末尾用 [n] 标出处;
  • 片段里没有的内容不许编。

先把 chunks 编成「带脚注的试卷」:

// packages/kb/src/retrieve/citation.ts
export function buildContextFromChunks(chunks: RetrievedChunk[]): string {
  return chunks
    .map((chunk, index) => {
      const heading = chunk.heading_path.length
        ? chunk.heading_path.join(' > ')
        : '正文'
      return `[${index + 1}] (${heading})\n${chunk.raw_text}`
    })
    .join('\n\n')
}

再由 kbGraph 的 generateNode 拼系统提示并 invoke(流式由上层 streamEvents 推给前端):

// packages/graph/src/kbGraph.ts — generateNode(节选)
const context = buildContextFromChunks(state.retrievedChunks)
const messages = [
  new SystemMessage([
    '你是企业知识库问答助手。仅根据下方检索片段回答用户问题。',
    '回答末尾对关键事实使用 [n] 标注引用,n 为片段编号。',
    '不要编造检索片段中不存在的信息。',
    '',
    '检索片段:',
    context,
  ].join('\n')),
  new HumanMessage(userQuery),
]
// 若上一轮引文失败,会再塞一条 HumanMessage(correctionPrompt)
const response = await llm.invoke(messages)
const validation = validateCitations(getAIMessageContent(response), state.retrievedChunks)

3.6 引文校验(validateCitations)

痛点:模型标的 [n] 也可能是「表演」:

  • 编号越界(只有 3 段却写 [5]);
  • 编号存在,但附近的引号摘录根本不在那段原文里——等于伪造脚注。

方案:答完后再做两道检查:

  1. 编号存在:[n] 必须对应真实片段;
  2. 摘录可溯:若附近有引号摘录,这段文字必须能在对应 chunk 里找到。

不过关就生成纠错提示让模型重答(最多 2 次);还不过就拒答。

对比:为什么不能只检查「编号在不在」?

编号合法 ≠ 内容诚实。模型完全可能写「[1] 本公司支持 30 天退款」,而 [1] 原文其实是「7 天」——脚注编号对了,事实仍是编的。摘录可溯就是专门抓这种造假。

校验核心如下:先扫所有 [n],再尝试从「“摘录”[n]」一类写法里抠引号内容,并做去空白后的包含判定:

// packages/kb/src/retrieve/citation.ts(节选)
export function validateCitations(answer: string, chunks: RetrievedChunk[]) {
  const indices = parseCitationIndices(answer) // 正则扫 [n]
  const citations: KbCitation[] = []
  const invalidIndices: number[] = []

  for (const index of indices) {
    const chunk = chunks[index - 1]
    if (!chunk) {
      invalidIndices.push(index)
      continue
    }
    const excerpt = extractQuotedExcerpt(answer, index)
    if (excerpt && !isExcerptInChunk(excerpt, chunk.raw_text)) {
      invalidIndices.push(index)
      continue
    }
    citations.push({ index, chunk_id: chunk.chunk_id, /* … */ })
  }

  if (invalidIndices.length) {
    return {
      ok: false,
      citations,
      invalidIndices,
      correctionPrompt: [
        '你上一版答案中的引用编号无效或与检索片段不符。',
        `无效引用:${invalidIndices.map(i => `[${i}]`).join(', ')}`,
        '请仅基于给定 context 重答,引用必须使用 [n] 格式且内容必须来自对应片段。',
      ].join('\n'),
    }
  }
  return { ok: true, citations, invalidIndices: [] }
}

图节点把「校验失败 → 回灌纠错消息 → 再 generate」做成条件边;通过后把 [n] 写成 GFM 脚注写入 AIMessage(不再发 kb_citations CUSTOM):

// packages/graph/src/nodes/kb/generate.ts(节选)
if (!validation.ok && state.citationRetries < MAX_CITATION_RETRIES) {
  return {
    messages: [new HumanMessage(validation.correctionPrompt ?? '请修正引用后重答。')],
    citationRetries: state.citationRetries + 1,
  }
}

const markdown = answerWithMarkdownFootnotes(answer, validation.citations)
return {
  messages: [new AIMessage({ content: markdown })],
  citations: validation.citations,
}

澄清分支(retrieve / kb_search)用 formatClarifyMarkdown 产出 Markdown,由客户端 @agent/markdown(含 marked-footnote)渲染。


四、流程实现

下面几张图把「文档怎么入库」「问题怎么被找到并精排」「引文怎么回炉」「整条图怎么走」画出来。前一节讲「为什么」,这里讲「流水线长什么样」。

4.1 ingest(入库)

把一份文件变成书架上的可检索卡片:转 Markdown → 清洗 → 按内容哈希判断要不要重建 → 切段 →(可选)摘要 enrichment → 向量化写入 Qdrant。

flowchart TD
    A[上传 / ingest/path] --> B[markitdown: 转 markdown]
    B --> C[cleaner: 链接/去噪]
    C --> D{content_hash 比对}
    D -->|未变更| Z[跳过]
    D -->|变更| E[删旧 chunks]
    E --> F[chunker: 标题树+滑窗]
    F --> G{skipEnrich?}
    G -->|否| H[enricher: LLM 摘要/关键词/FAQ]
    G -->|是| I[embedding + upsert]
    H --> I
    I --> J[(Qdrant kbId 集合)]
Loading

4.2 retrieve(检索 + 重排 + 兜底)

改写后的查询进入双路召回,经 RRF 合并后再精排;若第一名仍太弱,走 fallback。

flowchart TD
    Q[rewritten queries] --> ED[embedding dense]
    Q --> BM[Qdrant BM25 sparse]
    ED --> SD[dense search]
    BM --> SB[sparse search]
    SD --> RRF[RRF k=60]
    SB --> RRF
    RRF --> CE[硅基流动 rerank]
    CE --> TOP[top KB_RERANK_TOPK]
    TOP --> CK{top1 >= MIN_SCORE?}
    CK -->|否| FB[llmFallback: reject/clarify/retry_wider]
    CK -->|是| OUT[RetrievedChunk[]]
Loading

4.3 citation(引文闭环)

生成带 [n] 的答案 → 校验 → 失败则纠错重试 → 成功则带上引文事件结束。

flowchart TD
    GEN[generate: LLM 带 n 引用] --> PARSE[validateCitations]
    PARSE --> CV{引用 ∈ context?}
    CV -->|否, 未超重试| FIX[HumanMessage 纠错 prompt]
    FIX --> GEN
    CV -->|否, 超限| FAIL[AIMessage 失败提示]
    CV -->|是| OK[AIMessage + Markdown 脚注]
Loading

4.4 kbGraph(整图编排)

kbGraph 一眼看上去就是 RRR:rewrite → retrieve → generate。Retrieve 阶段若已拒答 / 追问则直接结束(还没进入 Read);进入生成后,引文校验循环仍属 Read 的收尾。

flowchart TD
    S([START]) --> RW[rewrite: queryRewrite fetch]
    RW --> RET[retrieve: retrieveAndRerank × N 查询]
    RET -->|routeRejected| E1([END 拒答/未找到])
    RET -->|有 chunks| GEN[generate: ChatOpenAI 流式]
    GEN --> CV{引文校验}
    CV -->|重试| GEN
    CV -->|通过/失败| E2([END])
Loading

五、端到端时序

从用户点开知识库对话,到屏幕上出现流式文字,大致按下面这条时间线走——仍是 RRR:先 Rewrite,再循环 Retrieve(含 rerank / fallback),最后 Read(流式生成 + 引文事件)。可以把 Client / Server / LangGraph 理解成「前台点菜 → 后厨总控 → 各工位出锅」。

sequenceDiagram
    autonumber
    participant U as `Client`
    participant S as `Server`
    participant G as LangGraph
    participant RW as Rewrite
    participant RR as Rerank
    participant E as Embedding
    participant Q as Qdrant
    participant L as ChatOpenAI

    U->>S: agent kb run + state.kbId
    S->>G: streamEvents v3 + configurable.kbId
    G->>RW: rewrite
    RW-->>G: ≤2 条查询(含原问)
    loop 每条查询
        G->>RR: retrieveAndRerank(kbId, query)
        RR->>E: embed + rerank
        RR->>Q: dense + BM25
        RR-->>G: chunks / fallback
    end
    alt 无结果 / clarify
        G-->>S: AIMessage(拒答文案)
        S-->>U: 流式文本
    else 有 chunks
        G->>L: generate(stream)
        loop text-delta
            L-->>G: TEXT_MESSAGE_*
            G-->>S: aguiEvents
            S-->>U: CopilotChat 渲染
        end
        G->>G: validateCitations
        G-->>S: AIMessage(Markdown 脚注)
        Note over U: 前端暂未消费引文事件
    end
Loading