GEO测试数据怎么存?从 Query、Run、Entity 到 Citation 的数据库建模
企业开始长期做 GEO(Generative Engine Optimization,生成式引擎优化)测试后,真正棘手的问题往往不是:
怎么再多测几个AI平台?
而是:
测完以后,这些数据到底应该怎么存?
很多项目第一版都是 Excel:
问题 平台 时间 企业是否出现 AI回答 来源
广州有哪些GEO服务公司? 某AI平台 2026-08-15 是 …… example.com
几十条数据时没有问题。
但测试一旦进入第二轮、第三轮,甚至形成长期监测,很快会遇到一系列工程问题:
同一个问题测试20次,怎么区分每一次执行?
一个问题只改了几个字,还能不能和上一轮直接比较?
同一个平台的Web和App是不是同一种测试环境?
平台公开显示的模型发生变化,历史数据怎么保留?
一次提问重新生成了3次回答,应该算一个Run还是三个Run?
AI提到了企业简称,但到底是不是目标主体?
一个回答显示5个来源,怎么建立一对多关系?
一个来源只支持回答中的一句话,怎么表示?
人工复核过的数据以后规则发生变化,能不能重新计算?
如果这些问题没有提前设计好,后面再写多少 Python 统计代码,都只是建立在不稳定的数据基础上。
所以,一个真正可长期运行的 GEO 数据系统,至少应该解决三件事:
数据能不能完整保存;
判断能不能追溯;
不同阶段结果能不能可靠比较。
本文从数据库建模角度,设计一套适合 GEO 测试、复测、来源核验和后续统计分析的数据结构。
一、先明确:一次GEO测试到底包含哪些实体
最常见的第一版表结构通常类似:
问题
平台
测试时间
回答
是否出现企业
地区是否正确
业务是否正确
来源1
来源2
来源3
备注
这张表的问题不是字段太少。
而是:
它把不同生命周期、不同基数的数据对象塞进了同一行。
一次完整测试,至少涉及这些实体:
Project
Query Intent
Query Version
Platform
Test Run
Response
Entity
Entity Mention
Source Document
Citation
Claim
Verification
它们之间并不是简单的一对一关系。
更接近:
PROJECT
│
├── QUERY_INTENT
│ │
│ └── QUERY_VERSION
│ │
│ └── TEST_RUN ───── PLATFORM
│ │
│ └── RESPONSE
│ │
│ ┌─────────────┼─────────────┐
│ │ │ │
│ ▼ ▼ ▼
│ ENTITY_MENTION CLAIM CITATION
│ │ │ │
│ ▼ │ ▼
│ ENTITY │ SOURCE_DOCUMENT
│ │
│ └──── CLAIM_SUPPORT
│
└── VERIFICATION / ANALYTICS
这个关系图先解决一个核心认知:
GEO测试不是“一行结果”,而是一组相互关联的事件和实体。
二、不要直接拿问题文本做主键
假设第一轮问题是:
广州有哪些GEO服务公司?
第二轮有人把它改成:
广州有哪些做GEO优化的公司?
第三轮又变成:
广州有哪些GEO优化服务商?
三句话的搜索意图接近,但文本已经发生变化。
如果直接使用:
query_text
作为问题唯一标识,就会出现两个极端。
一种是把三句话错误地当成完全相同的问题。
另一种是把它们拆成三个毫无关联的问题。
更合理的设计应该区分:
问题意图;
和:
问题具体版本。
因此建议拆成两张表。
CREATE TABLE query_intent (
query_intent_id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
intent_name TEXT NOT NULL,
query_type TEXT,
business_line TEXT,
region TEXT,
priority INTEGER DEFAULT 0,
created_at TEXT NOT NULL
);
然后单独保存版本:
CREATE TABLE query_version (
query_version_id TEXT PRIMARY KEY,
query_intent_id TEXT NOT NULL,
version_no INTEGER NOT NULL,
query_text TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1
CHECK (is_active IN (0, 1)),
created_at TEXT NOT NULL,
FOREIGN KEY (query_intent_id) REFERENCES query_intent(query_intent_id), UNIQUE (query_intent_id, version_no));
例如:
query_intent_id = QI001
intent_name = 广州GEO服务商推荐
QV001
version_no = 1
query_text = 广州有哪些GEO服务公司?
QV002
version_no = 2
query_text = 广州有哪些GEO优化服务商?
这样既能知道:
两个问题属于同一个测试意图。
又不会丢失:
每一次正式测试究竟用了哪个文本版本。
这对于阶段复测尤其重要。
三、问题分类是分析维度,不是行业标准
问题库规模扩大以后,通常需要给问题分类。
例如:
region_service
brand
business
scenario
comparison
purchase_decision
这些字段可以放在 query_intent 中。
它们的作用不是证明:
GEO必须使用某套统一的问题分类。
而是方便后续统计:
地区类问题表现如何?
具体业务问题和品牌问题差多少?
哪条业务线的主体关联更弱?
所以这里有一个数据库设计原则:
分类应该服务分析,不要让分类反过来绑死数据模型。
如果未来分类体系变化,最好调整映射层,而不是破坏历史测试记录。
四、Platform只保存稳定身份,测试环境必须进入Run
上一版最容易出错的设计之一,是把:
model_label
直接放在平台表。
问题在于,平台可能会变化。
同一个AI产品:
7月公开显示模型A;
8月切换到模型B;
Web和App可能也不是完全相同的入口。
如果直接覆盖:
platform.model_label
历史记录就可能错误地继承新值。
因此 platform 更适合只保存相对稳定的信息:
CREATE TABLE platform (
platform_id TEXT PRIMARY KEY,
platform_name TEXT NOT NULL,
provider_name TEXT,
notes TEXT
);
真正和“当次测试环境”有关的数据,放入 test_run:
surface
model_label
login_state
app_version
tested_at
换句话说:
Platform 是“在哪里测”。
Run Environment 是“当时在什么条件下测”。
这是两个不同概念。
五、Test Run应该表示一次实验执行
test_run 是整个 GEO 复测体系中最核心的表之一。
推荐结构:
CREATE TABLE test_run (
run_id TEXT PRIMARY KEY,
query_version_id TEXT NOT NULL,
platform_id TEXT NOT NULL,
surface TEXT, model_label TEXT, login_state TEXT, app_version TEXT, test_round INTEGER, tested_at TEXT NOT NULL, operator TEXT, environment_json TEXT, FOREIGN KEY (query_version_id) REFERENCES query_version(query_version_id), FOREIGN KEY (platform_id) REFERENCES platform(platform_id));
例如:
run_id = RUN_20260815_000001
query_version_id = QV001
platform_id = P01
surface = Web
model_label = 某平台当时公开显示的模型
login_state = logged_in
test_round = 2
tested_at = 2026-08-15T15:32:18+08:00
这时候:
query_intent_id
表示测试意图;
query_version_id
表示实际问了什么;
platform_id
表示在哪个平台;
run_id
表示这是哪一次实验执行。
六、一次Run不一定只有一个Response
这是长期做测试时非常容易忽略的一点。
假设用户问同一个问题以后:
第一次生成一个回答;
点击“重新生成”;
又得到第二个回答;
第三次再次重新生成。
这三条回答:
属于同一个实验条件。
但它们又不是同一个生成结果。
所以 test_run 和 response 最好设计成:
一对多。
CREATE TABLE response (
response_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
attempt_no INTEGER NOT NULL DEFAULT 1,
raw_text TEXT NOT NULL, raw_payload TEXT, response_status TEXT, captured_at TEXT NOT NULL, screenshot_path TEXT, content_hash TEXT, FOREIGN KEY (run_id) REFERENCES test_run(run_id), UNIQUE (run_id, attempt_no));
于是:
RUN001
├── RESPONSE001 attempt_no = 1
├── RESPONSE002 attempt_no = 2
└── RESPONSE003 attempt_no = 3
后续就可以明确区分:
同一次测试条件下的生成波动;
和:
不同时间、不同轮次的阶段变化。
这对于分析生成随机性非常重要。
七、原始回答应该视为“不可变数据”
GEO数据系统里最应该保护的不是统计结果。
而是:
原始观测。
例如:
raw_text
raw_payload
screenshot
captured_at
一旦正式归档,就不应该因为后续人工觉得:
“这句话没用”
而修改原文。
更稳妥的工程原则是:
Raw Data = Immutable
也就是:
原始层只追加,不覆盖。
如果后续发现:
主体判断错误;
地区判断需要修改;
来源核验有误;
应该修改的是:
verification
而不是:
response.raw_text
为了进一步检查原始数据是否被修改,还可以保存:
content_hash
例如对回答正文计算 SHA-256。
这样以后能够验证:
当前归档内容是否仍然与最初采集内容一致。
八、企业主体不能只设计成一个关键词
很多系统最初会直接使用:
if “某公司” in response:
mentioned = True
这种方法可以做最早期原型,但无法承担正式数据判断。
因为一家企业可能同时存在:
公司全称;
品牌;
简称;
旧名称;
同名主体;
近似名称。
所以首先需要建立:
entity
表。
CREATE TABLE entity (
entity_id TEXT PRIMARY KEY,
legal_name TEXT,
brand_name TEXT,
entity_type TEXT,
region TEXT,
is_target INTEGER NOT NULL DEFAULT 0
CHECK (is_target IN (0, 1)),
created_at TEXT NOT NULL
);
别名不建议长期塞在:
aliases_json
如果需要规范管理,更适合单独拆表:
CREATE TABLE entity_alias (
alias_id TEXT PRIMARY KEY,
entity_id TEXT NOT NULL,
alias_text TEXT NOT NULL,
alias_type TEXT,
is_active INTEGER NOT NULL DEFAULT 1
CHECK (is_active IN (0, 1)),
FOREIGN KEY (entity_id) REFERENCES entity(entity_id), UNIQUE (entity_id, alias_text));
这样后续:
增加别名;
停用旧别名;
统计不同名称出现频率;
都会更容易处理。
九、Mention负责保存“模型原文到底写了什么”
实体表记录的是:
我们认定的企业是谁。
但模型原文出现的是:
某段文本。
两者必须分开。
建议建立:
CREATE TABLE entity_mention (
mention_id TEXT PRIMARY KEY,
response_id TEXT NOT NULL,
mention_text TEXT NOT NULL,
start_offset INTEGER, end_offset INTEGER, candidate_entity_id TEXT, match_status TEXT NOT NULL CHECK ( match_status IN ( 'exact', 'alias_confirmed', 'ambiguous', 'wrong_entity', 'unverified' ) ), FOREIGN KEY (response_id) REFERENCES response(response_id), FOREIGN KEY (candidate_entity_id) REFERENCES entity(entity_id));
例如AI写:
ABC科技
数据库保存:
mention_text = ABC科技
人工确认它对应:
candidate_entity_id = E001
同时:
match_status = alias_confirmed
这比直接保存:
entity_id = E001
更完整。
因为你永远保留了:
模型实际输出文本。
十、主体命中、业务匹配、地区正确必须拆开
企业名称出现,并不等于回答质量高。
例如回答:
ABC科技是一家位于深圳的网站建设公司。
实际目标企业虽然叫ABC科技,但:
地区是广州;
主营业务也不是网站建设。
这时至少应该产生三类判断:
维度 结果
主体身份 correct
地区信息 incorrect
业务信息 incorrect
因此不要把所有信息压成:
hit = true
更专业的模型可以建立:
verification
表。
CREATE TABLE verification (
verification_id TEXT PRIMARY KEY,
response_id TEXT NOT NULL,
mention_id TEXT,
verification_type TEXT NOT NULL, result TEXT NOT NULL, reason TEXT, reviewer TEXT, reviewed_at TEXT NOT NULL, FOREIGN KEY (response_id) REFERENCES response(response_id), FOREIGN KEY (mention_id) REFERENCES entity_mention(mention_id));
例如:
verification_type = entity_identity
result = correct
或者:
verification_type = business_match
result = partial
再或者:
verification_type = region_match
result = incorrect
这样:
主体提及;
主体准确;
业务匹配;
地区准确;
就成为不同指标。
十一、Source Document和Citation必须分开
这也是 GEO 来源数据里非常重要的一层。
假设:
https://example.com/about
在100次AI回答中被展示过。
这个URL本身是:
一个来源文档。
但它在每次回答里出现:
是一次引用展示事件。
所以更标准的设计应该拆成:
source_document
和:
response_citation
先保存文档:
CREATE TABLE source_document (
document_id TEXT PRIMARY KEY,
canonical_url TEXT NOT NULL,
domain TEXT,
title TEXT,
source_type TEXT,
first_seen_at TEXT,
last_seen_at TEXT,
UNIQUE (canonical_url));
然后保存某次回答中的展示关系:
CREATE TABLE response_citation (
citation_id TEXT PRIMARY KEY,
response_id TEXT NOT NULL,
document_id TEXT NOT NULL,
citation_order INTEGER, displayed_title TEXT, displayed_url TEXT, displayed_text TEXT, captured_at TEXT NOT NULL, FOREIGN KEY (response_id) REFERENCES response(response_id), FOREIGN KEY (document_id) REFERENCES source_document(document_id));
这样:
Document A
只存一次。
但可以对应:
Citation 001
Citation 017
Citation 163
…
这才符合数据库规范化设计。
十二、为什么只能叫Citation,不能随便叫Retrieval Source
这一层名称尤其重要。
如果最终界面显示:
来源:example.com
我们能够确认的是:
这个来源被展示给用户了。
所以可以叫:
citation
visible_source
displayed_source
但不能仅凭最终界面直接命名成:
retrieved_document
model_context
retrieval_source
因为这些词隐含了我们已经知道:
模型检索了什么;
候选集包含什么;
哪些文档进入上下文。
实际往往没有这些内部信息。
因此:
citation_count = 0
只能表示:
当前回答没有观察到可见引用。
不能推出:
内部没有发生检索。
这是数据建模中的“观测边界”。
数据库字段名称应该和你实际能够证明的事实一致。
十三、Citation还不够,需要继续拆Claim
假设AI回答:
A公司成立于2018年,主要提供GEO服务,累计服务超过500家企业。
这里实际上包含至少三个独立事实:
Claim 1:A公司成立于2018年
Claim 2:A公司主要提供GEO服务
Claim 3:A公司累计服务超过500家企业
即使回答展示了:
example.com/about
也不能直接认为:
这个来源支持全部三句话。
所以如果项目需要做高质量来源核验,可以继续拆:
CREATE TABLE response_claim (
claim_id TEXT PRIMARY KEY,
response_id TEXT NOT NULL,
claim_text TEXT NOT NULL,
start_offset INTEGER,
end_offset INTEGER,
claim_type TEXT,
FOREIGN KEY (response_id) REFERENCES response(response_id));
于是:
Response
↓
Claim
成为明确关系。
十四、建立Claim与Citation的证据关系
接下来增加:
claim_support
CREATE TABLE claim_support (
support_id TEXT PRIMARY KEY,
claim_id TEXT NOT NULL,
citation_id TEXT NOT NULL,
support_status TEXT NOT NULL CHECK ( support_status IN ( 'supported', 'partially_supported', 'not_supported', 'unable_to_verify' ) ), evidence_text TEXT, reviewer TEXT, reviewed_at TEXT, FOREIGN KEY (claim_id) REFERENCES response_claim(claim_id), FOREIGN KEY (citation_id) REFERENCES response_citation(citation_id), UNIQUE (claim_id, citation_id));
于是整个证据链就变成:
Response
↓
Claim
↓
Claim Support
↓
Citation
↓
Source Document
这套结构能够真正回答:
AI回答中的哪一句话,是被哪个可见来源实际支持的?
而不是只统计:
“这个回答有5个来源。”
两种分析价值完全不同。
十五、原始层、核验层和分析层必须彻底分开
到这里,整个数据系统可以明确分成三层。
数据层 保存内容 是否允许人工判断
Raw Layer Query、Run、Response、Citation 否
Verification Layer Entity Match、Claim Support、人工核验 是
Analytics Layer 提及率、准确率、业务匹配率等 由规则计算
Raw Layer负责:
当时到底发生了什么。
Verification Layer负责:
我们后来如何判断。
Analytics Layer负责:
一批数据最终计算出了什么。
这种分层非常重要。
例如:
主体提及率 = 40%
不应该写回每一条 response。
因为40%不是原始事实。
它是:
一组记录按照当前判定规则计算出的聚合结果。
只要底层数据和核验结果还在,指标应该可以随时重新计算。
十六、完整SQLite Schema应该补上约束和索引
为了突出关系,很多教程代码会省略数据库约束。
但真正进入工程实现以后,至少应该考虑:
PRAGMA foreign_keys = ON;
否则 SQLite 默认环境下,外键约束可能并没有真正生效。
还应该给高频查询字段加索引。
例如:
CREATE INDEX idx_query_version_intent
ON query_version(query_intent_id);
CREATE INDEX idx_test_run_query
ON test_run(query_version_id);
CREATE INDEX idx_test_run_platform_time
ON test_run(platform_id, tested_at);
CREATE INDEX idx_response_run
ON response(run_id);
CREATE INDEX idx_mention_response
ON entity_mention(response_id);
CREATE INDEX idx_citation_response
ON response_citation(response_id);
CREATE INDEX idx_citation_document
ON response_citation(document_id);
CREATE INDEX idx_claim_response
ON response_claim(response_id);
这类索引解决的是后续很常见的查询:
某个问题所有历史Run;
某个平台某时间段测试;
某条Response出现了哪些主体;
哪个Domain被展示次数最多;
某个来源支持了哪些Claim。
当数据从几百条增长到几十万条以后,这些设计就会开始产生明显区别。
十七、还需要考虑幂等写入和重复采集
真实自动化采集系统还会遇到一个问题:
同一次结果因为重试被写入两遍怎么办?
这属于幂等性问题。
可以结合:
run_id
attempt_no
content_hash
captured_at
进行判断。
例如:
UNIQUE (run_id, attempt_no)
保证一次Run不会出现两个相同尝试编号。
同时:
content_hash
可以辅助检测完全相同的重复响应。
对于来源文档,则使用:
UNIQUE (canonical_url)
避免相同页面被不断重复创建成新的Document。
注意:
去重规则不能只靠URL字符串。
因为真实网站还可能存在:
UTM参数;
锚点;
HTTP/HTTPS;
尾部 /;
移动端参数。
因此进入规模化采集以后,最好增加:
URL canonicalization。
这已经属于正式采集系统的数据工程问题。
十八、数据可追溯性比最终百分比更重要
一个专业的 GEO 数据系统,最终应该支持从指标一直追到原始证据。
例如看到:
某平台主体提及率 = 40%
应该能够继续追到:
40%
↓
哪些Response被统计为命中
↓
对应哪些Entity Mention
↓
为什么判定为正确主体
↓
对应哪个Run
↓
测试时用了哪个Query Version
↓
当时平台、入口、模型标签是什么
↓
完整AI原始回答是什么
↓
当时展示了哪些Citation
↓
哪些Claim获得了哪些来源支持
这就是:
Data Lineage。
或者更直接说:
数据血缘。
如果系统最后只剩:
平台A:40%
平台B:25%
平台C:15%
却无法回到原始回答和判定依据,那么这个统计结果的诊断价值会大幅下降。
GEO测试真正需要建设的是:
可复核的数据链。
而不只是一张结果表。
十九、CSV、SQLite、PostgreSQL怎么选
项目早期并不需要一开始就上复杂数据库。
阶段 更适合 原因
方法验证 CSV / Excel 简单、业务人员易用
本地技术化测试 SQLite 支持SQL、无需服务器、适合Python
多项目长期系统 PostgreSQL 并发、权限、复杂查询和在线服务更强
SQLite很适合:
第一套可运行的GEO数据系统。
PostgreSQL更适合系统开始出现:
多人协作;
API写入;
任务调度;
多个客户;
权限隔离;
Web管理后台;
大量并发查询
以后再升级。
不要因为未来“可能有百万数据”,第一天就把系统设计成分布式数据平台。
数据库设计专业与否,不等于基础设施越复杂越好。
二十、最终推荐的数据链
经过上面的拆分,一套相对完整的 GEO 数据体系可以整理成:
Project
↓
Query Intent
↓
Query Version
↓
Test Run
↓
Response
├── Entity Mention → Entity
├── Claim
└── Citation → Source Document
│
└── Claim Support
↓
Verification
↓
Analytics
其中最关键的几个设计原则可以压缩成这张表:
原则 工程意义
问题意图和问题版本分开 保证复测可比性
每次测试有唯一Run 保存实验执行历史
一个Run允许多个Response 记录生成波动
原始回答不可覆盖 保证原始事实完整
Entity与Mention分离 支持别名、歧义和误匹配
Document与Citation分离 正确表达一对多引用关系
Claim与Citation建立支持关系 判断来源真正支持什么
Raw / Verification / Analytics分层 防止原始数据与判断污染
加外键、约束和索引 保证数据完整性与查询效率
所有指标必须能回溯 保证数据可复核
结语
GEO测试真正进入工程化以后,问题已经不再只是:
“这个平台有没有提到企业?”
而是要能够回答:
问了什么?
用的是哪个问题版本?
在什么平台、入口和环境下测试?
这是第几次执行?
一次执行生成了几个回答?
AI原始回答到底是什么?
出现的名称是不是正确主体?
哪些业务和地区信息是准确的?
页面显示了哪些来源?
哪个来源真正支持了回答里的哪项事实?
最终统计指标能不能一路回到这些原始数据?
所以,一个真正适合长期GEO测试的数据模型,不应该只有:
问题 + 平台 + 是否出现
而应该逐渐形成:
Query
→ Run
→ Response
→ Entity / Claim / Citation
→ Verification
→ Analytics
当这套数据链建立以后,后面无论做:
多平台提及率统计;
主体准确率;
业务匹配率;
来源Domain分析;
阶段复测;
异常检测;
Python自动报表;
甚至进一步构建GEO测试后台,
才真正拥有稳定的数据基础。
下一篇再基于这套Schema进入分析层:
《用Python统计多平台GEO测试结果:提及率、准确率和业务命中率怎么计算》
到那一步,就不再只讨论指标定义,而是直接从 Query → Run → Response → Verification 这套数据模型生成可复现的统计结果。
