📚 离职知识库

04 · 技术资料:数据层与中文混合检索

本节 SQL 已按 Leo-hub 实际探查结果写好,可直接执行。⛔ 但执行前必须先读 §1 的三个决策点。


#1. P0 必须先解决的三个决策点

这三件事决定后面所有代码怎么写。P0 阶段(前 45 分钟)就要有结论,⛔ 不要等到 P2 才发现。

#决策点 D1:拿不拿得到 service_role key?

实测事实:Leo-hub(project_id <ID>)现有 21 张表的 RLS policy 全是 for all to public using (true) with check (true) —— RLS 完全放行,不提供任何隔离。所有项目都用 NEXT_PUBLIC_SUPABASE_KEY(publishable key)直连。

Baton 的核心卖点就是隔离,所以理想架构是:表对 anon 默认拒绝,只有服务端持 service_role key 能访问

P0 要做的

  1. 检查 ~/Desktop/配置信息/ 下是否有人类留下的 service_role / secret key 文件(文件名可能形如 supabase-leo-hub-service-key.txt
  2. 检查 ~/personal/*/.env.local 里是否有 SUPABASE_SERVICE_ROLE_KEY已实测:本机目前没有,只在 brainknowledge-web/.env.example 里有变量名无值)
  3. 检查 Vercel 上现有项目的环境变量里有没有

分支

情况 架构 动作
✅ 拿到了 表 RLS 默认拒绝(不给 anon 建任何 policy),所有访问走服务端 SUPABASE_SERVICE_ROLE_KEY 走 §3 的「方案 A」DDL
❌ 拿不到 表 RLS 沿用 using(true)(与 Leo-hub 现有表一致),用 publishable key;隔离 100% 靠应用层 走 §3 的「方案 B」DDL,并在 blockers.md + README 安全章节明写这个边界

不管哪个分支,应用层的 scopedQuery 强制过滤都必须做。 方案 B 只是少了一层数据库兜底,不是可以不做隔离。

#决策点 D2:原始文件存哪?

优先级 方案 前置条件 风险
1 Vercel Blob(client upload,绕过 4.5MB 限制) 需要 BLOB_READ_WRITE_TOKEN。若 Vercel 项目还没有 Blob store,要跑 vercel blob create-store ⚠️ 这条命令历史上覆写过 .env.local 导致孤本 key 丢失。跑之前必须 cp .env.local .env.local.bak
2 Supabase Storage 建一个 bt-files bucket 上传要经服务端中转,受 4.5MB 限制
3 降级:只支持 ≤4MB 文件,文本内容直接入库不存原文件 演示够用,但「原始文件一起交接」这条 AC 要标 PARTIAL

DDL 已设计成三种都能用(storage_provider + storage_url 两列)。

#决策点 D3:embedding 用哪个 key?

云雾中转https://yunwu.ai/v1,OpenAI 兼容),模型 text-embedding-3-small(1536 维)。 ⚠️ 云雾现在一个项目一把 key,今晚不能自助新建(要人工登录后台)。做法:从现有项目的本地 .env.local 借一把跑通,在 blockers 里记一条「待人类建 baton 专属 key」。

拿不到任何可用 key → 走 00-总纲 §6.2 的降级:跳过向量列,只做模糊匹配检索。


#2. Leo-hub 现状(已实测,不用再查)

  • project_id:<ID>,区域 ap-northeast-1东京),Postgres 17.6
  • 现有 21 张表:messages note_entries private_auth auth_throttle opc_tools opc_academy_favs opc_tool_favs brain_notes se_hw_favs ms_materials ms_topics ms_drafts ms_sync_state wemark_docs lfy_users lfy_bookings en_profile en_articles en_vocab en_word_stats gifmoji_state
  • bt_ 前缀零冲突
  • 扩展:vector(pgvector 0.8.2) 和 pg_trgm(1.6) 可用但未安装,需要 create extension
  • pgcrypto 已装,直接用 gen_random_uuid()
  • zhparser / pg_jieba 不存在(Supabase 托管版没有中文分词扩展)→ to_tsvector('chinese', …) 这条路彻底走不通,中文精确匹配只能靠 pg_trgm
  • 备选:pgroonga 3.2.5 在允许列表里(CJK 全文检索更强),但索引体积/构建成本高,今晚不引入

⚠️ 跨区域延迟:Leo-hub 在东京,Vercel 函数默认在美东 iad1。给所有查库的 route 加:

export const preferredRegion = 'hnd1'   // 东京,就近连库

#3. DDL

#3.0 扩展与公共函数(两个方案都要)

create extension if not exists vector;
create extension if not exists pg_trgm;

-- 维护 updated_at 的公共触发器函数
create or replace function public.bt_set_updated_at()
returns trigger language plpgsql as $$
begin
  new.updated_at = now();
  return new;
end;
$$;

#3.1 七张表

-- ========== 1. 员工 ==========
create table public.bt_employees (
  id uuid primary key default gen_random_uuid(),
  employee_code text not null unique,
  display_name text not null,
  avatar_emoji text,
  title text,
  department text,
  role text not null default 'agent' check (role in ('agent','admin')),
  status text not null default 'active' check (status in ('active','offboarding','offboarded')),
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);
comment on table public.bt_employees is '接棒:员工主表,每个员工对应一份严格隔离的知识资料';
comment on column public.bt_employees.employee_code is '工号,前端身份切换用的短标识';
comment on column public.bt_employees.status is 'active=在职 / offboarding=交接中 / offboarded=已离职(资料只读并封存)';

create trigger trg_bt_employees_updated_at before update on public.bt_employees
  for each row execute function public.bt_set_updated_at();

-- ========== 2. 文件 ==========
create table public.bt_files (
  id uuid primary key default gen_random_uuid(),
  owner_employee_id uuid not null references public.bt_employees(id) on delete restrict,
  original_filename text not null,
  storage_provider text not null default 'vercel_blob'
    check (storage_provider in ('vercel_blob','supabase_storage','inline')),
  storage_url text,
  mime_type text not null,
  file_size_bytes bigint not null check (file_size_bytes >= 0),
  source_type text not null check (source_type in ('pdf','docx','xlsx','txt','md','other')),
  page_count int,
  checksum_sha256 text,
  parse_status text not null default 'pending'
    check (parse_status in ('pending','parsing','chunking','embedding','done','failed')),
  parse_error text,
  total_chunks int not null default 0,
  embedded_chunks int not null default 0,
  uploaded_at timestamptz not null default now(),
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);
comment on table public.bt_files is '接棒:员工上传的原始文件,归属唯一员工';
comment on column public.bt_files.parse_status is '状态机:pending→parsing→chunking→embedding→done,失败转 failed';
comment on column public.bt_files.storage_provider is '原文件存储后端,三种降级方案都支持';

create trigger trg_bt_files_updated_at before update on public.bt_files
  for each row execute function public.bt_set_updated_at();

create index bt_files_owner_idx on public.bt_files (owner_employee_id);
create index bt_files_status_idx on public.bt_files (parse_status)
  where parse_status not in ('done','failed');

-- ========== 3. 切片 ==========
create table public.bt_chunks (
  id uuid primary key default gen_random_uuid(),
  file_id uuid not null references public.bt_files(id) on delete cascade,
  owner_employee_id uuid not null references public.bt_employees(id) on delete restrict,
  chunk_index int not null,
  page_no int,
  page_label text not null,
  heading_path text,
  content text not null,
  content_norm text not null,
  token_count int,
  char_count int,
  embedding vector(1536),
  embedding_model text not null default 'text-embedding-3-small',
  embedding_status text not null default 'pending'
    check (embedding_status in ('pending','done','failed')),
  embedding_retry_count int not null default 0,
  created_at timestamptz not null default now(),
  unique (file_id, chunk_index)
);
comment on table public.bt_chunks is '接棒:文件切片,检索的最小召回单元,必须保留出处';
comment on column public.bt_chunks.page_label is '展示用出处文案,始终有值:PDF="第3页" / docx=章节路径 / xlsx="Sheet1!12-20行"';
comment on column public.bt_chunks.content_norm is '归一化文本(全角转半角、压缩空白),专供 pg_trgm 索引,不用于展示';
comment on column public.bt_chunks.owner_employee_id is '冗余存归属,避免每次检索都 join files';

create index bt_chunks_file_idx on public.bt_chunks (file_id);
create index bt_chunks_owner_idx on public.bt_chunks (owner_employee_id);
create index bt_chunks_pending_idx on public.bt_chunks (file_id, embedding_status)
  where embedding_status = 'pending';

-- ========== 4. 记忆条目(交接的标的物) ==========
create table public.bt_memories (
  id uuid primary key default gen_random_uuid(),
  owner_employee_id uuid not null references public.bt_employees(id) on delete restrict,
  category text not null check (category in
    ('客户约定','报价底线','供应商渠道','人际雷区','流程习惯')),
  title text not null,
  content text not null,
  source_file_id uuid references public.bt_files(id) on delete set null,
  source_chunk_id uuid references public.bt_chunks(id) on delete set null,
  source_label text,
  is_editable boolean not null default true,
  visible_to_colleagues boolean not null default false,
  include_in_handover_default boolean not null default true,
  embedding vector(1536),
  embedding_model text not null default 'text-embedding-3-small',
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);
comment on table public.bt_memories is '接棒:记忆条目,交接的标的物。三个开关分别控制可编辑、跨人可见、交接默认包含';
comment on column public.bt_memories.visible_to_colleagues is 'true 时其他员工的 Agent 可跨人问到(仅问答,不转移归属)';
comment on column public.bt_memories.include_in_handover_default is '发起交接单时是否默认勾选(用户仍可增删)';

create trigger trg_bt_memories_updated_at before update on public.bt_memories
  for each row execute function public.bt_set_updated_at();

create index bt_memories_owner_idx on public.bt_memories (owner_employee_id);
create index bt_memories_cat_idx on public.bt_memories (owner_employee_id, category);
create index bt_memories_visible_idx on public.bt_memories (visible_to_colleagues)
  where visible_to_colleagues = true;

-- ========== 5. 交接单 ==========
create table public.bt_handovers (
  id uuid primary key default gen_random_uuid(),
  from_employee_id uuid not null references public.bt_employees(id) on delete restrict,
  to_employee_id uuid not null references public.bt_employees(id) on delete restrict,
  reason text not null default 'daily_sync' check (reason in ('offboard','role_change','daily_sync')),
  status text not null default 'draft'
    check (status in ('draft','submitted','viewed','completed','cancelled')),
  title text,
  note text,
  created_at timestamptz not null default now(),
  submitted_at timestamptz,
  viewed_at timestamptz,
  completed_at timestamptz,
  constraint bt_handovers_diff_employee check (from_employee_id <> to_employee_id)
);
comment on table public.bt_handovers is '接棒:交接单。一次交接 = 一个 from→to 的可见权授予事件,不搬走数据';
comment on column public.bt_handovers.status is 'draft→submitted→viewed→completed,对应页面上三步进度条';

create index bt_handovers_to_idx on public.bt_handovers (to_employee_id, status);
create index bt_handovers_from_idx on public.bt_handovers (from_employee_id, status);

-- ========== 6. 交接明细 ==========
create table public.bt_handover_items (
  id uuid primary key default gen_random_uuid(),
  handover_id uuid not null references public.bt_handovers(id) on delete cascade,
  item_type text not null check (item_type in ('memory','file')),
  memory_id uuid references public.bt_memories(id) on delete cascade,
  file_id uuid references public.bt_files(id) on delete cascade,
  source_note text,
  included_by text not null default 'default'
    check (included_by in ('default','manual_add')),
  granted_at timestamptz,
  created_at timestamptz not null default now(),
  constraint bt_handover_items_type_consistency check (
    (item_type = 'memory' and memory_id is not null and file_id is null) or
    (item_type = 'file'   and file_id   is not null and memory_id is null)
  )
);
comment on table public.bt_handover_items is '接棒:交接明细。本表即可见权授予记录,原 owner 不变';
comment on column public.bt_handover_items.granted_at is '交接单 completed 时回填,代表可见权正式生效';

create index bt_handover_items_handover_idx on public.bt_handover_items (handover_id);
create unique index bt_handover_items_memory_uk on public.bt_handover_items (handover_id, memory_id)
  where item_type = 'memory';
create unique index bt_handover_items_file_uk on public.bt_handover_items (handover_id, file_id)
  where item_type = 'file';
create index bt_handover_items_memory_idx on public.bt_handover_items (memory_id)
  where memory_id is not null;
create index bt_handover_items_file_idx on public.bt_handover_items (file_id)
  where file_id is not null;

-- ========== 7. 跨 Agent 提问日志 ==========
create table public.bt_agent_queries (
  id uuid primary key default gen_random_uuid(),
  asking_employee_id uuid not null references public.bt_employees(id) on delete restrict,
  target_employee_id uuid references public.bt_employees(id) on delete set null,
  query_text text not null,
  answer_text text,
  matched_memory_ids uuid[] not null default '{}',
  matched_chunk_ids uuid[] not null default '{}',
  was_cross_employee boolean not null default false,
  hop int not null default 0,
  latency_ms int,
  created_at timestamptz not null default now()
);
comment on table public.bt_agent_queries is '接棒:问答与跨人提问日志,记录谁问了谁、命中了什么';
comment on column public.bt_agent_queries.hop is '跳数:0=问自己,1=问同事。⛔ 不允许出现 2(防无限套娃)';

create index bt_agent_queries_asking_idx on public.bt_agent_queries (asking_employee_id, created_at desc);
create index bt_agent_queries_target_idx on public.bt_agent_queries (target_employee_id, created_at desc);

#3.2 索引(向量 + 模糊)

-- 向量:数据量 <10 万,用 HNSW(不用 IVFFlat,后者要按行数调 lists 且数据变化后要重建)
create index bt_chunks_embedding_hnsw on public.bt_chunks
  using hnsw (embedding vector_cosine_ops) with (m = 16, ef_construction = 64);
create index bt_memories_embedding_hnsw on public.bt_memories
  using hnsw (embedding vector_cosine_ops) with (m = 16, ef_construction = 64);

-- 中文模糊匹配:pg_trgm GIN
create index bt_chunks_trgm on public.bt_chunks using gin (content_norm gin_trgm_ops);
create index bt_memories_trgm on public.bt_memories using gin (content gin_trgm_ops);

⚠️ 小数据量下向量索引可能比全表扫描还慢(HNSW 图遍历开销)。种子数据只有几十条 chunk 时这很正常,⛔ 不要因此怀疑方案有问题。

#3.3 RLS —— 二选一

方案 A(拿到了 service_role key,推荐)

alter table public.bt_employees      enable row level security;
alter table public.bt_files          enable row level security;
alter table public.bt_chunks         enable row level security;
alter table public.bt_memories       enable row level security;
alter table public.bt_handovers      enable row level security;
alter table public.bt_handover_items enable row level security;
alter table public.bt_agent_queries  enable row level security;

revoke all on public.bt_employees, public.bt_files, public.bt_chunks,
              public.bt_memories, public.bt_handovers, public.bt_handover_items,
              public.bt_agent_queries
  from anon, authenticated;
-- 不给 anon/authenticated 建任何 policy = 默认全部拒绝。
-- service_role 自带 BYPASSRLS,只在服务端使用,是唯一真正能读写的角色。

方案 B(只有 publishable key)

alter table public.bt_employees enable row level security;
create policy bt_employees_all on public.bt_employees for all to public using (true) with check (true);
-- 其余六张表同样处理(与 Leo-hub 现有表风格一致)

⚠️ 方案 B 下,任何拿到 publishable key 的人都能直连 REST 读全部 bt_ 数据。这必须写进 README 的「安全边界」章节和 blockers.md,⛔ 不许藏着。


#4. 混合检索函数

create or replace function public.bt_hybrid_search(
  query_text text,
  query_embedding vector(1536),
  target_employee_id uuid,
  match_count int default 10,
  candidate_pool int default 50
)
returns table (
  item_type text, item_id uuid, file_id uuid,
  page_no int, page_label text, heading_path text,
  title text, snippet text, owner_employee_id uuid,
  vec_score real, trgm_score real, rrf_score double precision
)
language sql stable as $$
with
chunk_vec as (
  select c.id as item_id, c.file_id, c.page_no, c.page_label, c.heading_path,
         null::text as title, c.content as snippet, c.owner_employee_id,
         1 - (c.embedding <=> query_embedding) as score,
         row_number() over (order by c.embedding <=> query_embedding) as rnk
  from public.bt_chunks c
  where c.embedding is not null
    and (c.owner_employee_id = target_employee_id
      or exists (select 1 from public.bt_handover_items hi
                 join public.bt_handovers h on h.id = hi.handover_id
                 where hi.item_type = 'file' and hi.file_id = c.file_id
                   and h.to_employee_id = target_employee_id and h.status = 'completed'))
  order by c.embedding <=> query_embedding
  limit candidate_pool
),
chunk_trgm as (
  select c.id as item_id, c.file_id, c.page_no, c.page_label, c.heading_path,
         null::text as title, c.content as snippet, c.owner_employee_id,
         similarity(c.content_norm, query_text) as score,
         row_number() over (order by similarity(c.content_norm, query_text) desc) as rnk
  from public.bt_chunks c
  where c.content_norm % query_text
    and (c.owner_employee_id = target_employee_id
      or exists (select 1 from public.bt_handover_items hi
                 join public.bt_handovers h on h.id = hi.handover_id
                 where hi.item_type = 'file' and hi.file_id = c.file_id
                   and h.to_employee_id = target_employee_id and h.status = 'completed'))
  order by similarity(c.content_norm, query_text) desc
  limit candidate_pool
),
memory_vec as (
  select m.id as item_id, m.source_file_id as file_id, null::int as page_no,
         m.source_label as page_label, null::text as heading_path,
         m.title, m.content as snippet, m.owner_employee_id,
         1 - (m.embedding <=> query_embedding) as score,
         row_number() over (order by m.embedding <=> query_embedding) as rnk
  from public.bt_memories m
  where m.embedding is not null
    and (m.owner_employee_id = target_employee_id
      or exists (select 1 from public.bt_handover_items hi
                 join public.bt_handovers h on h.id = hi.handover_id
                 where hi.item_type = 'memory' and hi.memory_id = m.id
                   and h.to_employee_id = target_employee_id and h.status = 'completed'))
  order by m.embedding <=> query_embedding
  limit candidate_pool
),
memory_trgm as (
  select m.id as item_id, m.source_file_id as file_id, null::int as page_no,
         m.source_label as page_label, null::text as heading_path,
         m.title, m.content as snippet, m.owner_employee_id,
         similarity(m.content, query_text) as score,
         row_number() over (order by similarity(m.content, query_text) desc) as rnk
  from public.bt_memories m
  where m.content % query_text
    and (m.owner_employee_id = target_employee_id
      or exists (select 1 from public.bt_handover_items hi
                 join public.bt_handovers h on h.id = hi.handover_id
                 where hi.item_type = 'memory' and hi.memory_id = m.id
                   and h.to_employee_id = target_employee_id and h.status = 'completed'))
  order by similarity(m.content, query_text) desc
  limit candidate_pool
),
unioned as (
  select 'chunk'::text as item_type, item_id, file_id, page_no, page_label, heading_path,
         title, snippet, owner_employee_id, score, rnk, 'vec'::text as method from chunk_vec
  union all
  select 'chunk'::text, item_id, file_id, page_no, page_label, heading_path,
         title, snippet, owner_employee_id, score, rnk, 'trgm'::text from chunk_trgm
  union all
  select 'memory'::text, item_id, file_id, page_no, page_label, heading_path,
         title, snippet, owner_employee_id, score, rnk, 'vec'::text from memory_vec
  union all
  select 'memory'::text, item_id, file_id, page_no, page_label, heading_path,
         title, snippet, owner_employee_id, score, rnk, 'trgm'::text from memory_trgm
),
fused as (
  select item_type, item_id,
         max(file_id) as file_id, max(page_no) as page_no, max(page_label) as page_label,
         max(heading_path) as heading_path, max(title) as title, max(snippet) as snippet,
         max(owner_employee_id) as owner_employee_id,
         max(score) filter (where method = 'vec')  as vec_score,
         max(score) filter (where method = 'trgm') as trgm_score,
         sum(1.0 / (60 + rnk)) as rrf_score          -- RRF,k=60 是业界常用默认
  from unioned
  group by item_type, item_id
)
select item_type, item_id, file_id, page_no, page_label, heading_path, title, snippet,
       owner_employee_id, vec_score::real, trgm_score::real, rrf_score
from fused
order by rrf_score desc
limit match_count;
$$;

alter function public.bt_hybrid_search(text, vector, uuid, int, int) set hnsw.ef_search = 40;

可见权判断函数(应用层也能直接调)

create or replace function public.bt_can_access_file(p_employee_id uuid, p_file_id uuid)
returns boolean language sql stable as $$
  select exists (select 1 from public.bt_files f
                 where f.id = p_file_id and f.owner_employee_id = p_employee_id)
      or exists (select 1 from public.bt_handover_items hi
                 join public.bt_handovers h on h.id = hi.handover_id
                 where hi.item_type = 'file' and hi.file_id = p_file_id
                   and h.to_employee_id = p_employee_id and h.status = 'completed');
$$;

create or replace function public.bt_can_access_memory(p_employee_id uuid, p_memory_id uuid)
returns boolean language sql stable as $$
  select exists (select 1 from public.bt_memories m
                 where m.id = p_memory_id and m.owner_employee_id = p_employee_id)
      or exists (select 1 from public.bt_handover_items hi
                 join public.bt_handovers h on h.id = hi.handover_id
                 where hi.item_type = 'memory' and hi.memory_id = p_memory_id
                   and h.to_employee_id = p_employee_id and h.status = 'completed');
$$;

⚠️ 注意 bt_can_access_memory 故意不包含 visible_to_colleagues —— 那个开关只在「跨人提问」这一条路径上生效(SPEC-006),⛔ 不能让同事直接在自己的知识库页面里看到别人的条目。这两条路径要分开,很容易写混。

调用示例:

const { data, error } = await supabaseAdmin.rpc('bt_hybrid_search', {
  query_text: q,
  query_embedding: vec,          // number[],supabase-js 自动序列化成 pgvector
  target_employee_id: employeeId,
  match_count: 10,
})

#5. 切片策略(怎么保住"第几页")

铁律:先按源文件的物理/逻辑单元切,再在单元内按长度切。⛔ 绝对不要先拼成一整篇纯文本再无差别滑窗 —— 页码在拼接那一步就永久丢了。

格式 单元 page_no page_label 示例
PDF 逐页 getTextContent() 真实页码 第 3 页
docx 段落 + 标题层级 null 第2章 > 2.3 报价条款(用 heading_path 兜底)
xlsx sheet + 行区间 null 报价表!12-20行
txt/md 标题分节 null 第 2 节

参数(中文按字符数算更直观):

  • 单 chunk 目标 500–800 中文字符(约 350–500 token)
  • overlap 约 15%(75–120 字符)
  • 递归切分:先按段落/句号/分号/换行切,累积到上限再断;把上一片末尾 1–2 句带到下一片开头,避免关键数字被硬切断
  • xlsx 的每个 chunk 必须带上表头行,否则脱离表头的数字没有语义

content_norm 归一化(供 pg_trgm 用):全角转半角、压缩连续空白、去控制字符、拉丁字母转小写。⛔ 不做分词(中文不需要,trigram 逐字滑窗天然贴合)。


#6. 坑清单

  1. 中文分词扩展不存在(已实证)→ ⛔ 不要试 to_tsvector('chinese'),全用 pg_trgm。
  2. 短查询词 trgm 召回为空:2 字的客户名简称凑不出足够 trigram,默认阈值 0.3 会把结果筛没。→ 查询词 < 3 字时 set pg_trgm.similarity_threshold = 0.1,或并入一路 ILIKE '%kw%' 兜底再参与 RRF。(对应 AC-3.1.3)
  3. service_role key 泄漏到浏览器:一旦误加 NEXT_PUBLIC_ 前缀或被客户端组件 import,隔离全废。→ 构建后 grep -r "service_role\|sb_secret_" .next/static,必须 0 命中(反作弊脚本已含这条)。
  4. 小数据量下向量索引更慢:正常现象,⛔ 不要为此改方案。用 explain analyze 确认走没走索引即可。
  5. embedding 维度写死 1536:换模型必须整表重算,⛔ 不能新旧混用。embedding_model 字段已留,检索时可加 where embedding_model = ... 兜底。
  6. 跨区域延迟:Leo-hub 在东京、Vercel 默认美东。→ 所有查库 route 加 export const preferredRegion = 'hnd1'
  7. Serverless 连接数:用 supabase-js(走 PostgREST/HTTP)就没有连接池问题。⛔ 不要为了图快改成 pg 直连 5432。
  8. PgBouncer 事务模式不支持 prepared statement:如果非要裸连 6543,postgres.js 要设 { prepare: false }。今晚 ⛔ 不要走这条路。
  9. embedding 批量限流:单批 30–50 条,p-limit 控并发 3–5,指数退避重试。失败的 chunk 留 embedding_status='failed' + retry_count,超过 5 次就放弃这一条继续跑,⛔ 不要卡死整份文件。
  10. bt_can_access_memoryvisible_to_colleagues 的区别(见 §4 的警告)—— 这是最容易写错、且会造成"越权可见"的一处。要有专门的负向测试(AC-3.2.1 / AC-6.1.2)。
  11. 交接是授予不是搬移:⛔ 任何情况下都不要 update bt_memories set owner_employee_id = ...。原始归属必须留痕(AC-5.2.3)。
  12. 测试数据污染生产表:所有集成测试数据必须带 RUN_ID 前缀,删除语句必须带条件。见 02-TDD规程 §5。

来源:沉淀/03-项目方案与交接/接棒-通宵施工包-20260731/04-技术资料-数据层与检索.md(整理于 2026-08-18)