`doc_id` 和 `chunk_id` 都有外键,为什么引用仍可能指错文档?重构 RAG 数据关系

假设 citations(引用)表同时保存下面三个字段:

1
2
3
4
message_id = 900
doc_id = 10
chunk_id = 205
chunk_index= 7

数据库检查后发现:message 900 存在,document 10 存在,chunk 205 也存在,所以 INSERT 成功。但 chunk 205 实际属于 document 20,ordinal 是 3。三个值各自满足外键或类型约束,组合起来却表达了一个不存在的来源。

这类错误对 RAG 系统尤其危险。页面仍能展示一条引用,模型回答也可能看起来合理,直到用户点击来源才发现跳到了另一份文档。它不是一次简单的“漏加外键”,而是表中保存了本可推导的重复事实,却没有约束这些事实必须一致。

原始 ER 草图还存在两个类似问题:把 document 与 index 画成一对一,导致 embedding 模型升级后无法保留旧索引;使用 tasks(entity_type, entity_id) 表达多态关联,数据库却不知道 entity_id 应该引用哪张表。

本文围绕这三类故障重建一个适合本地 RAG MVP 的关系模型。先用可运行的 SQLite 示例证明核心约束,再给出 MySQL/InnoDB 的工程 DDL、索引版本切换和跨 Redis 的一致性边界。目标不是设计一套“永远不改”的大而全 schema,而是让错误引用、错位向量和悬空任务在尽可能靠近写入的位置暴露。

1. 为什么给每个 ID 单独加外键仍然不够?

外键只验证它声明的关系:

1
2
citations.doc_id   -> documents.id
citations.chunk_id -> doc_chunks.id

它不会自动知道下面的函数依赖:

1
chunk_id -> document_id, ordinal, text

也就是说,只要知道 chunk_id,就能通过 doc_chunks 唯一确定所属文档、序号和原文。把这些值再次复制进 citations,便产生多个可独立更新的事实来源:

1
2
doc_chunks: chunk 205 belongs to document 20
citations: chunk 205 belongs to document 10

解决方向有三种:

方法 做法 适用条件
删除冗余列 citation 只保存 chunk_id,查询时 JOIN 得到 document 首选,结构最清楚
复合外键 citation 的 (chunk_id, doc_id) 引用 chunk 的唯一组合 确实需要同时存两列时
保存快照 保存当时的 snippet、标题等不可变展示信息 为历史审计有意反规范化

“删除冗余列”和“保存 snippet 快照”并不冲突。doc_id 是仍可从 chunk 推导的当前关系,通常不必复制;snippet 则可能是产品有意保存的历史证据,即使源文档以后重新切片,旧回答仍能展示当时引用的文字。快照要明确命名和用途,不能和当前源数据混为一谈。

2. 最小可运行版本:让错误引用在写入时失败

下面的 Python 3.9+ 程序使用标准库 sqlite3 建立内存数据库。它只保留 documents、chunks、messages 和 citations 四张核心表,用两次故意错误的 INSERT 验证主键、唯一约束和外键。

SQLite 与 MySQL 不是同一个数据库;该程序用于验证关系设计,不代表 MySQL DDL 可以直接复制到 SQLite。SQLite 还需要对每条连接显式启用 PRAGMA foreign_keys = ON

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import sqlite3

SCHEMA = """
PRAGMA foreign_keys = ON;

CREATE TABLE documents (
id TEXT PRIMARY KEY,
title TEXT NOT NULL
);

CREATE TABLE doc_chunks (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
ordinal INTEGER NOT NULL CHECK (ordinal >= 0),
text TEXT NOT NULL,
UNIQUE (document_id, ordinal),
FOREIGN KEY (document_id)
REFERENCES documents(id) ON DELETE CASCADE
);

CREATE TABLE messages (
id TEXT PRIMARY KEY,
content TEXT NOT NULL
);

CREATE TABLE citations (
message_id TEXT NOT NULL,
chunk_id TEXT NOT NULL,
rank INTEGER NOT NULL CHECK (rank > 0),
score REAL NOT NULL,
snippet TEXT NOT NULL,
PRIMARY KEY (message_id, rank),
UNIQUE (message_id, chunk_id),
FOREIGN KEY (message_id)
REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (chunk_id)
REFERENCES doc_chunks(id) ON DELETE RESTRICT
);
"""

def insert_is_rejected(
connection: sqlite3.Connection,
sql: str,
parameters: tuple[object, ...],
) -> bool:
try:
connection.execute(sql, parameters)
except sqlite3.IntegrityError:
return True
return False

def main() -> None:
connection = sqlite3.connect(":memory:")
try:
connection.executescript(SCHEMA)
connection.executemany(
"INSERT INTO documents(id, title) VALUES (?, ?)",
(("doc-a", "Redis Notes"), ("doc-b", "FAISS Notes")),
)
connection.executemany(
"""
INSERT INTO doc_chunks(id, document_id, ordinal, text)
VALUES (?, ?, ?, ?)
""",
(
("chunk-a0", "doc-a", 0, "Redis Streams stores events."),
("chunk-b0", "doc-b", 0, "FAISS searches vectors."),
),
)
connection.execute(
"INSERT INTO messages(id, content) VALUES (?, ?)",
("msg-1", "How are streaming events stored?"),
)
connection.execute(
"""
INSERT INTO citations(message_id, chunk_id, rank, score, snippet)
VALUES (?, ?, ?, ?, ?)
""",
("msg-1", "chunk-a0", 1, 0.91, "Redis Streams stores events."),
)

row = connection.execute(
"""
SELECT d.id, c.ordinal
FROM citations AS ci
JOIN doc_chunks AS c ON c.id = ci.chunk_id
JOIN documents AS d ON d.id = c.document_id
WHERE ci.message_id = ? AND ci.rank = ?
""",
("msg-1", 1),
).fetchone()
assert row is not None
print(f"citation document={row[0]} ordinal={row[1]}")

duplicate_rank_rejected = insert_is_rejected(
connection,
"""
INSERT INTO citations(message_id, chunk_id, rank, score, snippet)
VALUES (?, ?, ?, ?, ?)
""",
("msg-1", "chunk-b0", 1, 0.80, "FAISS searches vectors."),
)
missing_chunk_rejected = insert_is_rejected(
connection,
"""
INSERT INTO citations(message_id, chunk_id, rank, score, snippet)
VALUES (?, ?, ?, ?, ?)
""",
("msg-1", "chunk-missing", 2, 0.70, "missing"),
)

print(
"duplicate_rank_rejected="
f"{str(duplicate_rank_rejected).lower()}"
)
print(
"missing_chunk_rejected="
f"{str(missing_chunk_rejected).lower()}"
)
assert duplicate_rank_rejected and missing_chunk_rejected
finally:
connection.close()

if __name__ == "__main__":
main()

保存为 citation_schema_demo.py 后运行:

1
python3 citation_schema_demo.py

预期输出:

1
2
3
citation document=doc-a ordinal=0
duplicate_rank_rejected=true
missing_chunk_rejected=true

最关键的改进不是某条 CHECK,而是 citations 根本没有 document_idchunk_index 可供写错。查询通过 chunk 关系得到唯一文档;同一 message 的 rank 不能重复,同一个 chunk 也不能被同一 message 重复引用。

ON DELETE RESTRICT 让已有历史引用的 chunk 不能被直接删除。由于 document 到 chunk 是 CASCADE,删除一个仍被 citation 引用的 document 也会被阻止。真实产品通常对 document 使用 deleted_at 软删除,并通过授权过滤隐藏;合规删除则需要专门流程处理历史引用和快照,不能靠一次级联删除碰运气。

3. RAG 数据真正包含哪些关系?

一个保持克制、支持索引版本化的关系可以画成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
erDiagram
user_accounts ||--o{ documents : owns
user_accounts ||--o{ sessions : owns
user_accounts ||--o{ index_versions : builds

documents ||--o{ doc_chunks : contains
sessions ||--o{ messages : contains
messages ||--o{ citations : has
doc_chunks ||--o{ citations : supports

index_versions ||--o{ index_items : contains
doc_chunks ||--o{ index_items : indexed_as

documents o|--o{ jobs : ingest_target
messages o|--o{ jobs : generation_target

这张图有三个刻意变化。

第一,document 不再直接一对一拥有 index。一个索引版本通常包含很多文档的 chunks,同一个 chunk 也可能同时存在于旧、新两个 embedding 版本中,因此 index_versionsdoc_chunks 通过 index_items 形成多对多关系。

第二,citation 只指向 message 和 chunk。document 是 chunk 的父级,不再重复保存。

第三,job 使用两个显式可空目标:ingest job 指向 document,generation job 指向 message。它比 entity_type + entity_id 多态字段更容易建立真实外键。对于只有两类任务的 MVP,这点冗余比无法约束的通用接口更安全。

4. MySQL 中怎样落实 chunk 与 citation 的约束?

下面以 MySQL 8.4/InnoDB 为说明环境。MySQL 版本和 SQL mode 会影响可用语法;迁移必须在项目锁定版本上实际执行,并用 SHOW CREATE TABLE 核对最终约束。较老 MySQL 对 CHECK 的支持和执行行为不同,不能只看建表语句没有报错就认为约束有效。

父表的字段可按产品补充,核心关系如下。这些 MySQL 代码块是迁移片段,假设 user_accountsmessages 等父表已按相同的 unsigned ID 类型创建,不能作为一份独立脚本直接执行;完整迁移还应按外键依赖排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
CREATE TABLE documents (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
original_name VARCHAR(255) NOT NULL,
storage_key VARCHAR(512) NOT NULL,
sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status VARCHAR(32) NOT NULL,
deleted_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_documents_owner_hash (user_id, sha256),
KEY ix_documents_owner_created (user_id, created_at),
CONSTRAINT fk_documents_user
FOREIGN KEY (user_id) REFERENCES user_accounts(id)
ON DELETE RESTRICT,
CONSTRAINT ck_documents_status
CHECK (status IN ('uploaded', 'processing', 'ready', 'failed'))
) ENGINE = InnoDB;

CREATE TABLE doc_chunks (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
document_id BIGINT UNSIGNED NOT NULL,
ordinal INT UNSIGNED NOT NULL,
content LONGTEXT NOT NULL,
token_count INT UNSIGNED NOT NULL,
content_sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_chunks_document_ordinal (document_id, ordinal),
KEY ix_chunks_document (document_id),
CONSTRAINT fk_chunks_document
FOREIGN KEY (document_id) REFERENCES documents(id)
ON DELETE CASCADE
) ENGINE = InnoDB;

CREATE TABLE citations (
message_id BIGINT UNSIGNED NOT NULL,
chunk_id BIGINT UNSIGNED NOT NULL,
rank_no SMALLINT UNSIGNED NOT NULL,
retrieval_score DOUBLE NOT NULL,
snippet TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (message_id, rank_no),
UNIQUE KEY uq_citations_message_chunk (message_id, chunk_id),
KEY ix_citations_chunk (chunk_id),
CONSTRAINT fk_citations_message
FOREIGN KEY (message_id) REFERENCES messages(id)
ON DELETE CASCADE,
CONSTRAINT fk_citations_chunk
FOREIGN KEY (chunk_id) REFERENCES doc_chunks(id)
ON DELETE RESTRICT,
CONSTRAINT ck_citations_rank CHECK (rank_no > 0)
) ENGINE = InnoDB;

外键两端的整数类型、长度和 signed/unsigned 属性必须匹配。MySQL/InnoDB 还要求外键与被引用键有可用索引;现代版本对引用非唯一键的兼容扩展已趋于收紧,工程上应只引用 PRIMARY KEY 或明确的 UNIQUE NOT NULL key。

这里没有给 retrieval_score 添加 0 <= score <= 1。FAISS 的 squared L2 distance 可以大于 1,内积也可能是负数;只有业务已经把分数归一化到确定范围时,才能加这种 CHECK。表中还应记录 index version 或检索运行元数据,以解释分数使用的 metric。

5. 为什么 document 与 index 不应该是一对一?

模型从 embedding-v1 升级到 embedding-v2 时,向量维度、归一化方式或 metric 可能变化。若 document 只有一条 document_indexes 记录,更新新索引会覆盖旧路径;构建中途失败时,在线查询可能同时看到新元数据和旧文件。

更稳妥的结构是不可变版本 + 显式映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
CREATE TABLE index_versions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
version_no BIGINT UNSIGNED NOT NULL,
embedding_model VARCHAR(255) NOT NULL,
embedding_revision VARCHAR(128) NOT NULL,
dimension INT UNSIGNED NOT NULL,
metric VARCHAR(16) NOT NULL,
vectors_path VARCHAR(512) NOT NULL,
file_sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
state VARCHAR(16) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_index_versions_owner_version (user_id, version_no),
CONSTRAINT fk_index_versions_user
FOREIGN KEY (user_id) REFERENCES user_accounts(id)
ON DELETE RESTRICT,
CONSTRAINT ck_index_versions_metric
CHECK (metric IN ('l2', 'inner_product')),
CONSTRAINT ck_index_versions_state
CHECK (state IN ('building', 'ready', 'failed', 'retired')),
CONSTRAINT ck_index_versions_dimension CHECK (dimension > 0)
) ENGINE = InnoDB;

CREATE TABLE index_items (
index_version_id BIGINT UNSIGNED NOT NULL,
vector_position BIGINT UNSIGNED NOT NULL,
chunk_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (index_version_id, vector_position),
UNIQUE KEY uq_index_items_version_chunk (index_version_id, chunk_id),
KEY ix_index_items_chunk (chunk_id),
CONSTRAINT fk_index_items_version
FOREIGN KEY (index_version_id) REFERENCES index_versions(id)
ON DELETE CASCADE,
CONSTRAINT fk_index_items_chunk
FOREIGN KEY (chunk_id) REFERENCES doc_chunks(id)
ON DELETE RESTRICT
) ENGINE = InnoDB;

vector_position 对应 FAISS 返回的行位置,chunk_id 对应业务实体。若使用能保存显式 ID 的 FAISS wrapper,仍应记录版本和映射规则,不能假定所有 index 类型都以同一种方式返回 ID。

一次安全构建大致经历:

1
2
3
4
5
6
INSERT index_versions(state='building')
-> 读取固定快照的 chunks
-> 构建临时 FAISS 文件和 index_items
-> 校验 dimension、ntotal、checksum、抽样查询
-> 将文件移动到不可变版本路径
-> 事务内把 version 标为 ready,并切换 active pointer

FAISS 文件不受 MySQL 事务保护。最后两步之间崩溃时,需要启动扫描或后台修复识别 orphan file、长期 building row 和缺失文件。不能因为数据库 COMMIT 成功,就声称文件系统与数据库实现了原子事务。

6. entity_type + entity_id 为什么难以保证任务目标存在?

下面的通用字段看起来很灵活:

1
2
tasks.entity_type = 'document'
tasks.entity_id = 42

但普通外键无法根据一行里的字符串动态选择 documentsmessages。删除 document 42 后,数据库也无法自动判断哪些 task 变成孤儿。

任务类型有限时,可以使用显式可空外键,并用 CHECK 保证恰好选择正确目标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
CREATE TABLE jobs (
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
celery_task_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NULL,
job_type VARCHAR(16) NOT NULL,
document_id BIGINT UNSIGNED NULL,
message_id BIGINT UNSIGNED NULL,
state VARCHAR(16) NOT NULL,
stage VARCHAR(32) NULL,
progress TINYINT UNSIGNED NOT NULL DEFAULT 0,
error_code VARCHAR(64) NULL,
meta_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_jobs_celery_task (celery_task_id),
KEY ix_jobs_state_updated (state, updated_at),
KEY ix_jobs_document (document_id),
KEY ix_jobs_message (message_id),
CONSTRAINT fk_jobs_document
FOREIGN KEY (document_id) REFERENCES documents(id),
CONSTRAINT fk_jobs_message
FOREIGN KEY (message_id) REFERENCES messages(id),
CONSTRAINT ck_jobs_type_target CHECK (
(job_type = 'ingest' AND document_id IS NOT NULL AND message_id IS NULL)
OR
(job_type = 'generate' AND document_id IS NULL AND message_id IS NOT NULL)
),
CONSTRAINT ck_jobs_state CHECK (
state IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')
),
CONSTRAINT ck_jobs_progress CHECK (progress <= 100)
) ENGINE = InnoDB;

这里故意没有为两个目标外键配置 CASCADE/SET NULL,删除父对象时默认拒绝,避免任务审计记录静默失去目标。MySQL 对 CHECK 使用的列与外键 referential action 组合存在限制,任何改动都应在目标版本真实执行迁移验证。

如果 job 类型将来很多,不要立刻退回无约束多态字段。可以拆成 jobs 公共状态加各类型扩展表,或让每种业务任务拥有自己的表。选择依据是查询方式和完整性需求,不是“一个表看起来更通用”。

7. 最终回答、引用和 job 状态怎样原子提交?

生成完成时,MySQL 内部可以把三类变化放在一个事务里:

1
2
3
messages: status=completed, content=最终回答
citations: 写入本次固定排序和 snippet
jobs: state=succeeded, progress=100

示意流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
START TRANSACTION;

UPDATE messages
SET content = ?, status = 'completed', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'generating';

INSERT INTO citations(
message_id, chunk_id, rank_no, retrieval_score, snippet
) VALUES
(?, ?, 1, ?, ?),
(?, ?, 2, ?, ?);

UPDATE jobs
SET state = 'succeeded', progress = 100, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND state = 'running';

COMMIT;

生产代码必须检查两条 UPDATE 的 affected rows。若 message 已被取消或 job 不再 running,不能仍然插入成功引用。重试还要使用幂等键或先锁定 job,避免重复终止。

Redis done 事件只能在 MySQL 提交后发布:

1
2
MySQL COMMIT
-> XADD event=done

但两者不是一个事务。进程可能在 COMMIT 后、XADD 前崩溃,于是最终结果已存在但 SSE 没有 done。解决方案是让重连端先查最终状态、让 done 发布幂等,或者使用 outbox 表由后台可靠发布。MVP 不一定立即实现完整 outbox,但必须承认并测试这个故障窗口。

8. 索引应该根据查询设计,而不是给每列都加一个

索引服务于具体过滤和排序:

查询 合适的索引起点
按用户列出最近文档 documents(user_id, created_at)
按文档顺序读取 chunks doc_chunks(document_id, ordinal),已由 UNIQUE 覆盖
按会话读取消息 messages(session_id, sequence_no)
找待处理或超时 job jobs(state, updated_at)
按回答顺序读取引用 citations(message_id, rank_no),已由主键覆盖
FAISS 位置映射 chunk index_items(index_version_id, vector_position),已由主键覆盖

MySQL 通常会为外键所需列建立或要求索引,但名称和复合顺序仍应通过 SHOW CREATE TABLEEXPLAIN 和真实查询验证。重复创建单列索引可能浪费写入和存储,却没有改善目标查询。

不要把低基数 status 单独索引就期待所有查询变快。state + updated_at 能支持“找到最早的 running job”之类模式;是否有效仍取决于数据分布和查询计划。

9. 哪些字段适合 JSON,哪些必须关系化?

meta_json 适合保存执行器版本、阶段统计和暂时不稳定的诊断字段,但不适合藏进所有高频查询条件。

判断标准可以很直接:

1
2
需要外键、唯一性、范围约束、频繁过滤或 JOIN -> 普通列/关系表
低频读取、形状随版本变化、只用于诊断 -> JSON 候选

例如 citation 的 chunk_id 必须关系化,因为它决定来源完整性;一次任务的 tokenizer timing 明细可以放 JSON。若后来需要按某个 JSON 字段高频查询,应考虑提升为普通列并迁移,而不是让业务层到处解析 JSON。

10. 常见误区:ER 图看起来连通,为什么数据仍会坏?

误区一:重复保存 doc_id 能少一次 JOIN,所以一定更快

先保证一致性,再用测量决定是否反规范化。citation 查询通常批量 JOIN,正确索引下未必是瓶颈;重复字段若无复合约束,会把性能猜测变成长期数据风险。

误区二:一个 document 永远只有一个 index

模型、维度、metric 和 chunk 规则都会升级。索引是可重建版本,不是 document 的唯一附属文件。

误区三:entity_type + entity_id 是通用任务表的最佳设计

它把参照完整性转移到所有写入代码。类型少时,显式外键更简单、更可查;类型多时再评估子类型表。

误区四:所有外键都使用 ON DELETE CASCADE 最省事

删除 document 可能顺带删除 chunks、index mapping 和历史引用,审计链会无声消失。CASCADE 只用于明确属于父对象且确实应同生共死的数据;业务实体通常优先软删除或 RESTRICT。

误区五:FAISS 文件是最终数据源

FAISS 索引是由 chunk 与 embedding 配置生成的派生物。业务真相、模型 revision 和映射必须存在可审计存储中,索引损坏时应能重建。

误区六:Celery task state 就是 jobs 表

执行器状态可能过期,PENDING 也可能表示未知 ID。业务 job 应有稳定 ID、目标外键和自己的终止状态。

误区七:关掉 foreign_key_checks 导入后再打开就会自动修复

MySQL 重新启用 foreign key checks 不会自动扫描并纠正禁用期间写入的所有不一致数据。正常业务写入应保持检查开启;迁移需要显式校验。

11. 什么时候应该简化这套关系?

如果只是单用户、一次性检索实验,可以暂时只用 documents、chunks 和一个内存映射,没必要先建用户、会话、任务和引用全套表。实验结论是检索质量,不是业务持久化。

如果目标是可演示、可恢复的 RAG 应用,messages、citations、jobs 和 index version 很快会变成必要边界。多用户场景还必须在每次查询中约束 owner,不能因为 chunk ID 难猜就省略授权。

数据量与过滤需求超过本地 FAISS 文件的边界时,可以评估专用向量数据库或其他存储,但关系原则不变:业务文档、消息和引用仍需要明确的权威来源,向量检索结果不能替代授权与事务。

12. 总结

开头那条 citation 之所以能同时通过两个外键却指错文档,是因为 schema 保存了三份本可由 chunk_id 推导的事实,却没有约束它们相互一致。最稳妥的修复不是再加一层应用校验,而是删除没有必要写入的 doc_idchunk_index

重构后的关键原则有五条:

  1. citation 只引用 chunk,并按需要保存明确的历史 snippet 快照;
  2. index 使用不可变版本和 index_items 映射,不与 document 建一对一关系;
  3. job 使用真实外键目标,避免无法约束的 entity_type + entity_id
  4. MySQL 保存业务真相,FAISS 文件和 Redis 事件属于可重建或有期限的数据;
  5. MySQL 与文件系统、Redis 之间没有自动原子事务,需要状态机、幂等和补偿。

最直接的实践建议是:检查每张表中的重复 ID,问一句“它能否由另一个键唯一推导?”如果答案是能,就优先删除;如果为了历史或性能必须保留,就把一致性约束、更新规则和验证测试一起写出来。

参考资料