PostgresEDI 2026 年 7 月聚会安排了两个看似相距很远的话题:内向的工程师如何走上讲台,以及如何用 PostgreSQL 和 pgvector 构建面向监管场景的 AI 检索系统。两场分享其实都在回答同一个问题:如何让技术工作经得起公开检验。前者要求工程师清楚表达自己的判断,后者要求系统留下足以复现判断过程的证据。
技术能力之外,还要让别人理解你的工作
Redgate 的 PostgreSQL Advocate Pat Wright 给出的起点很实际:公开演讲不要求一个人突然变得外向,只需要对主题有足够热情,并在关键时刻拿出“20 秒的勇气”。
真正值得工程师借鉴的是完整的准备流程:
- 摘要不要只写“我会介绍什么”,还要说明听众遇到了什么问题、将带走什么方法。
- 在面对听众前,先对着空房间完整讲一遍,以便发现内容断点和时间失控。
- 演示必须反复测试,并准备截图、录屏或静态输出作为备用方案。
- 上台时允许自己暂时“暂停内向”,但不必模仿高亢或夸张的表达风格。
- 参加下一次活动时,主动认识三位新朋友,把社区参与变成可执行的小目标。
可以这样实践一份技术演讲摘要:
标题:一次故障之后,我们如何让 PostgreSQL 迁移可回滚
问题:
数据库迁移经常在应用发布过程中执行,但失败后的恢复路径没有经过验证。
听众收获:
1. 识别不可逆 DDL;
2. 为迁移设计演练和回滚步骤;
3. 在 CI 中验证旧版本应用与新模式的兼容性。
证据:
一个最小演示、一次真实故障的时间线,以及迁移前后的检查命令。
这种结构迫使讲者明确问题、方法和证据,也方便会议组织者判断内容是否适合听众。
相关性不是证据
Algonix AI 的 Martins Otun 把第二场分享放进了一个具体场景:AI 批准了一笔 300 万英镑贷款,六个月后监管机构要求解释原因。系统此时不能只返回一段看起来合理的文字,而要回答:
- 当时检索了哪些文档版本?
- 使用了哪个嵌入模型及版本?
- 哪些分块进入了模型上下文?
- 检索时应用了什么权限?
- 今天能否重建当时的检索结果?
这场分享的核心判断是“相关性不是证据”。向量数据库通常重点优化近邻搜索质量和延迟,但在合规系统里,召回结果还必须绑定文档版本、身份、权限、模型版本和事务时间。
Martins 提出的方向是把向量、权限、文档版本和审计记录放进同一个 PostgreSQL 数据库。检索和审计写入处于同一事务中,行级安全策略负责隔离租户或用户数据。这样可以减少跨数据库写入失败造成的证据缺口,也能使用 SQL 联结业务记录与检索轨迹。
这并不意味着 PostgreSQL 自动解决了合规问题。保留期限、密钥管理、模型输入中的个人数据、管理员权限和日志防篡改仍然需要单独设计。不过,一个事务边界和一套访问控制规则,通常比多个最终一致的存储系统更容易审查。
可以这样实践:在一次事务里检索并记账
下面是一个最小化演示。它使用三维向量方便手工运行;接入真实模型时,需要把 vector(3) 改成模型实际的嵌入维度。运行前需要 Docker,并使用包含 pgvector 扩展的 PostgreSQL 镜像。
启动数据库:
docker run --name auditable-rag \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
-d pgvector/pgvector:pg16
将下面内容放入 demo.sql,再执行 psql postgresql://postgres:postgres@localhost:5432/postgres -f demo.sql:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE document_versions (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id text NOT NULL,
document_key text NOT NULL,
version integer NOT NULL,
content_hash text NOT NULL,
UNIQUE (tenant_id, document_key, version)
);
CREATE TABLE chunks (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
document_version_id bigint NOT NULL REFERENCES document_versions(id),
chunk_no integer NOT NULL,
content text NOT NULL,
embedding vector(3) NOT NULL,
embedding_model text NOT NULL,
UNIQUE (document_version_id, chunk_no)
);
CREATE TABLE retrieval_runs (
id uuid PRIMARY KEY,
tenant_id text NOT NULL,
actor_id text NOT NULL,
query_text text NOT NULL,
query_embedding vector(3) NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE TABLE retrieval_hits (
run_id uuid NOT NULL REFERENCES retrieval_runs(id),
rank bigint NOT NULL,
chunk_id bigint NOT NULL REFERENCES chunks(id),
distance double precision NOT NULL,
document_hash text NOT NULL,
embedding_model text NOT NULL,
PRIMARY KEY (run_id, rank)
);
ALTER TABLE document_versions ENABLE ROW LEVEL SECURITY;
ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;
CREATE POLICY document_tenant_policy ON document_versions
USING (tenant_id = current_setting('app.tenant_id', true));
CREATE POLICY chunk_tenant_policy ON chunks
USING (
EXISTS (
SELECT 1
FROM document_versions d
WHERE d.id = chunks.document_version_id
AND d.tenant_id = current_setting('app.tenant_id', true)
)
);
CREATE OR REPLACE FUNCTION retrieve_and_audit(
p_query text,
p_embedding vector(3),
p_limit integer DEFAULT 3
)
RETURNS TABLE (
run_id uuid,
rank bigint,
chunk_id bigint,
content text,
distance double precision
)
LANGUAGE plpgsql
AS $$
DECLARE
v_run_id uuid := gen_random_uuid();
v_tenant_id text := current_setting('app.tenant_id', true);
v_actor_id text := current_setting('app.actor_id', true);
BEGIN
IF v_tenant_id IS NULL OR v_actor_id IS NULL THEN
RAISE EXCEPTION 'app.tenant_id and app.actor_id must be set';
END IF;
INSERT INTO retrieval_runs (
id, tenant_id, actor_id, query_text, query_embedding
) VALUES (
v_run_id, v_tenant_id, v_actor_id, p_query, p_embedding
);
RETURN QUERY
WITH selected AS MATERIALIZED (
SELECT
row_number() OVER (ORDER BY c.embedding <=> p_embedding) AS hit_rank,
c.id AS hit_chunk_id,
c.content AS hit_content,
c.embedding <=> p_embedding AS hit_distance,
d.content_hash,
c.embedding_model
FROM chunks c
JOIN document_versions d ON d.id = c.document_version_id
ORDER BY c.embedding <=> p_embedding
LIMIT p_limit
),
logged AS (
INSERT INTO retrieval_hits (
run_id, rank, chunk_id, distance,
document_hash, embedding_model
)
SELECT
v_run_id, hit_rank, hit_chunk_id, hit_distance,
content_hash, embedding_model
FROM selected
RETURNING chunk_id
)
SELECT
v_run_id,
s.hit_rank,
s.hit_chunk_id,
s.hit_content,
s.hit_distance
FROM selected s
JOIN logged l ON l.chunk_id = s.hit_chunk_id
ORDER BY s.hit_rank;
END;
$$;
INSERT INTO document_versions (
tenant_id, document_key, version, content_hash
) VALUES
('bank-a', 'loan-policy', 1, 'sha256:policy-v1');
INSERT INTO chunks (
document_version_id, chunk_no, content, embedding, embedding_model
) VALUES
(1, 1, 'Loans above two million require committee review.', '[1,0,0]', 'demo-embedding-v1'),
(1, 2, 'Applicants must provide verified income records.', '[0,1,0]', 'demo-embedding-v1'),
(1, 3, 'Decisions must retain supporting evidence.', '[0,0,1]', 'demo-embedding-v1');
BEGIN;
SET LOCAL app.tenant_id = 'bank-a';
SET LOCAL app.actor_id = 'analyst-42';
SELECT * FROM retrieve_and_audit(
'What review is required for a large loan?',
'[0.9,0.1,0]',
2
);
COMMIT;
TABLE retrieval_runs;
TABLE retrieval_hits;
函数中的 selected 结果既用于返回,也用于写入 retrieval_hits。如果审计写入失败,整条语句乃至外层事务都会失败,不会出现“答案已经返回,但审计记录丢失”的正常提交路径。
生产实现还应补上数据库角色隔离,对审计表启用 RLS,必要时使用 FORCE ROW LEVEL SECURITY,并禁止应用角色更新历史记录。文档版本应保持不可变;如果法规要求删除原文,则需要明确哈希、向量和查询文本是否也属于必须删除的派生个人数据。
落地前检查什么
两场分享给出的建议可以汇成一份工程检查表:
- 对外表达时,明确问题、方案、演示证据和失败备用路径。
- 对 AI 检索,保存查询、调用者、时间、模型版本、文档版本、分块标识和排序分数。
- 让权限判断发生在检索查询内部,而不是结果返回后再过滤。
- 让检索结果与审计写入共享事务边界。
- 定期执行复现演练,不要等监管询问出现后才验证日志是否够用。
- 为审计数据制定访问、加密、保留和删除策略,避免审计系统本身成为敏感数据泄漏点。
走上讲台需要的是一小段可控的勇气,构建可审计 AI 需要的是一组可验证的工程约束。二者都不依赖口号:准备过程、失败预案和可复现证据才是可信度的来源。