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.
Digital Worker 的聊天记录搜索目前依赖 Postgres 全文搜索,而它无法识别中文或中英混杂的 "rojak"(马来西亚混合语)——因此聊天中约 40% 的马来语和 10–20% 的中文内容如今实际上 无法被搜索到。本设计引入本地的、基于 pgvector 的语义搜索,使用 bge-m3 向量,由单一的周期性 watermark sweep 生成,并合并进现有的搜索路径。经过 2026-07-22 的 grill 会议解决了所有开放问题后,已批准。
问题
DW 聊天记录搜索目前使用 Postgres 全文搜索
(HistorySearchRepository.Search,learning_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 相互隔离 |
| 2 | bge-m3(1024 维),经 Ollama 本地运行 | 原生处理 rojak,对马来语(低资源)表现强 |
| 3 | 对原始文本向量化——不做 LLM/翻译归一化 | 一步且确定;归一化会增加成本与非确定性 |
| 4 | 不用 MRL / 不截断——完整 1024 维 | 语料小;MRL 解决的是我们没有的规模问题 |
| 5 | halfvec(1024) + halfvec_cosine_ops | 存储为 vector 的一半;余弦是标准做法 |
| 6 | 独立表 dw_message_embeddings | 与 ai 模块的 vector(1536) 模型/维度不同 |
| 7 | 存 model 列;始终按它过滤 | 两个模型即使维度相同也绝不共享同一向量空间 |
| 8 | 一次周期性 watermark sweep(无入队、无单独回填) | window 关闭后才能 embed;一条路径同时给出异步、重试、回填、换模型 |
| 9 | substance 过滤作用于 window,而非 message | window 内一句 "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"]
数据模型
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。 - 合格条件:现有
WindowAfterpredicate(event_type='message'、decision IN ('received','processed'))——denied/verification 流量绝不 embed。 - 会话切分:间隔超过 30 分钟就开新 window。
- 上限:每 window 最多 15 条消息 + 模型输入上限;达到上限的会话在下一个 window 续接。
- phase 1 不重叠;不按 thread(
ExternalThreadID被chatEventRow丢弃——那是 schema 改动,不在 phase 1)。 - 30 分钟间隔与 15 条上限都是有名称、有测试的常量。
配置与功能开关
- 总闸(#11)
- 两层 flag
digital_worker_semantic_search(admin_feature_flags默认关闭 +org_feature_flags),与模块digital_workerflag 一起检查。关闭 → 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 阶段——本设计
对已关闭 window 的 watermark 向量化 sweep、
dw_message_embeddings表、在现有SearchAgentHistory路径内的 hybrid 搜索(无新端点)、功能开关、可配置模型。 -
第 2 阶段——本地重排器
bge-reranker-v2-m3:先用向量搜索取前约 50 条 → 重排 → 返回前约 5–10 条。当出现精度抱怨时再加。作为独立的本地推理服务(TEI/Infinity)运行,而非 Ollama。
测试方法——两层
向量由输入文本确定性生成,跑在测试 Postgres 上(在 test-DB migrations 中确认 pgvector)。覆盖:windowing(间隔切分、15 条上限、续接)、window substance 过滤、watermark(推进、临时失败暂停、poison 跳过)、hybrid 合并/去重/上限、model 过滤、查询时降级为 FTS、开关关闭 no-op、retention purge。
除非设置 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 绑定意味着回填 = 所有仍存在的行;没有更旧的数据。 |