-
Notifications
You must be signed in to change notification settings - Fork 0
RAG
这篇文档讲的是:怎么让 AI 只根据你们自己准备的资料 来回答问题,还尽量少胡说、每句话都能对回原文。
行业里把这件事叫 RAG(Retrieval-Augmented Generation,检索增强生成)——先翻资料,再开口;本仓库里对应的是知识库(KN)问答。
可以把它想成开卷考试:先把课本整理进书架(入库);考试时先把题目读清楚再翻页(检索);最后只依据翻到的段落作答,并标注出处(生成 + 引文)。
在线答题这一截,学界常概括成三步 RRR(Rewrite-Retrieve-Read):先 改写 把口语题变成更好搜的问法,再 检索 去书架捞材料,最后 阅读生成——读材料、写答案。相对更早的「Retrieve-then-Read」(上来就搜),RRR 承认用户原话和检索引擎之间往往有一道缝,与其只改检索器或只改生成模型,不如先把查询本身对齐。本仓库的 kbGraph 正是按这条主轴编排的;后文的精排、兜底、引文校验,都是把 Retrieve / Read 做扎实的加固,而不是另起炉灶。
一次靠谱的知识库回答,至少要过三关:
- 找得全:不漏掉真正相关的段落;
- 答得准:不拿检索结果当借口瞎编;
- 说得清出处:答案里的关键事实能对回原文。
如果做成最简版「向量搜一下 → 把结果塞给大模型 → 让它写答案」,通常不够用——那正是朴素 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。
本地用 Docker 起两个服务:向量书架(Qdrant)和文档翻译官(markitdown)。业务身份、会话等仍在 PostgreSQL 里;知识库检索这件事拆成独立服务,是为了「专柜专用」。
向量检索可以粗想成:每段文字先变成一串数字指纹(embedding),问句也变成指纹,再找「距离最近」的那几段。
这件事当然也可以塞进已有的 PostgreSQL,靠扩展 pgvector 存向量、做近邻搜索。本项目最终仍单独引入 Qdrant,主要出于下面几条:
-
混合检索是一等公民:知识库要同时走「意思像」(
dense)和「字面对得上」(sparse/BM25)。Qdrant原生支持在同一collection里挂稠密向量 + 稀疏向量,并内置BM25;对「语义 + 关键词」这条主路径更省事。 -
为检索而生的
API:按collection/payload过滤、批量upsert、快照备份等,都是检索场景的常用动作,不用硬把SQL拧成向量工作流。 -
和业务库解耦:账号、会话、
checkpoint继续留在Postgres;知识库向量可以独立扩缩、独立清空重建,炸了也不会拖垮事务库。 -
本地一条
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 更贴这条路径。
用户丢进来的材料五花八门:PDF、Word、PPT……直接拿原始二进制去切段,等于每个格式写一套解析器,又脏又容易丢标题结构。
因此引入 MarkItDown(微软开源)做成独立小服务:任何支持的格式先翻成 Markdown,后面的清洗、按标题切块、embedding 只认这一种「中间语」。
引入思路与优点:
-
格式战争交给专人:office /
PDF解析坑很多,自研不如复用成熟工具。 -
中间格式统一:
Markdown既是纯文本又保留标题层级,很适合「标题树 + 滑窗」切chunk。 -
ingest管道变薄:packages/kb的入库链路可以固定为「转 MD →cleaner→chunker→ embed」,不必关心文件后缀。 -
独立容器、按需启动:查询链路其实不用它(只连
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)。
知识库的「算法实验室」都在这里:怎么入库、怎么混合检索、怎么重排、怎么兜底、怎么校验引文。
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 # 包对外导出入口
-
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 |
科普文里几乎都会强调:很多 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组)。'三句话收束:过滤解决「该不该看到」;目录地图解决「先往哪一章找」;预生成问答解决「高频口语有没有捷径」。它们都不替代切块与混合检索,而是叠在离线流水线之上的增益层。
痛点:用户常说「怎么开票」这种省略句——缺主体、缺场景。拿这种原句直接 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: [],
}痛点:
- 只用向量检索:擅长「意思像」,但可能对精确词(如
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,
})痛点:向量召回像「先各自拍照,再比谁更像」——快,但粗糙,容易混进「数字指纹近、读起来却不对题」的段落。
方案:用 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 }痛点:若重排后第一名分数仍低于阈值(例如 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
}痛点:大模型天生爱「补全故事」;若不加约束,很容易写检索材料里没有的情节,而且读者也分不清哪句来自哪段原文。
方案:先把精选片段排成带编号的开卷材料,再在系统提示里写死规矩:
- 只能依据这些片段回答;
- 关键事实末尾用
[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)痛点:模型标的 [n] 也可能是「表演」:
- 编号越界(只有 3 段却写
[5]); - 编号存在,但附近的引号摘录根本不在那段原文里——等于伪造脚注。
方案:答完后再做两道检查:
-
编号存在:
[n]必须对应真实片段; -
摘录可溯:若附近有引号摘录,这段文字必须能在对应
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)渲染。
下面几张图把「文档怎么入库」「问题怎么被找到并精排」「引文怎么回炉」「整条图怎么走」画出来。前一节讲「为什么」,这里讲「流水线长什么样」。
把一份文件变成书架上的可检索卡片:转 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 集合)]
改写后的查询进入双路召回,经 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[]]
生成带 [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 脚注]
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])
从用户点开知识库对话,到屏幕上出现流式文字,大致按下面这条时间线走——仍是 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