万字长文|LLM Wiki Agent 与知识图谱是怎么做出来的

本文摘要万字长文|LLM Wiki Agent 与知识图谱是怎么做出来的本文来源: 徐小夕(公众号:徐小夕)原文链接: https://mp.weixin.qq.com/s/sX8W1GR4lqVTfZMpYFeL8A发布时间: 2026-09-10 08:20作者: 徐小夕  发布时间: 2026-09-10 08:20往期精彩:万字长文!LLM Wiki 技术深度拆解与落地实践攻克 Web 文档难题:...

万字长文|LLM Wiki Agent 与知识图谱是怎么做出来的

本文来源: 徐小夕(公众号:徐小夕)
原文链接: https://mp.weixin.qq.com/s/sX8W1GR4lqVTfZMpYFeL8A
发布时间: 2026-09-10 08:20

万字长文|LLM Wiki Agent 与知识图谱是怎么做出来的

作者: 徐小夕  发布时间: 2026-09-10 08:20


往期精彩:

万字长文!LLM Wiki 技术深度拆解与落地实践

攻克 Web 文档难题:JitWord 如何实现业界顶尖的 Word 高精度解析与渲染

对比实测:为什么 JitWord 是目前最适配商用的 Web Word 编辑器?

上一篇我把 LLM Wiki 的范式思想讲清楚了。这一篇不聊概念,我花了3天时间,直接把AI小册里正在跑的这套 Agent 方案拆开摊在桌上:

它们就是 Alex Agent 智能学习助手知识图谱。

体验地址:https://aibook.mvtable.com/agent

从内容摄入、中文检索、权限分级,到图谱建边、前端力导布局、模型降级,每一层怎么想、怎么写、实测多少毫秒,我都会在这篇文章中详细和大家分享。

文章略长,1.4万字,建议先收藏,再看。

先说为什么要做这件事

AI小册现在有 34 个模块、161 个精品章节,内容体量已经过了「靠目录能翻完」的临界点:

读者的真实困境是这样的:想找「上下文工程和提示词工程有什么区别」,得先猜它在哪一章;想问「独立开发者怎么变现」,搜索框只能匹配字面词,换个说法就什么都搜不到。

更麻烦的是跨模块的问题。AI小册的知识是有关联的——MCP 网关那一章用到的鉴权思路,和前面 Agent 记忆机制那一章是连着的,但目录树是层级结构,天然表达不了这种横向关联。

所以我做了两件事:

Alex Agent 负责「你问,我带着原文出处答」;

知识图谱负责「你不问,也能看见知识之间怎么连的」。

同一份底层数据,两个视图:

而整套实现里最反常识的一点是:我一个重型依赖都没引。

没有向量数据库,没有 Elasticsearch,没有 Neo4j,没有 d3-force,没有 langchain。中文分词是我自己设计的 60 行核心代码,图谱建边是自己写的 TF-IDF,前端力导布局是自己设计的 Canvas 2D。

具体的实现模块如下:

1. Alex Agent 智能问答助手

基于 SQLite FTS5 + 自实现中文 bigram 分词,把 161 章内容编译成 1,432 个带原文地址的知识块。提问后平均 4.0ms 完成检索,回答自带可点击的原文出处。

2. 知识图谱可视化

用 TF-IDF 余弦相似度算出 639 条跨模块语义关联边,前端 1,385 行单文件手写力导向布局与 Canvas 渲染,零第三方图形库。支持模块/章节两级视图、知识块按需展开、节点深链跳转。

基于上面的技术方案,我做出了完整高性能的知识图谱方案,如下图所示:

下面按数据流的顺序,一层一层拆。

01|指导原则:LLM Wiki 的「摄入时编译、请求时只读」

上一篇讲过,传统 RAG 是「解释执行」——每次提问都现场检索、现场拼接、现场让模型理解一遍。LLM Wiki 反过来,主张像编译代码一样编译知识

这句话落到工程上,就是一条很硬的边界:凡是能在内容变更时算好的,绝不留到用户提问时再算。

我把这条边界画成了整个系统的骨架。内容文件变更时跑一次编译,产出三样东西常驻在 SQLite 里:切好块的知识正文、建好倒排的全文索引、算好权重的图谱关联边。

用户提问时,链路里没有任何一步需要 LLM 去「理解知识库结构」,全部是本地只读查询。

我画了一张架构流程图,供大家参考:

image.png

图 1 编译期与查询期的责任划分:查询链路里没有任何一步需要模型去理解知识库结构

这个划分带来三个很实在的好处。

第一,成本可控。查询期不烧 Token 去理解结构,Token 只花在最后的回答生成上。没配 API Key 的读者也能用——降级模式直接把检索结果聚合成带出处的答案。

第二,结果稳定。同一个问题今天问和三个月后问,召回的知识块完全一致,因为召回是确定性的 SQL 查询,不含任何模型采样。这一点传统 RAG 做不到。

第三,知识真的沉淀下来了。图谱的 639 条关联边是编译期算好存库的,它是一份可以反复查、可以可视化、可以随内容增长而增长的资产,不是每次提问临时拼出来的碎片。

一句话总结这一节:我把「理解知识」这件贵的事挪到了编译期,让查询期只剩下便宜的只读检索。后面所有技术选型,都是在为这条边界服务。

02|整体架构:五层分离,两个数据库物理隔离

先看全景。整个系统 8 个源文件、约 4,600 行,按职责单向依赖,没有任何环形引用。

image.png

图 2 五层架构与文件职责。行数为实测值,合计约 4,600 行

这张图里最值得单独拎出来讲的,是一个看起来平平无奇的决定:知识库和业务库是两个物理隔离的 SQLite 文件。

AI小册的用户、订单、激活码、佣金全在 store.db 里,那是真金白银的数据。而 Agent 的知识索引、图谱、会话记录全在独立的 agent.db(7.3 MB,WAL 模式)。

为什么必须分开?因为知识库是可以被随时全量销毁重建的派生数据——它的唯一真相来源是内容文件。重建索引是一个 448ms 的破坏性操作(清空表再灌),如果和业务数据同库,一次误操作或者一个事务死锁就可能波及付费用户的订单记录。物理隔离之后,最坏情况也只是知识库需要重建一次,用户数据一根汗毛都碰不到。

这里得说句实在话:「零依赖」不是目的,是结果。每一处我都是先算了成本才决定自己写的。

AI小册的部署环境是一台跑着 5 个应用的服务器,多引一个需要 node-gyp 编译的原生模块,就多一处部署失败的可能;多起一个 Elasticsearch 容器,就多一份常驻内存。

而我的内容体量是 1,432 个知识块——这个量级下,上面那些重型方案带来的收益,覆盖不了它们的运维成本。

但这条路有代价,我会在第 9 节会把天花板如实摊开讲。

03|知识摄入:把「渲染用的 JS」编译成知识库

这一层的第一个难点,很多人会忽略:AI小册的内容根本不是 Markdown 文件。

它是 25 个 JS 文件,每个文件往 window.CHAPTERS_* 这个全局变量上挂一个数组,官网加载时按顺序执行这些脚本、再由 app.js 聚合成目录树渲染出来。也就是说,内容的真相存在于「脚本执行之后的运行时状态」里,而不是文件文本里。

所以正则去扒源码是行不通的——那样只能拿到字符串字面量,拿不到拼接、条件注入和跨文件聚合的结果。

我的做法是在 Node 里开一个 vm 沙箱,把这些脚本原样执行一遍,然后读它挂到 window 上的结果。等于在服务端复刻了一次浏览器的加载过程,内容怎么渲染给读者,就怎么进知识库,两边天然一致。

沙箱里还得补上浏览器才有、Node 没有的那些全局对象(windowdocument 等),否则脚本一执行就抛 ReferenceError。这是个不起眼的坑,踩过才知道。摄入流程:从内容文件到可检索的知识块(实测全量 448 ms)

image.png

图 3 摄入流程。mtime 判定让服务重启不必每次都重建,ensureGraph() 兜住「索引已就绪但图谱表为空」的升级场景

切块策略也值得一说。我没有按固定字数硬切,而是按 Markdown 标题层级切——遇到 ##### 就断开,单块超过 1800 字符再往下切。这样每个知识块都是一个语义完整的单元,不会出现「半句话被切在两块里」的情况。

实测下来 1,432 个块平均 429 字符,最长 1,879、最短 35。每个块都带三样东西:原文地址/docs/#moduleId/sectionId,点进去直达官网对应章节)、标题路径(模块 > 章节 > 小节,用于生成引用胶囊)、权限标记(plusOnly,模块级或章节级)。

「回答必须带原文地址」这个产品要求,其实是在摄入期就落进数据结构的——不是回答时再想办法拼出来的。这也是编译范式的典型体现。

为什么索引和图谱必须同事务?图谱的边是从 kb_chunks 派生出来的。如果分两个事务写,中间任何一次崩溃都会留下「索引是新的、图谱是旧的」这种脏状态,而且极难排查——因为两边单看都是自洽的。放进同一个事务,要么全成功要么全回滚,一致性由 SQLite 保证,我不用写任何补偿逻辑。

04|中文检索:用 60 行 bigram 换掉一个分词库

这是整套方案里最朴素、也最容易被低估的一环。

问题出在哪:SQLite FTS5 默认分词器 unicode61 是按空格和标点切词的。英文没问题,中文没有空格——一整句话会被当成一个 token 塞进索引。结果就是除了完全一致的整句,什么都搜不到。中文全文检索的第一道坎,永远是分词。

常规解法是接 jieba。但 nodejieba 是原生模块,需要 node-gyp 现场编译——这意味着部署机得有完整的编译工具链,每次 Node 大版本升级都可能要重编,跨平台部署直接变成一场赌博。我们的服务器上还跑着另外 4 个应用,不想为了一个分词功能引入这种不确定性。

所以我自己写了 60 行 CJK bigram。原理朴素到有点土:中文既按单字切、也按相邻两字切,英文和数字的连续串整体保留。

function tokenize(text) {
  const s = String(text).toLowerCase();
  const out = [];
  let ascii = '';
  for (let i = 0; i < s.length; i++) {
    const ch = s[i], code = s.charCodeAt(i);
    if (isCjk(code)) {                    // 中文 + 扩展A + 兼容汉字 + 假名 + 谚文
      if (ascii) { out.push(ascii); ascii = ''; }
      out.push(ch);                       // 单字也留,保证「一字查询」能命中
      if (isCjk(s.charCodeAt(i + 1))) {
        out.push(ch + s[i + 1]);          // 相邻双字,这才是检索的主力
      }
    } else if (/[a-z0-9]/.test(ch)) {
      ascii += ch;                        // ASCII 连续串整体保留:RAG / FTS5 / LLMOps
    } else if (ascii) { out.push(ascii); ascii = ''; }
  }
  if (ascii) out.push(ascii);
  return [...new Set(out)].join(' ');      // 去重,控制索引体积
}

节选自 db.js,isCjk() 的完整判定覆盖五个 Unicode 区段

三个设计决定,每一个都是被现实逼出来的。

单字为什么也要留?因为读者会搜「图」「券」「码」这种单字。纯 bigram 的话,单字查询永远命中不了——索引里根本没有长度为 1 的中文 token。留着单字,查询才有兜底。

ASCII 为什么整体保留?AI小册里全是 RAGFTS5MCPLLMOps 这类术语。如果把英文也按 bigram 拆成 ra ag,搜 RAG 会命中一大堆 ragdoll 式的无关内容,术语检索直接废掉。

末尾为什么去重?纯粹为了压索引体积。「的」「是」这类字在一篇文章里出现几百次,不去重的话 tokens 列会爆炸。

代价我也如实和大家分享一下:分词膨胀率 1.455×。tokens 列 894,120 字符(873 KB),比 content 列的正文 614,503 字符(600 KB)还大 45%。这是 bigram 的固有开销——我用磁盘换了部署确定性。整个 agent.db 主库 7.3 MB,这个代价完全付得起。

存储:正文和索引分开,靠触发器同步

kb_chunks 存正文和元数据,kb_fts 是 FTS5 的 external content 表,两者之间用 SQLite 触发器同步。这样写入只有一处入口,不可能出现「正文改了索引没改」的不一致,也不需要应用层维护双写。

查询:AND 优先,OR 兜底,双引号包裹

function buildMatchExpr(query) {
  const toks = tokenize(query).split(' ').filter(Boolean);
  if (!toks.length) return null;
  // 每个 token 用双引号包裹:既过滤掉 FTS5 的特殊语法
  // (NEAR / NOT / * / ^),也杜绝查询串注入
  const esc = toks.map(t => '"' + t.replace(/"/g, '') + '"');
  return { and: esc.join(' AND '), or: esc.join(' OR '), tokens: toks };
}

注意它一次返回两个表达式。检索时先试 AND(要求所有词都命中,精度最高),命中数为 0 就退到 OR(任一词命中即可),再用 BM25 排序把最相关的顶上来。

这里有个我们实测出来的、必须诚实告诉你的结论:在自然语言长问句下,AND 分支基本命中不了。「知识图谱怎么构建」拆出来的 token 有七八个,要求它们全部出现在同一个知识块里,概率极低——多加一个「怎么」,AND 命中数就归零了。AND 真正生效的场景是关键词式短查询,比如「RAG 分块」「MCP Server」。

所以实际承载检索质量的是 OR 召回 + BM25 排序这一条路。AND 更像是一个精度加成项,不是主力。这个认知很重要——如果你也打算用 FTS5 做中文问答,别把希望寄托在 AND 上,排序才是决定体验的地方。

还有一个细节:我没用 FTS5 自带的 snippet() 函数。因为它作用在 bigram 的 tokens 列上,返回的摘要会带分词噪声(形如「知 识 图 谱」这种被切碎的文本)。改成自己在原文 content 列里定位查询词、生成摘要窗口,展示效果干净得多。

05|检索链路:召回不是终点,「挑哪几块」才是

很多人以为 RAG 的核心在召回。真做过就知道,召回只是拿到一个候选池,真正决定体验的是从池子里挑哪几块、以及怎么组装。

我在召回之后加了三道关卡,每一道都对应一个真实踩过的坑。

第一道:权限分流必须在 SQL 层,不能在前端

AI小册有 225 个 Plus 专享知识块,占总量 15.7%。对非 Plus 用户,这些块不进 sources,只进 lockedHits,而且 lockedHits 里只有标题和 url。

这个区别很关键:正文在 SQL 查询里就没被选出来,不是查出来再在前端遮住。前端遮罩是可以被 DevTools 一行指令绕过的,付费内容的分级必须做在数据出口。lockedHits 上限 3 条,刚好够生成一条不让人反感的升级引导。

第二道:两轮挑选,先保多样性再保相关度

直接按 BM25 分数取 top-6 会出什么事?实测单个章节最多被切成 37 块。一个大章节里往往多块都含查询词,分数也都高——结果就是 6 个引用胶囊全部指向同一个原文地址,用户看到的是六份重复信息。

// 第一轮每章节只取最相关的 1 块 —— 最大化来源多样性,
// 避免 6 个引用胶囊全指向同一个原文地址;
// 不足 limit 时第二轮回补已选章节的次优块,每章节上限 2 块。
const MAX_SOURCES = 6;

for (const maxPerSection of [1, 2]) {
  for (let i = 0; i < usable.length && sources.length < limit; i++) {
    if (picked.has(i)) continue;
    const { row, secKey } = usable[i];
    const total = perSection.get(secKey) || 0;
    if (total >= maxPerSection) continue;      // 本章节配额用完
    picked.add(i);
    perSection.set(secKey, total + 1);
    sources.push({ url: row.url, headingPath: row.heading_path,
                   plusOnly: row.plus_only === 1, score: row.score, ... });
  }
  if (sources.length >= limit) break;           // 第一轮就够了,不进第二轮
}

一个 [1, 2] 的数组就把两轮逻辑写完了,没有分支嵌套。第一轮保证来源尽量分散,第二轮只在不够数时才回补,且每章节最多 2 块——既不会让一个大章节吐完所有名额,也不会为了凑数硬塞不相关的内容。

第三道:上下文预算,超了就停

const MAX_CONTEXT_LEN = 9000;

for (const block of sources) {
  if (ctx.length + block.length > MAX_CONTEXT_LEN) break;   // 整块装不下就停
  ctx.push(block);
}

// 单块过长时在内部摘录,且按句子边界切——绝不切半句
function cutBySentence(text, budget) {
  const flat = text.replace(/\s+/g, ' ');
  if (flat.length <= budget) return flat;
  const window = flat.slice(0, budget);
  // 在窗口内从后往前找最后一个句末标点,在那里断开
  ...

为什么用字符数而不是 token 数做预算?因为算 token 就要再引一个 tokenizer 依赖,而中文场景下字符数与 token 数的比例相当稳定,用字符数做上限完全够用。能不多引一个依赖就不多引,这是贯穿全文的原则。

切句子边界这个细节看着小,影响很大。把一句代码说明从中间截断,模型很可能就着半句话编出错误结论。

所以我设计了一个更加靠谱的方案,流程如下:检索链路:只有最后一步可能调用 LLM,前面全是本地只读查询

image.png

图 4 完整检索时序。左右两个出口共享同一套溯源结构,所以降级模式也能给出带原文地址的回答

降级回答也要有结构,不能硬编

buildFallbackAnswer() 分三种情况处理,而不是统一返回一句「没找到」:

有 sources 时,把检索到的知识块聚合成结构化摘要,后面附原文列表——这条路不开模型也能用,是我们上线第一天的主力形态。

只有 lockedHits、没有 sources 时,说明答案在 Plus 专享内容里——这时输出升级引导,并把命中的章节标题列出来,让用户知道订阅后能得到什么,而不是一句干巴巴的「无权访问」。

两者都为空时,明确告诉用户「AI小册里没有这部分内容」。这一条看似简单,却是整个系统可信度的地基——宁可说不知道,也不能拿不相关的内容凑一个看似合理的答案。这一点我做得还不够好,第 09 节会接着说。

06|知识图谱:不用 Neo4j,两张表算出 639 条语义边

图谱要解决的问题是目录树解决不了的:知识之间的横向关联。MCP 网关那一章的鉴权思路,和前面 Agent 记忆机制那一章是连着的,但在目录树上它们隔了六个模块。

建图之前先建模。这里有两个反直觉的决定,它们直接决定了图谱能不能看。

建模决定一:chunk 不建节点

只把 34 个模块 + 161 个章节 = 195 个建成图节点,1,432 个知识块一个也不建。

理由很实际:同一章节内所有 chunk 的 url 完全相同。把它们建成节点,图会糊成一团黑,而且点哪个都跳同一个地方——信息密度为零,干扰却拉满。

那 chunk 去哪了?标题数组挂在所属章节节点的 meta_json 里,前端展开某个章节时再按需派生出 chunk 节点。数据不冗余存,展示时派生——这部分在 API payload 里只占 17.6%,比存 1,432 个完整节点便宜太多。

建模决定二:层级归属边不入库

模块→章节的父子关系已经由 parent_id 字段表达了,前端自己推导即可。这不只省了 161 条边,更重要的是避免了一个视觉灾难:层级边是确定的树结构,语义边是概率性的关联,两种边混在一个画布上,读者就看不出哪条是真关联了。

一个不会报错、只会让图谱悄悄变废的坑

权重公式用的是 对数 TF(1 + Math.log(tf)) * idf(term)。为什么要取对数?因为实测单个章节最多被切成 37 块,正文极长。不压一下的话,长章节会和几乎所有章节都「相似」,变成一个假枢纽节点,把真正的关联全遮掉。

同时还要用 dfMax 过滤高频词——「AI」「模型」这种出现在几乎每个章节的词,区分度为零,留着只会制造假边。

关键优化:倒排累加,不做 N² 稠密配对

// 不做 N² 稠密配对:161² = 2.6 万次稀疏点积,绝大多数结果为 0。
// 改为按词倒排,只在「共享该词的章节对」上累加,
// 复杂度与实际重叠量成正比。
const postings = new Map();                    // term -> [secIdx, ...]
for (let i = 0; i < N; i++)
  for (const term of vectors[i].keys()) {
    let list = postings.get(term);
    if (!list) { list = []; postings.set(term, list); }
    list.push(i);
  }

// 用一维下三角数组存相似度对,避开 Map 的键字符串拼接开销
const pairKey = (a, b) => (a > b ? a*(a+1)/2 + b : b*(b+1)/2 + a);

for (const [term, list] of postings) {
  if (list.length < 2 || list.length > dfMax) continue;
  for (let x = 0; x < list.length; x++) {
    const i = list[x], wi = vectors[i].get(term);
    for (let y = x + 1; y < list.length; y++) {
      const j = list[y];
      // 同模块章节本就有层级边相连,再连语义边只会让视图糊成一团
      if (sections[i].moduleId === sections[j].moduleId) continue;
      // 只累加当前这个词的贡献。切勿在此处重算整对点积——
      // 那样每共享一个词就会把完整相似度叠加一次,结果被成倍放大
      const key = pairKey(i, j);
      sim.set(key, (sim.get(key) || 0) + wi * vectors[j].get(term));
    }
  }
}

里面有三处值得单拎出来说。

下三角数组做键。无向对的键如果用 i + ':' + j 这种字符串,每轮内循环都要拼接字符串并哈希。换成 a*(a+1)/2 + b 得到一个整数键,内存和速度都更好。反解也很便宜:a = floor((sqrt(8*key+1)-1)/2),再用 while 修正一次浮点误差即可。

只累加当前词的贡献。这是我真踩过并修掉的坑:如果在内循环里重算整对的点积,那两个章节每共享一个词就会把完整相似度叠加一次,结果被放大成倍,而且放大的倍数还取决于它们共享了多少词——排序彻底失真。

同模块直接 continue。不只为了好看:同模块章节已经有层级边相连,再加语义边会让模块内部结成一块死疙瘩,跨模块的真关联反而被挡在视野外。图谱构建:TF-IDF + 倒排累加,84 ms 产出 195 节点 / 639 边

image.png

图 5 图谱构建全流程。底部对比的是我主动放弃的写法与最终采用的写法

最后说下模块级权重为什么要做 w/(1+w) 压缩。模块级的权重是它下属所有章节对权重的累加,很可能超过 1;而前端要拿权重映射连线粗细,要求值落在 (0,1) 区间。

这里没用常见的「除以最大值」归一化,因为归一化会让每次重建的线宽整体漂移——同一对模块,这次内容多了点就可能从粗线变细线,读者会困惑。w/(1+w) 是单调递增的压缩函数,保序、天然落在 (0,1),而且每个值的映射只取决于它自己,不依赖全局分布。

07|图谱前端:1,385 行单文件,手写力导向布局

先回答必然会问的问题:为什么不用 d3-force、ECharts graph 或者 AntV G6?

三个理由。ECharts 和 G6 体积几百 KB,而我要的是一个能秒开的页面;d3-force 只解决布局,渲染、缩放、命中检测还是得自己写,引它省不了多少代码;最关键的是我的图很小——195 个节点,力导布局本身不到 100 行,引库的收益覆盖不了体积和认知成本。

渲染选 Canvas 2D 而不是 SVG DOM,也是算过的:195 节点 + 639 边意味着每帧要重绘上千个图元。SVG DOM 方案每帧要改上千个属性,触发样式重算和重排;Canvas 每帧一次 clearRect 加批量绘制,没有 DOM 开销。

O(N²) 斥力:我故意没上四叉树

力导布局的斥力计算是两两配对,标准优化是 Barnes-Hut 四叉树把复杂度降到 O(N log N)。我没做,因为算过账之后发现不需要:

各视图层级的每帧配对量(实测)

最后一行就是这个设计的边界。所以 chunk 节点是按需展开而不是默认全展开——这不是妥协,是主动的规模控制。把四叉树加上当然能撑更大的图,但在当前体量下那是白写的复杂度。

传输:自己 gzip,自己管缓存

136.6 KB 的 JSON payload 不能裸传。但站点的 nginx 没配 gzip,也没改写

Accept-Encoding,所以 compression 中间件那套协商式压缩靠不住。我直接用 Node 内置的 zlib.gzipSync(level 6)压到 34.8 KB,不依赖任何中间层配置。

// 按「权益档位 + builtAt」分槽缓存:Plus 用户与非 Plus 用户
// 看到的节点集合不同(锁定节点只给标题),payload 不能共用。
// 分槽后各自常驻,builtAt 变化时旧槽自然被覆盖,不会白白
// 重复 JSON.stringify + gzip。
const graphCache = { plus: null, std: null };

const key   = slot + ':' + payload.builtAt;
const hit   = graphCache[slot];
if (hit && hit.key === key) return hit;

graphCache[slot] = {
  key,
  etag: 'W/"' + crypto.createHash('sha1').update(key)
                       .digest('base64').slice(0, 22) + '"',
  gz:   zlib.gzipSync(Buffer.from(json, 'utf8'), { level: 6 }),
};

// 请求侧:ETag 命中直接 304,一个字节都不传
res.setHeader('ETag', cached.etag);
if (req.headers['if-none-match'] === cached.etag) return res.status(304).end();

ETag 用 sha1(档位:builtAt) 而不是对整个 payload 算哈希——builtAt 已经是重建的时间戳,内容变了它必变,算 136 KB 的哈希是白花钱。弱校验(W/ 前缀)也正好匹配这个语义。

两个页面不是两个功能,是同一份知识的两个入口

图谱支持 ?focus=节点id 深链,直接定位并高亮;同时与 /agent/?q=标题 互相跳转——在图谱里看到一个章节,可以带着标题直接去问 Agent;Agent 的回答也能跳回图谱,看看这个知识点还连着什么。

这个互跳能做得这么便宜,根因在于两个功能共享同一份编译产物。图谱不是另一个系统,它就是 kb_chunks 的第二个视图。这也是第 01 节那条架构边界带来的直接红利。

08|模型接入与降级:没配 API Key 也得能用

模型解析走三级优先级:user > platform > none

user 是会员自己在设置里配的 Key(存 agent_llm_configs,返回前端时做密钥脱敏);platform 是环境变量 DEEPSEEK_API_KEY / BASE_URL / MODEL;none 就是降级。

为什么要让会员自配?因为平台统一出 Token 成本不可控,而且不同人想用不同模型。自配之后,平台成本和用户自由度解耦了——你愿意用自己的 Key 就能用更强的模型,不愿意也照样能用降级模式拿到带出处的答案。

会员能自配 baseUrl,就必须防 SSRF

这是一个很容易被忽略的安全口。一旦允许用户填写任意 baseUrl,他就可以填 http://127.0.0.1:6379 或者云厂商元数据地址 169.254.169.254,让我的服务器替他发请求、再把响应回给他。

// 字面量先过一道
if (isPrivateAddress(host)) {
  return { ok: false, error: '出于安全考虑,不允许指向内网或本机地址' };
}

// 域名必须先解析、再逐条校验解析结果——
// 只校验字面量挡不住「域名指向内网 IP」这种绕法
const records = await dns.lookup(host, { all: true, verbatim: true });
for (const r of records) {
  if (isPrivateAddress(r.address)) return { ok: false, error: '...' };
}

// isPrivateAddress 覆盖:10/8、172.16/12、192.168/16、127/8、
// 169.254/16(含云厂商元数据 169.254.169.254),
// 以及 ::ffff:127.0.0.1 这类 IPv4 映射的 IPv6 地址

两个细节容易漏:all: true 要拿全部解析记录(只看第一条的话,多记录域名可以用剩下那条绕过),verbatim: true 保持解析顺序不被 Node 重排。还有 IPv4 映射的 IPv6 地址,::ffff:127.0.0.1 字面上不是 127 开头,但连上去就是本机。

伪流式揭示器:4ms 出结果反而体验差

这是个反直觉的发现。降级模式下本地检索只要 4ms,整段答案「啪」一下全铺出来——用户反而觉得「这根本没在思考」,甚至怀疑是不是缓存的旧答案。但我又不想假装在调模型骗用户。

所以做了一个前端揭示器:文本本身是完整的,只是按时间轴逐段展示。

// 时长按字数自适应并夹在上下限内:
// 短答案不至于一闪而过,长答案也不让人干等
const duration = Math.min(3000, Math.max(650, total * 1.2));

const step = (ts) => {
  if (!t0) t0 = ts;
  // 以时间轴而非帧计数推进:掉帧时自动补偿,总时长保持稳定
  const target = Math.min(total, Math.ceil(((ts - t0) / duration) * total));
  if (target > pos) { renderer.push(fullText.slice(pos, target)); pos = target; }
  if (pos < total) raf = requestAnimationFrame(step);
  else { raf = 0; settle(); }
};

// 用户点「停止」或切换会话:立即铺满剩余内容
// (全文本身是完整的,所以这不算截断)
skip() { if (raf) cancelAnimationFrame(raf);
         if (pos < total) renderer.push(fullText.slice(pos));
         settle(); }

里面最值得抄走的一行是 target 的算法。用时间轴而不是帧计数推进。如果写成「每帧推 N 个字符」,在卡顿的机器上揭示会直接变慢,总时长不可控;而按 (ts - t0) / duration 算目标位置,掉帧就自动跳更多字,总时长始终稳定。

流式渲染 Markdown 的两个坑

坑一:代码高亮不能在流式过程中做。逐字推送时如果每收到一个 delta 就重新高亮整段,开销会随文本长度平方增长,肉眼可见地卡。改成只在完成态调一次高亮。

坑二:表格会在流式中途以裸管道符露出。Markdown 表格必须整张齐才能渲染,只收到表头那一行时,渲染器会把它当普通文本输出,用户就看到一行 | 列一 | 列二 | 裸符号在屏幕上闪一下。得在渲染前判断「当前是不是一个孤立表头」,是就先缓着不输出。

09|工程边界:这套方案的天花板在哪

前面八节都在讲我做对了什么。这一节讲做不到什么。一篇只讲优点的技术文章没有参考价值。

边界一:纯词法匹配,没有语义层

这是 FTS5 方案的固有边界,不是 bug。「上下文工程」和「提示词工程」这种字面重叠的近义表述能命中一部分,但真正的同义改写——比如问「怎么让模型记住前面说的话」而正文写的是「记忆机制」——bigram 完全对不上,会漏。

什么时候该上向量层?我的判断标准很具体:内容涨到万级块,或者「换个说法就搜不到」成为读者的主要抱怨。在那之前加向量是负收益——多一个 embedding 服务、多一份索引、多一处两边不一致的可能,而当前 1,432 块的体量下它带来的召回改善很有限。

边界二:图谱弱边占比不低

MIN_SIM = 0.05 定得偏宽,而边权重中位数只有 0.082——说明相当一部分边是弱关联。我的取舍是「宁多连不漏连」,靠前端按权重过滤展示。

但这也意味着:如果你要复用这套算法,阈值必须按自己的内容重新标定。

0.05 是对

AI小册这 161 个章节调出来的,换一批语料它很可能不是最优值。

边界三:多轮对话没有查询改写

现在第二轮问「它怎么部署」,检索层不知道「它」指代什么,只能拿着这两个字去分词,召回必然失准。

这是我明确列在下一步要补的:用上一轮的召回结果做一次轻量改写,把指代补全成完整问句。不需要额外的模型调用,所以不会破坏「查询期只读」这条边界。

边界四:还没有「查不到」的闸门

目前只要 OR 召回有结果就会给答案,但 OR 是「任一词命中即可」——问一个完全无关的问题(比如「今天天气怎么样」)也能因为一个「怎」字而拉回一堆无关内容。

该做的是加一道相关性阈值:分数低于阈值时明确说「没找到」。一个会说「我不知道」的助手,比一个永远能扯出点什么的助手可信得多。降级模式尤其需要这道闸门,因为它没有模型这一层再筛一次。

写在最后

回头看这一整套东西,如果只能留一句话,应该是:把贵的挪到编译期,把查询期留给便宜的只读操作。

这一条原则决定了后面每一个选择:

最终的结果是:约 4,600 行代码、一个 7.3 MB 的 SQLite 文件、零重型依赖,撑起了 161 个章节的智能问答与可视化导航。

但说真的,这些数字本身不算惊人。真正让我满意的是另一件事:这套系统的每一部分都能被一个人读懂。

没有需要专人运维的中间件,没有需要反复调参的向量模型,出了问题打开一个 .js 文件就能从头看到尾。对一个还在每周迭代内容的AI小册来说,这比任何单项性能指标都重要——因为内容再长,系统必须跟得上,而跟得上的前提是看得懂。

这也正是 LLM Wiki 那个范式真正打动我的地方:它不只是一套技术方案,更是一种把知识当资产沉淀下来的思路。内容每更新一次,索引和图谱就重新编译一次,知识就沉淀一层。跑得越久,积累越厚。

谢谢一路陪着我把这套东西从想法做到上线的每一位读者。

大家在后台留言问的那些「这个能不能搜」「那两个章节是不是有关」,直接变成了这次的知识图谱。

想自己上手试一下?

AI小册官网 https://aibook.mvtable.com

如果大家对AI知识库感兴趣,也欢迎加入我们一起把AI知识库打磨的更好:

微信二维码

同时大家也可以关注下方公众号获取更多AI协同办公解决方案:

先暂时聊这么多,后续会持续分享AI创业开源笔记,欢迎留言交流 ~


本文转载自微信公众号「徐小夕」,仅供学习交流使用。

觉得内容不错?我要

打赏杯咖啡或蜜雪冰城吧
微信扫一扫
微信赞赏码
支付宝扫一扫
支付宝赞赏码
评论 暂无评论
请登录后参与评论