实战 · 打造会记忆的AI 写作搭档(二):数据库篇(从 JSON 到单库,再到关系表)

如果你已经读过《实战 · 打造会记忆的AI 写作搭档(一):多 Agent 架构进化》,大概率对“多 Agent 如何协作、记忆如何串起来”有个整体印象。但真正让系统长期可用的,不只是一张好看的架构图,还得有一套能扛住增长的数据底座:能查、能改、能回溯。

这篇文章专注聊“事实层”(数据库)的演进:JSON 文件 → SQLite 单库(KV)→ SQLite 单库(关系表)。至于语义检索、混合检索、全图谱索引与云化迁移,我单独写在下一篇《实战 · 打造会记忆的AI 写作搭档(坤):检索系统篇(向量检索、混合检索与云化)》里。

长篇小说写作系统的本质,不是“写一段文本”,而是长期维护一个不断生长的世界:角色状态、势力关系、物品流转、地点层级、伏笔链条……随着字数增长,这些信息会指数级膨胀。

当数据只是“文本堆”,你会遇到三类必然问题:

  • 查询不动:想找一段“类似氛围/类似冲突”的描写,或者想精确列出“某宗门现役成员”,都很难
  • 一致性变差:删不干净、改了 A 忘了改 B、同一实体在不同地方重复定义
  • 跨设备维护崩溃:多端同步、合并冲突、回滚备份变成体力活

目标一直很明确:

让数据变成“实体关系系统”,再叠加“检索索引层”,最终让 AI 不只是会写,还要会查、会记、不会乱。


0. 阶段零:JSON 文件(最省事,但很快遇到上限)

0.1 当时的选择

最早为了快速起步,我用文件系统存储:角色库、地图、世界观设定等以 JSON(或类 JSON)文件落盘。

它的好处非常直接:

  • 零依赖:不需要数据库,不需要迁移脚本
  • 可读可 diff:用 Git 看差异很舒服
  • 适配 LLM:大模型提取出来就是 JSON,落盘几乎没有摩擦

0.2 很快遇到的问题

当数据量和功能开始增长,JSON 文件会暴露几个硬伤:

  • 缺乏“全局唯一 ID”:一切都靠名字当键,重名、改名、别名会让数据不可控
  • 关系难表达:角色↔宗门经历、角色↔功法熟练度、角色↔法宝持有这些都要手写嵌套结构,越来越难维护
  • 跨端同步痛苦:两个设备同时修改同一个 JSON,合并冲突很难可靠解决
  • 查询很弱:没有索引,最后会变成“加载 JSON → Python 遍历过滤 → 自己维护缓存”

升级的意义并不是“换个更复杂的东西”,而是:把“存档文件”变成“可运行的数据系统”。


1. 阶段一:SQLite 单库(KV 为主)——先把“数据聚合与备份”稳定住

1.1 解决的核心问题

我把早期的 JSON 内容迁移进 SQLite 的 kv_store(key/value)里:例如 character_dbmap_db、世界观、未来规划等。

这一步的价值是把写作系统从“多文件散落”升级到“单文件事实来源”的雏形(注意:这并不等同于解决多端并发合并):

  • 部署与备份简单:一个 novel.db 文件就能跑(备份/回滚更可控)
  • 读写路径统一:不再到处散落读写逻辑
  • 仍保留 JSON 优势:KV 里存的依然是人类可读的 JSON

边界也要说清楚:SQLite 把“事实来源”收敛成单文件,但如果用网盘去同步整个 db 文件,多端同时改写仍然会产生“冲突副本”,无法像文本那样可靠 merge。真正的跨设备同步要靠“中心化仲裁(上云)”或“基于操作日志(op-log)的可合并同步”(后面会在云化迁移里继续展开)。

(实现上会在应用初始化阶段创建 kv_store / chapters / drafts 等基础表,让数据读写从“多文件”收敛到“单库”。)

1.2 留下的问题

KV 的上限也很清晰:

  • 查询的上限:所有复杂查询都要“拿出 JSON 再遍历”
  • 关系表达的上限:关系被迫写成嵌套 JSON,删除/更新很难保证一致性
  • 一致性边界模糊:同一个实体可能在多段 JSON 里被重复描述,冲突难以裁决

这一步适合“系统早期快速迭代”,但不适合“长期维护实体关系图谱”。


2. 阶段二:SQLite 单库(正文表 + KV)——明确“单一事实来源”

2.1 我做了什么

在同一个 data/novel.db 里,除了 kv_store,我也维护结构清晰的正文表:

  • chapters:章节元数据(title/ulid/时间戳/索引字段;章节正文拆到 data/blob_store/
  • drafts:草稿

它的意义是把“写作正文”从文件读写升级为数据库记录,形成更稳定的版本与同步路径。

2.2 单一事实来源(Source of Truth)

从这里开始,我明确一条底层原则:

事实来源(Source of Truth)= data/novel.db(结构化数据/元数据/KV/FTS) + data/blob_store/(章节正文对象)。 任何索引、缓存、衍生结构都必须可以从事实来源重建。

这条原则后面会直接决定“检索层”怎么设计:无论是全文检索还是向量检索,都只能是索引层,不能变成第二套事实来源。


3. 阶段三:SQLite 单库 + 关系表——让“记忆库”从文本堆变成实体关系系统

这一阶段的核心决策是:

直接以事实来源(data/novel.db + data/blob_store/)作为底座:在同一个 SQLite 文件内新增关系型结构表来承载结构化知识。

3.1 为什么要做关系表?

因为写作资料库本质上是一个“实体-关系系统”。当你开始想做这些查询时,KV 模式就会变成维护地狱:

  • “南海鳄神拥有哪些法宝/功法?熟练度分别是多少?”
  • “蛮鳞古族有哪些成员?哪些是现役?职位是什么?”
  • “某功法被哪些人修炼?按熟练度排序”
  • “某条未填坑涉及哪些角色/地点/法宝?最早在哪章出现?”

3.2 关系表的两个最关键约束:实体表 + 唯一 ID

更具体一点,“唯一 ID”这件事要刻意做对,因为它决定了后续所有 join、索引、迁移、合并冲突的成本:

  • 不要用 name 当主键:名字会改、会重名、会有别名/称号;name 只是可变字段
  • 区分“内部行号”和“全局唯一 ID”
    • 本地单机:可以用自增整数主键(性能好、join 轻量)作为内部事实锚点
    • 多端/云化:对外引用最好用 ULID/UUIDv7 这类全局唯一 ID,避免离线编辑后合并时发生 ID 冲突
  • 用唯一约束表达“业务唯一性”:可以给 name 加 UNIQUE(按项目接受程度决定),但仍不把它当主键
  • 别名/称号单独表:可以引入 entity_aliases(entity_type, entity_id, alias) 解决“同名/外号/称号”与查照问题

在当前实现里,关系表仍以 id INTEGER PRIMARY KEY 为主;同时我已在 chapters 表增加 ulid,用于索引对齐与未来多端同步预留。下一步会把实体表也补齐 ulid/public_id

3.3 关系表的查询优势:从“遍历 JSON”变成“几行 SQL”

多对多关系抽出来之后,很多功能会突然变得简单、可靠、可优化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 例 1:南海鳄神拥有哪些功法?按熟练度排序
SELECT
  c.name AS character_name,
  m.name AS method_name,
  cc.proficiency,
  cc.note
FROM characters c
JOIN char_cultivations cc ON cc.char_id = c.id
JOIN cultivation_methods m ON m.id = cc.method_id
WHERE c.name = '南海鳄神'
ORDER BY cc.proficiency DESC;

-- 例 2:蛮鳞古族有哪些成员?哪些是现役?职位是什么?
SELECT
  o.name AS org_name,
  c.name AS character_name,
  ca.position,
  ca.is_current
FROM organizations o
JOIN char_affiliations ca ON ca.org_id = o.id
JOIN characters c ON c.id = ca.char_id
WHERE o.name = '蛮鳞古族'
ORDER BY ca.is_current DESC, ca.position;

甚至“推测/未填坑”这种看似散文的内容,只要补上 subject_type + subject_id,就会立刻变成强可检索的结构化知识点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- 例 3:未填坑关联到某个角色,并按挖坑章节排序
SELECT
  um.id AS mystery_id,
  um.content,
  c.name AS subject_character_name,
  um.created_at_chapter AS created_at_chapter_no
FROM unresolved_mysteries um
JOIN characters c
  ON um.subject_type = 'character'
 AND um.subject_id = c.id
WHERE um.status = 'open'
ORDER BY created_at_chapter_no ASC;

3.4 工程落地:不从“表设计”开始,而从“读写路径”开始

迁移最容易翻车的点,不是 schema 漂亮不漂亮,而是读写路径太激进。

我的策略是“先让系统跑起来,再逐步让关系表成为主路径”:

  • 迁移脚本:提供 KV → 关系表的导入脚本,让历史数据可以逐步搬进新结构
  • 存储层兜底:读取优先走关系表,同时把 JSON 仍写回 kv_store(过渡期备份/回滚)
    • 这能在不打断现有功能的前提下,把主读路径慢慢切到关系表

同时,这一阶段一定要补齐“删除语义”,否则 UI 会出现典型问题:“看起来删了,刷新又回来了”。

3.5 一个现实的折中:mentioned_character_ids(反范式字段)

严格来讲,“本章提及角色”可以通过结构化实体引用表(或通过 FTS/NER 解析)在查询时动态计算;但为了让章节库 UI 做“角色筛选”和“本章提及角色展示”更直观,我新增了 chapters.mentioned_character_ids,用 JSON 字符串保存角色表 id 数组。

与此同时,chapters.primary_character_id 对应的“主视角” UI 与检索过滤已移除:多视角写作里,用单一字段表达视角往往会制造更多误导;字段暂时保留,仅用于兼容与未来可能的重新设计。


4. 小结

这篇文章把“事实层”的演进路线讲清楚了:

  • 从 JSON 文件起步,快速跑通
  • 迁到 SQLite KV,把备份与读写路径先统一
  • 再引入关系表,把世界设定从“文本堆”推进到“实体关系系统”

下一篇会把“索引层”讲透:向量检索如何落地、FTS5 与向量如何做混合检索、如何把索引扩展到全图谱,以及为什么云化优先尝试 Cloudflare: