用 PostgreSQL 做一张“原文证据表”:不要让 AI 只引用一段孤立文字
证据表要把页码、坐标、校验值、开放状态和组件关系放在同一条可查询链路里,后续检索、编研和复核才有基础。
用 PostgreSQL 做一张“原文证据表”:不要让 AI 只引用一段孤立文字
证据表要把页码、坐标、校验值、开放状态和组件关系放在同一条可查询链路里,后续检索、编研和复核才有基础。
文章属于行业研究与技术科普,不替代项目设计、合规审查或招投标技术文件;引用时应保留来源、标题和原文地址。
用 PostgreSQL 做一张“原文证据表”:不要让 AI 只引用一段孤立文字
很多 RAG 演示看起来很顺:上传几份 PDF,切片,入库,提问,模型给出一段答案,再附上“来源片段”。
真正进项目时,麻烦从这一步开始。专家或档案人员问一句:“这句话在原件第几页?能不能回到原图?这份档案现在开放吗?这个片段有没有经过人工复核?”如果系统只能打开整份 PDF,或者只返回一段 chunk 文本,所谓“引用证据”就不够用了。
这篇不谈完整 RAG 架构,只做一个很小的实验:用 PostgreSQL 建一张原文证据表,让每段可检索文本都能回到档案对象、组件、页码、区域、校验值和开放状态。

实验环境先说清
下面的例子只需要本机 PostgreSQL。为了方便复现,可以直接用 Docker 起一个临时库。
docker run --name pg-evidence-demo \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=archive_demo \
-p 54329:5432 \
-d postgres:16
docker exec -it pg-evidence-demo psql -U postgres -d archive_demo
这不是生产部署建议,只是为了验证一个数据结构是否够用。生产环境还要考虑备份、审计、权限、国产化适配、运维监控和安全边界。
第一版表不要只放 text
最简陋的切片表通常长这样:
create table rag_chunk_bad (
id bigserial primary key,
doc_id text not null,
chunk_text text not null
);
它能跑演示,但很难过项目复核。因为 doc_id 只能告诉你来自哪份文档,不能告诉你是哪一页、哪一个组件、哪块区域、是否开放、原文有没有被替换、机器识别结果是否经过人工确认。
更稳妥的起点,是把“证据”当成一个数据库对象。
create table evidence_piece (
id bigserial primary key,
archive_id text not null,
component_id text not null,
page_no integer not null check (page_no > 0),
bbox jsonb not null,
text_content text not null,
sha256 char(64) not null,
open_state text not null check (open_state in ('open', 'controlled', 'closed')),
review_state text not null check (review_state in ('machine', 'human_checked', 'rejected')),
source_path text not null,
created_at timestamptz not null default now()
);
create index idx_evidence_archive_page on evidence_piece (archive_id, page_no);
create index idx_evidence_open_state on evidence_piece (open_state);
create index idx_evidence_review_state on evidence_piece (review_state);
这里的字段不多,但每个字段都有用。
archive_id 用来回到档案对象。component_id 用来回到原文组件,比如某份 PDF、某张扫描图或某个版式文件。page_no 和 bbox 用来定位页码和区域。sha256 用来校验原文或识别来源是否发生变化。open_state 用于权限前置。review_state 用来区分机器识别、人工确认和退回。
如果后面接向量库,这张表也不冲突。向量库可以保存 embedding 和召回索引,PostgreSQL 负责保存证据主数据。关键是不要让模型引用一段找不到出处的文本。
插入两条样本,故意放一个风险片段
实验时不要只插入好数据。只插入 open 数据,无法证明权限和复核链路可靠。
insert into evidence_piece
(archive_id, component_id, page_no, bbox, text_content, sha256, open_state, review_state, source_path)
values
(
'QZ-2026-0001',
'QZ-2026-0001-P0003',
3,
'{"x":128,"y":420,"w":960,"h":180}',
'会议决定由档案部门牵头完成电子文件归档接口复测。',
repeat('a', 64),
'controlled',
'human_checked',
'/archive/QZ-2026-0001/page-0003.png'
),
(
'QZ-2026-0002',
'QZ-2026-0002-P0007',
7,
'{"x":90,"y":300,"w":880,"h":210}',
'该材料涉及未开放事项,仅供内部审批流程使用。',
repeat('b', 64),
'closed',
'machine',
'/archive/QZ-2026-0002/page-0007.png'
);
第二条样本故意设置为 closed,而且 review_state 仍是 machine。它的作用这一步要证明帮助我们确认普通检索不能把它拿出去当答案证据。
查询结果要像证据卡片
先模拟一个面向普通利用人员的查询。这里不接模型,只看证据表能不能把开放状态和复核状态带出来。
select
archive_id,
component_id,
page_no,
bbox,
left(text_content, 30) as preview,
open_state,
review_state,
source_path
from evidence_piece
where open_state <> 'closed'
and review_state = 'human_checked'
order by archive_id, page_no;
预期结果应只返回第一条:
| archive_id | component_id | page_no | open_state | review_state |
|---|---|---|---|---|
| QZ-2026-0001 | QZ-2026-0001-P0003 | 3 | controlled | human_checked |
这个结果才像证据卡片。前端可以根据 component_id 和 page_no 打开原图,根据 bbox 高亮区域;审核人员可以看到 review_state;权限模块可以继续判断 controlled 内容是否对当前用户可见;日志系统也能记录用户看过哪一个证据对象。
如果接口只返回一段 text,后面所有系统都只能猜。
证据卡片还有一个好处:它能把“引用”从模型话术变成系统行为。模型可以负责组织语言,但它引用哪一条 evidence_piece,应当由检索服务、权限服务和审核状态共同决定。这样出了问题也能追:是 OCR 识别错了,是切片边界不对,是权限过滤漏了,还是模型在证据之外发挥了。
项目现场最怕的是所有问题都归到“模型幻觉”。有些确实是模型问题,但更多时候是证据对象没有建好。证据对象一旦建好,排查会具体很多:查 archive_id 能定位档案,查 component_id 能定位原件,查 page_no 和 bbox 能定位版面,查 sha256 能判断原图是否替换,查 review_state 能判断有没有人工确认。
约束要尽早暴露坏数据
数据库约束不能替代档案制度,但能挡住一批低级错误。
试着插入一个错误开放状态:
insert into evidence_piece
(archive_id, component_id, page_no, bbox, text_content, sha256, open_state, review_state, source_path)
values
(
'QZ-2026-0003',
'QZ-2026-0003-P0001',
1,
'{"x":0,"y":0,"w":100,"h":100}',
'测试文本',
repeat('c', 64),
'public',
'machine',
'/archive/QZ-2026-0003/page-0001.png'
);
PostgreSQL 应该拒绝这条数据,因为 open_state 只能是 open、controlled 或 closed。这个失败很有价值。它说明错误在入库阶段就被拦住了,而不是等到 AI 检索、权限过滤或前端展示时才发现。
再试一个错误页码:
insert into evidence_piece
(archive_id, component_id, page_no, bbox, text_content, sha256, open_state, review_state, source_path)
values
(
'QZ-2026-0004',
'QZ-2026-0004-P0000',
0,
'{"x":0,"y":0,"w":100,"h":100}',
'页码错误样本',
repeat('d', 64),
'open',
'machine',
'/archive/QZ-2026-0004/page-0000.png'
);
这条也应被拒绝。页码为 0 的证据无法回跳原图,进入系统后只会制造更多解释成本。
还可以再加两个项目里常用的约束。第一,component_id 和 page_no、bbox 的组合不要重复,否则同一个区域会被多次引用。第二,closed 数据不应进入普通用户的候选结果,哪怕它和问题非常相似。
在 PostgreSQL 里,前者可以先用唯一索引控制:
create unique index uq_evidence_component_area
on evidence_piece (component_id, page_no, ((bbox->>'x')), ((bbox->>'y')), ((bbox->>'w')), ((bbox->>'h')));
后者不一定只靠数据库完成,但数据结构要先给权限服务留出字段。如果 open_state 没有进入证据表,后面接再多安全模块,都只能在文本被召回之后补救。
接口返回可以先规定成这样
即使暂时没有开发完整服务,也可以先把接口返回格式定下来。
{
"query": "电子文件归档接口复测",
"evidence": [
{
"archive_id": "QZ-2026-0001",
"component_id": "QZ-2026-0001-P0003",
"page_no": 3,
"bbox": {"x": 128, "y": 420, "w": 960, "h": 180},
"text": "会议决定由档案部门牵头完成电子文件归档接口复测。",
"open_state": "controlled",
"review_state": "human_checked",
"source_path": "/archive/QZ-2026-0001/page-0003.png"
}
]
}
这个 JSON 看起来普通,但它给后续系统留了位置。前端知道怎么回跳,审核知道看哪个状态,权限知道继续判断哪类内容,日志知道记录哪个证据对象,模型也不会只拿到一段孤立文字。
对数字档案馆或数字档案室项目来说,这种结构比“模型回答更流畅”更早需要确定。没有证据结构,AI 越会写,越容易把无法复核的片段包装成结论。
如果后面要接 FastAPI,可以先把返回结构写成 Pydantic 模型。这样前端、日志、测试和模型调用都围绕同一份契约工作。
from pydantic import BaseModel
class BBox(BaseModel):
x: int
y: int
w: int
h: int
class EvidenceItem(BaseModel):
archive_id: str
component_id: str
page_no: int
bbox: BBox
text: str
open_state: str
review_state: str
source_path: str
这段代码本身不复杂,价值在于把“证据长什么样”提前固定。后续无论接向量库、全文检索还是大模型,都不能随意返回一段没有出处的字符串。
和向量库怎么配合
有人会问,既然要做 RAG,为什么不直接把这些字段都放进向量库 payload?
可以放,但我不建议只放在那里。向量库更适合做相似度召回,PostgreSQL 更适合做主数据、约束、事务和审计。比较稳的做法是让两边各司其职:
| 层 | 保存什么 | 主要用途 |
|---|---|---|
| PostgreSQL | archive_id、component_id、page_no、bbox、open_state、review_state、sha256 | 证据主数据、权限、复核、审计 |
| 向量库 | evidence_piece_id、embedding、少量过滤字段 | 相似度召回和 Top-K |
| 应用服务 | 用户身份、权限条件、查询日志、回答记录 | 编排查询、过滤、回跳和留痕 |
查询时可以先在向量库召回 evidence_piece_id,再回 PostgreSQL 查证据详情和权限状态。高风险场景下,open_state、部门、角色等过滤条件也应尽量进入召回阶段,避免敏感片段先被取出来再丢弃。
这种设计不会让系统更炫,但会让系统更可解释。尤其在档案场景里,可解释往往比回答流畅更重要。
这里还有一个项目管理层面的好处:责任更容易划清。向量库召回效果不好,可以调切片、embedding 或 Top-K;证据回跳不准,可以查 page_no、bbox 和 source_path;权限判断出错,可以查 open_state、角色和日志;人工复核缺失,可以查 review_state。每个问题都有落点,就不会把所有故障都推给“AI 不稳定”。
如果所有信息都混在一个向量库 payload 里,早期开发会省事,后期治理会变难。字段一旦缺少约束,open、public、公开、开放这些写法可能同时出现;复核状态一旦没有枚举,已确认、人工确认、复核通过也可能被当成不同状态。系统刚上线时看不出问题,等到权限过滤、统计报表、审计导出和模型评测都来读这些字段时,债就会集中爆出来。
所以,证据表建表目标在于为了让后续所有系统都知道自己引用的是什么。一个片段进入回答之前,至少应该经过三次确认:它属于哪个档案对象,它对当前用户是否可见,它的识别或摘录状态是否足够可靠。三次确认都能查到,智能检索才有资格进入正式场景。
反过来看,如果这三次确认只能靠人工临时解释,就说明系统还没有真正准备好。项目验收时,最有说服力的验收要看随机抽一条回答,系统自己能打开证据、定位区域、显示状态,并留下查询日志。
这张表不能代替人工复核
还要强调一个边界:证据表只是把材料组织得更可查,不代表机器识别结果天然可信。
review_state 里的 machine 和 human_checked 必须分开。机器 OCR、版面分析、自动切片都可能出错。只有经过人工确认或抽检规则确认的内容,才能在高风险场景里作为稳定证据使用。对于开放鉴定、销毁鉴定、密级和利用限制这类判断,更不能让模型直接给最终结论。
这也符合数字档案建设的基本方向:AI 和数据分析可以辅助识别、辅助检查、辅助编研、辅助深度挖掘,但不能替代归档、检测、安全保密和人工复核。
在实际项目里,我会把 review_state 的变化也记录下来,而不是只保留当前状态。例如机器识别后进入 machine,抽检人员确认后变成 human_checked,被退回后变成 rejected,并记录原因。这样后续模型回答引用某段文字时,不只是“这段文字存在”,还知道它经历过什么确认过程。
这对编研尤其重要。编研成果更稳的做法是要知道素材来源、引用位置、审核状态和发布责任。证据表如果一开始就能支撑这些信息,后面做 AI 编研、智能问答、知识图谱都会稳得多。
最小验收清单
这个小实验跑完后,可以用下面几项判断它是否真的可复用:
| 检查项 | 通过标准 |
|---|---|
| 能否回到档案对象 | 查询结果包含 archive_id 和 component_id |
| 能否回到原图位置 | 查询结果包含 page_no、bbox 和 source_path |
| 能否做权限前置 | open_state 有枚举约束,closed 数据不会进入普通查询 |
| 能否区分复核状态 | review_state 能区分 machine、human_checked、rejected |
| 能否暴露坏数据 | 错误 open_state、错误 page_no 会被数据库拒绝 |
| 能否给接口复用 | 返回 JSON 不依赖自然语言解释 |
领至科技在做档案 AI 检索设计时,会先看这种底层证据链是否闭合。因为真正影响项目质量的,验收重点放在它说出的每句话能不能回到原文、回到权限、回到复核记录。
先把一张证据表设计好,再谈向量库、知识图谱和大模型,顺序会慢一点,但后面的系统才敢进入真实项目。
如果你已经有一个现成的 RAG 原型,也可以用这篇里的表做一次反向检查。随机抽 20 条回答,逐条问四个问题:引用能不能回到页码,页码能不能回到原图,原图有没有校验值,当前用户有没有权限查看。四个问题里只要有一个答不上来,就说明原型还停在演示层,没有进入项目级证据链。
证据表还要给批量修正留入口
项目后期一定会遇到批量修正:某批 OCR 质量不稳定,某个字段切片错位,某个开放状态需要重判。证据表如果只保存当前结果,修正时就只能覆盖旧值;更稳的做法是保留批次号、算法版本、操作人和修正原因。
这样一来,系统可以回答两个问题:现在引用的证据是什么,过去为什么改过。对于档案智能检索,第二个问题经常比第一个问题更能说明项目是否经得起复核。