文章简介:AI回答采集后,如何存储原始数据以保证可追溯性?本文使用PostgreSQL JSONB设计存储方案,包含raw_answers主表、answer_versions版本表和answer_metrics指标表,并给出Python+SQLAlchemy示例代码。适合后端开发者阅读,最终给出可复用的表设计和存储流程。
问题背景
AI回答采集系统中,原始数据存储常被简化:只保存最终指标(如回答长度、关键词出现次数),但一旦需要回溯异常回答或重新计算指标,就会陷入无源可查的困境。本文设计一套可追溯的存储方案,确保每条回答的来源、版本和上下文都能被精确还原。
为什么选择JSONB
选择PostgreSQL的JSONB类型存储原始回答,而不是拆成多个字段或使用关系表,主要基于以下考虑:
- 结构灵活:AI回答内容本身是嵌套JSON(如包含思考链、工具调用),JSONB天然支持,无需预定义字段。
- 可扩展:后续增加元数据(如token用量、延迟)只需在JSON中添加键值,无需改表结构。
- 查询方便:JSONB支持GIN索引,可对回答内容进行部分检索,例如查找包含特定关键词的回答。
如果回答超过10MB,建议使用外部存储(如S3),在answer_content中存储对象键。本文以常规场景(回答<1MB)为例。
数据库表设计
主表:raw_answers
每条回答对应一条记录,通过request_id+platform+model_name保证唯一性。
sql
CREATE TABLE raw_answers (
id BIGSERIAL PRIMARY KEY,
request_id VARCHAR(64) NOT NULL, -- 唯一请求ID
query_text TEXT NOT NULL, -- 原始问题
platform VARCHAR(32) NOT NULL, -- 平台标识,如 'openai', 'wenxin'
model_name VARCHAR(64) NOT NULL, -- 模型名称,如 'gpt-4', 'ernie-bot'
model_version VARCHAR(32), -- 模型版本号,如 '2024-01-25'
answer_content JSONB NOT NULL, -- 完整回答内容(JSON格式)
metadata JSONB, -- 元数据,如 token用量、延迟、重试次数
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
is_deleted BOOLEAN DEFAULT FALSE, -- 软删除标记
UNIQUE(request_id, platform, model_name)
);
CREATE INDEX idx_raw_answers_request_id ON raw_answers(request_id);
CREATE INDEX idx_raw_answers_created_at ON raw_answers(created_at);
CREATE INDEX idx_raw_answers_platform_model ON raw_answers(platform, model_name);
为什么用request_id+platform+model_name作为唯一约束? 同一请求可能同时调用多个模型,每个模型返回不同回答,需要独立存储;而同一模型的重试请求应覆盖旧回答,但旧版本需保留。
版本追踪表:answer_versions
当回答因重试或手动修正被覆盖时,旧版本移入此表。
sql
CREATE TABLE answer_versions (
id BIGSERIAL PRIMARY KEY,
raw_answer_id BIGINT NOT NULL REFERENCES raw_answers(id),
answer_content JSONB NOT NULL,
metadata JSONB,
version INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
change_reason VARCHAR(128) -- 变更原因,如 'retry', 'manual_correction'
);
CREATE INDEX idx_answer_versions_raw_id ON answer_versions(raw_answer_id);
版本管理策略:只保留最近N个版本(如5个),通过定时任务清理旧版本。变更原因字段务必填写,否则后续审计困难。
指标结果表:answer_metrics
从原始回答计算出的指标(如长度、关键词计数)存储在此表,与原始数据分离,便于重算。
sql
CREATE TABLE answer_metrics (
id BIGSERIAL PRIMARY KEY,
raw_answer_id BIGINT NOT NULL REFERENCES raw_answers(id),
metric_name VARCHAR(64) NOT NULL, -- 指标名称,如 'length', 'keyword_count'
metric_value NUMERIC NOT NULL,
computed_at TIMESTAMP NOT NULL DEFAULT NOW(),
computation_version VARCHAR(32) -- 计算脚本版本
);
CREATE INDEX idx_answer_metrics_raw_id ON answer_metrics(raw_answer_id);
CREATE INDEX idx_answer_metrics_name ON answer_metrics(metric_name);
当计算逻辑变更时,通过computation_version字段区分,先删除旧版本指标,再重新计算。
核心实现
存储流程
- 采集服务收到回答后,生成唯一request_id(UUID)。
- 将原始回答(JSON)和元数据(token用量、延迟等)组装成一条记录。
- 插入raw_answers表:如果request_id+platform+model_name已存在,则先将旧版本插入answer_versions,再更新raw_answers。
- 异步计算指标,写入answer_metrics。
示例代码(Python + SQLAlchemy)
python
from sqlalchemy import create_engine, Column, String, JSON, DateTime, Boolean, BigInteger, Text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from datetime import datetime
import uuid
Base = declarative_base()
class RawAnswer(Base):
__tablename__ = 'raw_answers'
id = Column(BigInteger, primary_key=True)
request_id = Column(String(64), nullable=False)
query_text = Column(Text, nullable=False)
platform = Column(String(32), nullable=False)
model_name = Column(String(64), nullable=False)
model_version = Column(String(32))
answer_content = Column(JSON, nullable=False)
metadata = Column(JSON)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
is_deleted = Column(Boolean, default=False)
def save_raw_answer(session, query_text, platform, model_name, answer_content, metadata=None, model_version=None):
request_id = str(uuid.uuid4())
existing = session.query(RawAnswer).filter_by(
request_id=request_id,
platform=platform,
model_name=model_name
).first()
if existing:
# 保存旧版本
old_version = AnswerVersion(
raw_answer_id=existing.id,
answer_content=existing.answer_content,
metadata=existing.metadata,
version=1,
change_reason='update'
)
session.add(old_version)
# 更新现有记录
existing.answer_content = answer_content
existing.metadata = metadata
existing.model_version = model_version
existing.updated_at = datetime.utcnow()
else:
new_record = RawAnswer(
request_id=request_id,
query_text=query_text,
platform=platform,
model_name=model_name,
model_version=model_version,
answer_content=answer_content,
metadata=metadata
)
session.add(new_record)
session.commit()
return request_id
代码说明:
- 该代码是核心片段,完整实现需包含AnswerVersion模型和数据库连接配置。
- 实际使用时,应处理并发冲突(如使用SELECT FOR UPDATE)。
验证结果
以下验证步骤基于PostgreSQL 15,在psql客户端执行。假设已创建表并插入一条记录。
- 插入记录后,查询raw_answers表:
sql
SELECT id, request_id, platform, model_name, created_at
FROM raw_answers
WHERE query_text LIKE '%如何实现可追溯性%';
正常情况下应当返回一条记录,包含自动生成的ID和创建时间。如果返回空,请检查query_text是否匹配。
- 更新同一request_id的记录后,查询answer_versions表:
sql
SELECT * FROM answer_versions WHERE raw_answer_id = 1;
正常情况下应当返回一条记录,包含旧版本的answer_content和change_reason='update'。如果返回空,说明更新时未触发版本保存,请检查代码中的existing判断逻辑。
注意:以上验证未在实际生产环境执行,仅为设计验证。实际部署时建议编写自动化测试。
常见问题与避坑
1. 回答内容过大
如果回答超过PostgreSQL的JSONB限制(约1GB),建议:
- 使用外部存储(如S3),在answer_content中存储对象键。
- 或者分块存储,用多个字段组合。
2. 版本管理策略
- 只保留最近N个版本,通过定时任务清理。
- 变更原因字段务必填写,否则后续审计困难。
3. 指标重算
当计算逻辑变更时,需要重算历史指标。此时通过answer_metrics的computation_version字段区分,先删除旧版本指标,再重新计算。
总结
本文解决了AI回答采集后原始数据存储的可追溯性问题。根因是只保存指标而丢弃原始回答。最终方案使用PostgreSQL JSONB存储原始回答,通过request_id+platform+model_name保证唯一性,版本表支持回滚,指标表与原始数据分离便于重算。该方案适用于日均百万级记录的中等规模系统。对于更高吞吐场景,可考虑分区表或列式存储。当前限制:未处理并发写入冲突,版本表清理策略需根据实际数据量调整。