返回归档
🏅LLM 与 Agent

从 0 到 1 构建知识库

知识库不是向量库。离线把文档写成可检索的点,在线把问句收成可引用的证据。按 parse、chunk、caption、embed、generation、hybrid、rerank、refuse 走完每一步。

文章目录

向量库只回答「这两组数字近不近」。私有文档不在模型权重里。给 Agent 接文档问答,要先搭一层 knowledge layer:retrieve 之后,把带 citation 的 evidence 写回 context。证据不足就 refuse,不要编。

Function Calling / MCP 决定什么时候开口要一次 retrieve。这篇管 store 里有什么、检索契约是什么。知识库不是 Vector DB。它是两条不同时跑的流水线:

  • 离线写入:文档 → 可检索的 point
  • 在线检索:问句 → 可引用的 evidence

共享的只有已经 commit 的 active generation。写入看吞吐和能不能重试;检索看 p99 和 citation 对不对。焊进同一条请求路径,写入的 retry 会拖死问答的尾延迟。

两条流水线

离线写入文档,在线检索问句,中间只共享 active generation

写入(离线) 检索(在线)
节奏 异步任务,行锁认领 同步,延迟打在问答路径上
看什么 吞吐、单价、能否重试 尾延迟、能否降级、引用对不对
失败 半成品不可见,旧版继续服务 精排挂了按召回分排,低分就拒
存储 写出点、原文、版本指针 只读点和指针

存储上是三块,观测围在外面,不拥有「现在对外是哪一版」:

Point 活在向量库、任务库、对象存储

  • 向量库:active 可检索的知识(child 有向量,parent 无向量)
  • 任务库:入库状态、unanswered
  • 对象存储:原文和图的永久副本

契约

半年内到不了一百万 point。必须同时接住 paraphrase,和专有名词、编号。写入可以慢,问答不行。更新失败不能让文档消失,半成品不能被问到,答不上来必须认。模型会换,切块契约和版本指针不该跟着倒。

四条尺子量后面所有细节:

  1. 身份可重算。 同一来源、同一位置,永远落到同一个 point。
  2. 失败分两类。 看图、抽词、归档可以降级。身份、写入校验、切 active generation 必须停,留下旧版。
  3. 检索内核不管对话。 不改写问句,不生成答案。paraphrase 交给 Agent。
  4. 观测不拥有线上知识。 Active 是哪一版,向量库里的 active generation 说了算。

级与级不共享内存对象,只认契约:

写入这一级 交出什么
解析 {text, metadata},metadata 里至少有 source_ref
身份 doc_id(来源哈希前 16 位)
归档 原文对象键(失败可空)
切块 child 列表;图块正文仍空
看图 图块正文 + 邻块追加短 caption
组父块 parent(无向量)
编码 point_id 集合、成功 / 失败计数
提交版本 切 active,或整版作废
检索这一级 交出什么
过滤 只要已 commit 的 active child
双路召回 两路各最多 10 条
融合 一份有序名单
精排 重排后的池,不截断
补图 / 展开 / 装箱 给模型看的最终块
门槛 编号化 citation,或交给外壳拒答

组件是这之后的事:Qdrant + HNSW 够这个量级。所有模型进同一网关,业务侧只认四个角色:dense、VLM、rerank、llm。

后面每一步都走同一份假文档。它不是业务材料,只用来卡住三件事:heading 边界、可被 paraphrase 的事实、必须先写成字才能进 dense / sparse 的图。

样例

八行假文档,切完是三个 child。问句永远是「超时是多少」。

# 安装
安装需要三个文件。

# 配置
超时默认 30 秒。
![示意图](...)
child 从哪来 用来验什么
C1 # 安装 那一节 heading 切开,不要和超时揉在一起
C2 # 配置 那一节 问「超时是多少」该打中这里
C3 ![示意图] 图先写成字,否则对 vector 不存在

现在库是空的。问这句什么也搜不到。先 ingest,不要 retrieve。

写入

Parse

来源异构。如果每种来源自己切、自己 embed,后面的更新和 citation 会各写一套。Parser 只交 {text, metadata}text 统一成 markdown(heading、列表、代码块、![](url) 都还在原文位置)。metadata 至少有 source_ref,它是 doc_id 的种子。图先以链接留在正文里,到切块再拆成独立 child。

身份分三层,全部可重算,不含随机:

  1. doc_id:对 source_ref 做 SHA-256,取 hex 前 16 个字符。没有来源引用时才退回内容哈希——这一路更新无法幂等,只当兜底。
  2. chunk_id:只由位置决定,doc:type:parent:seq
  3. point_id:由 chunk_id 派生。同一 doc_id 的新 generation 再叠一层,避免新旧点撞主键。

内容变没变,另用 content hash 判断要不要 re-embed。不把 content hash 编进 chunk_id,否则改一个标点就变 orphan。算不出稳定 point_id,整次导入停,不写随机 ID。正文非空却切出零块,当解析失败,不作「清空」——否则 prune 会把上一版有效知识删掉。

解析结果先整份归档,再往下切。没有底稿,切坏了只能猜。图随后也存成永久 URL,object key 用 content hash,不带会过期的 query string。归档失败只记账,可以降级:缺一份底稿不该阻断整次入库。

走到这里:一份统一正文、一个 doc_id、一份归档。整篇仍是一块。问「超时」,「安装需要三个文件」会一起被拖进来。下一步不是 embed,是按结构切开。

Chunk

Parent-child:检索打 child,回答看 parent

# 安装# 配置 是两条缝。![示意图] 是第三条,不并进文本。按固定字数横切,会把代码、列表和一句话切断。按 heading 切开,heading_path 带完整祖先,两处同名标题也不会撞。代码、列表、表格是原子块,再长也不从中间拆。段落按句子堆,超过 512 单位新开一块;重叠留整句,不超过 64 单位。碎块小于约 128 单位会和邻居合并,不跨图

单位故意低估:一个汉字或一个英文词算 1,标点空格不算。入库定 parent、问答装箱,用同一套单位,宁可切短,不会把真实 token 撑破。

检索要小块才尖,回答要大块才完整,所以写成 parent-child(LangChain Parent Document Retriever / LlamaIndex hierarchical node 是同一类):

  • Child 带 dense + sparse 两路向量,是唯一被检索的对象。
  • Parent 无向量,和 child 放同一 collection,检索靠 filter 排除。上限 2500 单位。相邻节贪心打包:加上下一节会超,或标题比当前组最浅的更浅,就封口。整篇小于上限则全文一块。
  • 分组后 child 必须做文档全局重编号。否则「安装」和「配置」各自从 0 编,point_id 会撞。

图先占位,正文空着,前后各留最多 500 字且停在段边界。Caption 之前不要拼 parent——里面会是一根链接。原子块可能超大,编码前超过约 5000 词再滑窗拆,每段加 :oversized{i} 另算 point_id。共用父主键会互相覆盖。

走到这里:

  • C1:安装需要三个文件
  • C2:超时默认 30 秒
  • C3:图,占位,正文仍空
  • Parent 还不能拼

C2 是「超时是多少」该打中的 child。C3 对向量还不存在。不先写成字,后面换什么 Embedding 都打不中这张图。

Caption

第一版不换 multimodal embedding 空间。入库时用 VLM 把图写成字,问句仍走文本。带着前后文、节标题、图号一起看。

短 caption + 细 caption 写成 C3 的正文,C3 才能进 dense / sparse。图类型、OCR 进 payload,不进向量正文。

只写进 C3 不够。问「超时是多少」打中的是 C2;C3 的 caption 短,后面 rerank 很容易把它挤掉。所以把 caption 再 append 到同一 parent 里、往前最近的文本 child 末尾。C2 变成:

超时默认 30 秒。
图片:超时设置界面

这是 dual index:

  • 问文字、问超时:打中 C2,图意已经在这块里
  • 问「找这张图」:打中 C3
  • C3 被 rerank 挤掉时,C2 里还留着那一句

细 caption 只留在 C3,不往 C2 堆,避免把文本 child 撑成图注。 整图塞进请求,单张经常 75–120 秒。先归档再传 URL,大约 5–19 秒。VLM 调用两层闸:进程 4,单次任务 3。只限单次,多任务叠在一起仍会打满。小于 100px 或 5KB 当装饰丢掉。超时、解析失败、下载失败都当 caption 失败:C3 正文置空,编码前丢掉,失败文案不写进描述。文档不该因为一张图没看成,就整篇进不了库。

编码前再给每块抽 3–7 个 keyword,只拼进 BM25 输入,不进 dense 正文。图上的 OCR、关键 UI 字同样只进 sparse。抽失败降级,不中断。

走到这里:C3 有自己的正文,C2 末尾有一句短 caption,parent 可以拼了。图已经是字,和正文走同一条检索路径。现在才选 Embedding。

Embed

写入是离线批量,看吞吐和单价;问答是在线,看 p99。两条线不要共用同一套超时。

Dense 用网关文本向量,1024 维;sparse 用库内 BM25,服务端生成,不另训稀疏模型。按约 5 个一批写。换模型是 re-embed;换维度是迁移 collection。

模型 图文 扫描件 / 图表 Dense + Sparse 怎么用
BGE-M3 纯文本 三路都有 早期默认,先把链路打通
text-embedding-v4 纯文本 模型可出 sparse 本方案只用它的 dense;sparse 走库内 BM25
Jina Embeddings v4 文本 + 图 Dense + multi-vector 统一模型省事,卡和许可要过
Qwen3-VL-Embedding 文本 + 图 仅 Dense 视觉文档强,sparse 另补
CLIP 自然图 仅 Dense 图表和扫描页不是它的场
商业 multimodal API 各异 适合验证,不适合终态

扫描件成批召不回,再迁空间。Rerank 是另一个角色,不是 Embedding 的附带功能。

C1、C2(已追加图意)、C3(已有 caption)这时都有 dense + sparse。它们还不能对外可见。Point 写进去 ≠ 可以开始问。同一篇可能已经有一版在线上。

Generation

先删旧再写新,写到一半文档会蒸发。立刻对外可见,问答会读到一半新、一半旧。所以同一 doc_id 写成新的 generation:新 point 先写入,检索过滤器看不见它们;整版校验通过后,再把 active generation 切过去,最后删上一版。粒度是一篇文档,不是整库蓝绿。

新 generation 先写完,再切 active

检索恒定三道门:只要 child,只要属于 active generation,只要已经 commit。读不到 generation 清单,这次 retrieve 失败,不混进未提交的点。

写入校验:

  • 全成功:先写 parent(本轮应保留的 id 必须包含 parent,否则下一轮 prune 会把大块清掉),再 commit,切 active,删上一版。
  • 写入不完整:丢掉新 generation,active 不动。
  • 若接受少量 embedding 失败(约 5%):本轮应保留的 id 必须含预期的全部 point_id,包括没写上的——它们的旧版还在。只按「写成功的 ID」去 prune,会误删仍有效的旧点。
  • 零块且正文非空:当失败,不作清空。

两个任务同时更新同一篇,后提交的若发现 active 已变,就放弃(compare-and-swap)。清旧失败只是多一版脏数据,不回滚 active。

任务是状态机:pending → processing → success / retryable / failed。领取用 SKIP LOCKED,5 秒一轮,并发 4,最多重试 3 次。Worker 水平扩展靠行锁,不靠进程内队列。

走到这里:active 切过来,问答读到这一版 C1 / C2 / C3。可以问了。问句不要直接打模型。先进入 retrieve。Agent 上游可以改写、拆 subquery;retrieve 不管对话历史。

Retrieve

Retrieve 是 Agent 的 tool,不是对话系统。入参是最终 query,外加可选的 rewritten_query / keywords / subqueries / fusion。内核不自己拆句;subquery 最多 5 条,由上游传入。出参是结构化 hits:chunk_id、正文(展开后可能是 parent)、score、citation 字段。不交最终答案,也不交 refuse 文本。

Agent 侧通常准备两个入口:默认 hybrid;编号 / 专有名词走 sparse 权重更高的一路。Query plan 是可选前置 tool,失败则用原句。本轮 retrieve 最多一次;空结果不要在同一轮里自我重试。每条查询恒定上面那三道门。

Agent → Retrieve: dense + sparse ranks, RRF then pool 20, rerank reorder only, citation pins C2

Hybrid

「超时怎么设」是 paraphrase,dense 容易打中 C2。问句里的「超时」和编号类字面量走 sparse / BM25。只走一路,总会漏掉另一类。默认每路宽度 10。这是召回宽度,不是最终给模型的块数。写入和查询必须用同一套 BM25 tokenizer;中文分词换版本,等于整库要重 ingest。

Dense 分 0.82 和 BM25 分 18.4 不能直接加,RRF 只看名次

Dense 分在 0 到 1,BM25 可以到几十。直接加,18.4 会吞掉 0.82。所以默认 RRF(Cormack et al., 2009),k = 60,只看名次倒数和:

score(d) = 1/(k + rank_dense(d)) + 1/(k + rank_sparse(d))

Qdrant 原生 prefetch,一次往返。加权融合留作进程内选项:各预取 2 倍宽度,Min-Max 到 [0,1],默认 0.7 / 0.3。某一路全员同分必须回退成 1,不能归一成 0。编号类问句,上游可把 sparse 抬到约 0.9。非法参数直接拒绝,不静默降级。

去重:point_idchunk_id → 来源 + 正文前 200 字。不带分数,不用对象身份。上游传入的 subquery 和主问句并发,失败的变空组。

C2 会在两路都靠前,RRF 把它推到最前。名单有了,还不能直接喂给模型。

Rerank

召回只要别漏,排序交给 cross-encoder rerank。召回宽度 10 和精排池 20 是两颗旋钮——可以只加大其中一颗。精排只重排,不截断;最终切多少块,交给后面的装箱。低于 0.35 记低分。精排挂了按召回分排,trace 能区分两条路。不要悄悄返回空。

C3 的 caption 短,精排容易把它挤掉。按原召回补回 image chunk,最多 3 张,门槛 0.30。去重看永久 URL 最后一段(content hash),先去掉 query string。先排序再去重。

Expand

命中的是小块,回答要完整上下文。文本 child 用和写入相同的公式取 parent,只换正文。分数和 citation 仍钉在 C2。同一 parent 只展开一次。

C3 不展开。 它和 C2 共一个 parent 短 ID:展开会塌成重复,或被整节冲淡。图 / 表是自含单元,保持自己。取失败则整表保持 child。单块超过约 4000 单位退回 child(比写入的 2500 松,兼容历史大块)。

预算打开时按同一套单位塞,大约 12000 单位、最多 20 块。精排留下的是地板,无条件保留。后面超长就跳过,看下一条更短的还进不进——continue,不是 break。

Refuse

最后按分数编号。Citation 带 doc_idchunk_idheading_path、modality、图对象、score。内核交结构化 citation,不交最终答案。

空结果或最高分低于 0.35:记 unanswered,同一句只加计数。检索 API 仍返回当次 candidates。Refuse 不在内核里——它是 Agent 外壳的 safety net:本轮没有 grounded citation,就写回固定拒答,禁止靠参数记忆答题。上游决定改写 query 还是停。知识库不负责把 miss 编成答案。

这份 fixture 走完:

  • 命中 C2
  • 短文档小于 parent 上限,展开后是全文一块(安装 + 配置 + 图意)。它演示的是「检索打小块、citation 钉 C2」,演示不了长文的窗口收益
  • Citation 仍指向 C2,不是整篇
  • C3 若被补回,保持自己,不展开

不变量

阈值只卡最后一刀。召回是写入时埋进去的入口,准确是检索时守的纪律。

步骤 保的是 若跳过
统一正文 + 稳定 ID 更新幂等 orphan;每种来源一套 citation
先归档再切块 可 re-embed 切坏没底稿
parent-child 命中尖、上下文完整 召不中,或命中了答不全
看图 + 双路索引 图进得了语义检索 图对向量不存在
先写新 generation 更新窗口 半成品被问到,或整篇消失
RRF 两路公平 BM25 分吞掉 dense 分
精排不截断 宽度和窗口解耦 改一个旋钮带坏另一个
图 child 不展开 图不被冲掉 图塌成重复
citation 钉在命中的 child 可追溯 跳到整章
refuse 准确 miss 变幻觉

评测拆开:检索看排序、覆盖、图是否找回且顺序对;生成看是否胡编、是否答所问、该拒是否拒。拒答率和时延不用再调一次模型。

观测对齐刚才走过的路。一次写入一条 root span:各阶段耗时、写了多少、这个 generation 提交还是丢掉。一次检索另一条:两路召回了谁、融合、精排前后名次、是否降级、补了几张图、最高分、unanswered 原因。要留下问句、candidate、citation、context length。只记最终答案,对比「换了融合」或「换了切块」时说不清赢在哪。Unanswered、差评、低分收成回放集,改参数先过门再放量。

默认值

默认
库 / 索引 Qdrant / HNSW
dense / sparse 1024 维文本向量 / 库内 BM25
child / overlap / parent 512 / 64 / 2500 单位
编码前再拆 约 5000 词
写入丢失容限 约 5%
看图 URL 送图;闸 4 + 3;超时 60 秒
召回 / 精排池 / 门槛 10 / 20 / 0.35
融合 RRF,k = 60
子问题 / 补图 最多 5 / 最多 3(0.30)
任务 5 秒,并发 4,重试 3

512、0.35、k=60 以后几乎一定要改。不该改的是一路读下来反复碰到的那些:身份由位置重算;新 generation 先写再切 active;只打已 commit 的 child;图块不展开;citation 钉在打中的那一块;失败分类;观测不拥有线上版本。

Embedding 以后几乎一定要换。那时值得 re-embed,不值得把写入和检索焊回同一条请求。