从 20 秒勇气到可审计 AI:PostgresEDI 七月聚会的两条工程主线

2026-07-13 26 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

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 需要的是一组可验证的工程约束。二者都不依赖口号:准备过程、失败预案和可复现证据才是可信度的来源。


相关推荐