三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

GEO测试数据怎么存?从 Query、Run、Entity 到 Citation 的数据库建模

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 这套数据模型生成可复现的统计结果。

← 返回列表