English

DW Semantic History Search — Design

Adds local, pgvector-based semantic search over Digital Worker chat history. The current Postgres FTS ('simple' config) cannot segment Chinese or code-switched rojak, leaving 40% Malay + 10-20% Chinese chat effectively unsearchable; local bge-m3 embeddings fix this with no API cost and enable cross-lingual retrieval. Approved after the 2026-07-22 grill session resolved all open questions.

View source markdown ↗ generated by claude-opus-4-8 · diagrams mermaid

Digital Worker 的聊天记录搜索目前依赖 Postgres 全文搜索,而它无法识别中文或中英混杂的 "rojak"(马来西亚混合语)——因此聊天中约 40% 的马来语和 10–20% 的中文内容如今实际上 无法被搜索到。本设计引入本地的、基于 pgvector 的语义搜索,使用 bge-m3 向量,由单一的周期性 watermark sweep 生成,并合并进现有的搜索路径。经过 2026-07-22 的 grill 会议解决了所有开放问题后,已批准。

  • 模块 backend/go/internal/modules/digitalworker
  • 模型 bge-m3(1024 维),经 Ollama 本地运行
  • 存储 halfvec(1024),精确 KNN,无 ANN 索引
  • 写入路径 一次周期性 watermark sweep,扫描已关闭 window

问题

DW 聊天记录搜索目前使用 Postgres 全文搜索 (HistorySearchRepository.Searchlearning_repositories.go:533):

search_tsv @@ plainto_tsquery('simple', ?)

对 zh/ms/rojak 的召回而言,语义搜索是唯一可行的方式,并且能实现跨语言检索 (跨语言检索):用一种语言的查询能找到用另一种语言写的相关消息。

目标与非目标

目标

范围内
  • 对英文、马来语、中文及中英混杂消息均有效的语义搜索。
  • 本地、免费、私密——不向第三方 API 发送任何聊天内容。
  • 由功能开关控制,可完全关闭。
  • 向量模型可配置,日后可在不改代码的情况下更换。

非目标(推迟)

范围外
  • 语义化记忆注入——保持 ADR-0034 的新近度排序,直到出现超预算信号。
  • 重排器(reranker)——第 2 阶段。
  • 跨域搜索 / 平台统一向量标准——改用按域独立模型。
  • 付费模型(OpenAI/Voyage)——本地已足够;隐私 + 零成本。
  • 摄取时内联向量化——由 sweep 负责。
  • MRL / 截断ANN 索引——语料太小。

关键决策

#决策理由
1按域独立模型;不做跨域搜索使 DW 与 ai/crm 相互隔离
2bge-m3(1024 维),经 Ollama 本地运行原生处理 rojak,对马来语(低资源)表现强
3原始文本向量化——不做 LLM/翻译归一化一步且确定;归一化会增加成本与非确定性
4不用 MRL / 不截断——完整 1024 维语料小;MRL 解决的是我们没有的规模问题
5halfvec(1024) + halfvec_cosine_ops存储为 vector 的一半;余弦是标准做法
6独立dw_message_embeddingsai 模块的 vector(1536) 模型/维度不同
7model 列;始终按它过滤两个模型即使维度相同也绝不共享同一向量空间
8一次周期性 watermark sweep(无入队、无单独回填)window 关闭后才能 embed;一条路径同时给出异步、重试、回填、换模型
9substance 过滤作用于 window,而非 messagewindow 内一句 "ok" 往往就是答案;只跳过极小的 window
10以小的对话 window 为块最大的精度杠杆;聊天含义横跨多条短消息
11功能开关总闸先暗发布,再按环境逐步放量
12向量模型可配置,默认 bge-m3日后换模型(一次重嵌任务)无需改代码

embedding 如何生成?

一次周期性 watermark sweep,扫描已关闭的对话 window。

window 只有在其会话关闭后才能 embed,因此 per-message 任务形状不对。单一 sweep 按 (org, provider, channel, model) watermark 分区,用一条代码路径给出异步、 重试(临时失败时 watermark 不推进)、回填(空 watermark)以及换模型时的重建。摄取保持不变。

已否决:摄取时入队 + worker + 单独回填——为一个必须等会话关闭才能运行的任务用了三个部件。

用哪种向量索引?

不用——为 (org_id, model) 建 btree + 精确 KNN。

retention 绑定把表限制在约 30 天的 window(每个 org 数百到数千个向量),此规模下精确扫描 为个位数毫秒,且召回完美

已否决:HNSW/IVFFlat——filtered ANN 会对 org_id/model 做后过滤(可能返回不足或 漏掉命中),而 IVFFlat 需要训练数据。只有单个 org 接近约 50–100k live vectors 才重新考虑 HNSW。

架构

flowchart TB
    msg["Inbound message"] --> ingest["Ingest: store dw_chat_events row (ZERO changes)"]
    sweep["Periodic watermark sweep"] --> read["Read events after watermark (WindowAfter)"]
    read --> sess["Sessionize: 30-min gap, 15-msg cap"]
    sess --> closed{"Window closed?"}
    closed -->|open tail| wait["Skip until it closes"]
    closed -->|closed| subst{"Meaningful text >= 20 chars?"}
    subst -->|no| skipw["Skip window"]
    subst -->|yes| emb["Embed with bge-m3 (local, unbilled)"]
    emb --> store["Write dw_message_embeddings (halfvec + model)"]
    store --> adv["Advance watermark past embedded windows"]

    q["Search request"] --> flag{"Semantic flag on?"}
    flag -->|off| fts1["FTS only (unchanged)"]
    flag -->|on| hybrid["FTS + embed query + exact KNN (WHERE org_id and model)"]
    hybrid --> merge["FTS first, semantic appended, dedup by event ID, cap at limit"]
    hybrid -.embed fails.-> fts2["Degrade to FTS-only"]
    
摄取保持不变;sweep 对已关闭 window 做向量化(上);hybrid 搜索合并 FTS + KNN,失败时降级为 FTS(下)。

数据模型

dw_message_embeddings
  id            uuid pk
  org_id        varchar   -- tenancy scope; every query filters on it
  model         varchar   -- e.g. 'bge-m3'; every query filters on it (#7)
  embedding     halfvec(1024)          -- (#5)
  content       text      -- the exact chunk text that was embedded
  source_message_ids  ...  -- which dw_chat_events rows this chunk covers (#10)
  channel / provider / time-range metadata  -- to map a hit back to messages
  created_at    timestamptz

Substance 过滤——window 级

substance 过滤作用于已完成的 window,而非单条消息。符合条件的 window 内每条 消息都保留——一句 "ok" 或 "BetterAuth" 往往是上一条问题的答案;逐 message 丢弃会从 chunk 中删掉含义。

分块——按时间间隔切分会话

向量化小的对话 window,而非单条消息。聊天含义分散在多段短片段里("migrate 那个" / "Clerk 还是 BetterAuth?" / "BetterAuth"),因此逐 message 向量化会把含义切碎并错过它。

  • 范围:严格 (org_id, provider, external_channel_id)——绝不跨 channel/provider。
  • 合格条件:现有 WindowAfter predicate(event_type='message'decision IN ('received','processed'))——denied/verification 流量绝不 embed。
  • 会话切分:间隔超过 30 分钟就开新 window。
  • 上限:每 window 最多 15 条消息 + 模型输入上限;达到上限的会话在下一个 window 续接。
  • phase 1 不重叠不按 threadExternalThreadIDchatEventRow 丢弃——那是 schema 改动,不在 phase 1)。
  • 30 分钟间隔与 15 条上限都是有名称、有测试的常量。

配置与功能开关

总闸(#11)
两层 flag digital_worker_semantic_searchadmin_feature_flags 默认关闭 + org_feature_flags),与模块 digital_worker flag 一起检查。关闭 → sweep 为 no-op,搜索回退到 FTS;两种状态下摄取都不变。
可配置模型(#12)
deployment 级 env 配置 DigitalWorker.EmbedModel(默认 bge-m3),复用 OllamaURL非 per-org——模型必须与 Ollama host 实际提供的一致,且模型身份定义向量空间。更换它会让新模型的 watermark 从空开始;搜索按当前生效模型过滤,新旧向量绝不混用。
启发式常量
20 字符 substance 阈值、30 分钟间隔、15 条上限都是有名称、有测试的 Go constants——org_module_configs 不新增配置。

分阶段

  1. 第 1 阶段——本设计

    对已关闭 window 的 watermark 向量化 sweep、dw_message_embeddings 表、在现有 SearchAgentHistory 路径内的 hybrid 搜索(无新端点)、功能开关、可配置模型。

  2. 第 2 阶段——本地重排器

    bge-reranker-v2-m3:先用向量搜索取前约 50 条 → 重排 → 返回前约 5–10 条。当出现精度抱怨时再加。作为独立的本地推理服务(TEI/Infinity)运行,而非 Ollama。

测试方法——两层

第 1 层——fake embedder(阻塞 CI)

向量由输入文本确定性生成,跑在测试 Postgres 上(在 test-DB migrations 中确认 pgvector)。覆盖:windowing(间隔切分、15 条上限、续接)、window substance 过滤、watermark(推进、临时失败暂停、poison 跳过)、hybrid 合并/去重/上限、model 过滤、查询时降级为 FTS、开关关闭 no-op、retention purge。

第 2 层——真实模型冒烟测试(env-gated,不阻塞 CI)

除非设置 Ollama env var,否则 t.Skip。播种 rojak/zh/ms/en 消息;断言一条英文查询能检索到语义匹配的非英文消息(跨语言召回)。验证的是模型,不是代码——是每个环境启用 flag 前 rollout checklist 上的必做项。

已解问题

五个原始开放问题都在 2026-07-22 的 grill 会议上关闭:

问题结论
Substance 阈值window 级长度启发式,约 20 个有效字符;不复用 triage,不做 AI 检查。
Windowing 规则按时间间隔切分会话(30 分钟),上限 15 条,按(org, provider, channel);不重叠;不按 thread。
索引类型都不用——精确 KNN + (org_id, model) btree;只有接近约 50–100k live vectors 才用 HNSW。
配置形态两层 digital_worker_semantic_search flag;EmbedModel env 默认 bge-m3;启发式为 Go constants。
回填范围已无意义——retention 绑定意味着回填 = 所有仍存在的行;没有更旧的数据。