本文首发地址 https://h89.cn/archives/731.html


我的 Hindsight 记忆库(给 Hermes 用的记忆存储)默认 embedding 模型是 BAAI/bge-small-en-v1.5——纯英文、384 维。中文检索?基本形同虚设,存进去的中文记忆几乎搜不回来。

本机 LLM 走 DeepSeek,没有 embedding 端点,所以 embedding 只能自己想办法。换模型的路上有两条硬约束:

  1. 中文语义检索质量要提上来;
  2. 26996 条记忆一条不能丢(hermes 库 26515 条事实 + claude-code 库 466 条),不能清空重建。

最终选了 Ollama 上的 qwen3-embedding:0.6b(1024 维,639MB),全量重嵌花了 3 小时 16 分,全部记忆原样保留,中文检索正常。过程不复杂,坑不少,写下来给你省时间。

封面

一、选型:为什么是 qwen3-embedding

候选模型横着比了一圈:

模型 来源 维度 体积 中文质量 CPU 适用
bge-small-en-v1.5(现用) Hindsight 内置 384 ~133MB 差(纯英文)
paraphrase-multilingual-MiniLM-L12-v2 HF 384 ~470MB 一般
bge-m3 HF/Ollama 1024 ~2.3GB 好(100+ 语言)
multilingual-e5-large HF 1024 ~2.2GB
qwen3-embedding:0.6b Ollama/Qwen 1024 639MB 好(Qwen 原生中文强)
nomic-embed-text Ollama 768 ~274MB 一般

锁死 qwen3-embedding:0.6b 的理由:

  • 中文是 Qwen 的主场,0.6B 轻量版中文语义能力也压过 bge 系;
  • 639MB、0.6B 参数,本机 NAS 无 GPU,i5-1240P 16 核 CPU 推理毫无压力;
  • 1024 维,和 bge-m3 同维度,但体积只有它 1/4,速度和中文质量反而占优;
  • 支持 32K 上下文、100+ 语言,官方 MTEB 多语言榜第一的是 8B(70.58 分),0.6B 是它的轻量落地版。

为什么走 Ollama 而不是在 Hindsight 容器里跑本地模型? Ollama 原生暴露 OpenAI 兼容的 /v1/embeddings,Hindsight 只需要把 embedding provider 配成 openai、base_url 指向 Ollama 就行,不用在容器里再塞一套 Python 模型环境(省内存)。本地推理,数据不出门,也没 API 费用。

二、动手前的关键查证

换模型前最担心的是记忆保不保得住,查了数据库结构才放心。

1. 原始记忆和 embedding 是分开存的。 Hindsight 用 pg0 内嵌 PostgreSQL,三张核心表:

  • chunkschunk_text 保留原始对话分段(4024 条);
  • memory_unitstext 存结构化事实文本(26996 条),embedding 是独立的 vector(384) 列;
  • mental_modelsembedding vector(384),0 行。

也就是说,只要重算 embedding 列,全部记忆就能原样保留,不用动任何正文数据。

2. 官方不支持带数据跨维度迁移。 看了镜像里 /app/api/hindsight_api/migrations.py_migrate_table_embedding_dimension(L393-445):维度相同跳过;维度不同但表是空的就 ALTER COLUMN 改维度;维度不同且有数据,直接抛错拒绝,提示要么清空要么用同维度模型。

结论很明确:官方没有 re-embed 工具,跨维度切换只能走 SQL 层手工重建——改列类型 → 脚本重嵌 → 重建索引,全自己来。

三、执行五步走

第 1 步:停机 + 备份

docker stop -t 60 hindsight          # 优雅停机:trap 转发 SIGTERM 到 API+pg0
mkdir -p backups
tar -czf backups/hindsight-data-$(date +%Y%m%d-%H%M%S).tar.gz -C data instances installation
# 产物:309M(原始 1.3G)

第 2 步:起一个临时维护容器

同镜像 + 手动启动 postgres,数据目录必须挂进来,否则白忙:

docker run -d --name hindsight-maint --user 1000:1000 \
  -v /vol1/1000/Tools/hindsight-docker/data:/home/hindsight/.pg0 \
  --entrypoint /bin/bash ghcr.io/vectorize-io/hindsight:0.9.1 -c "sleep infinity"

这里踩了个大坑:手动启动 postgres 必须手动设 LD_LIBRARY_PATH,否则直接报 libicuuc.so.70: cannot open shared object file。原容器是 pg0 python 库启动时自动注入的,自己起就得手动加:

docker exec hindsight-maint sh -c 'export LD_LIBRARY_PATH=/home/hindsight/.pg0/installation/18.1.0/lib; \
  /home/hindsight/.pg0/installation/18.1.0/bin/postgres \
  -D /home/hindsight/.pg0/instances/hindsight/data -p 5432 ... &'

数据库认证走 pg_hba 的 password 认证,默认凭据 hindsight/hindsight(源码 pg0.py 里的 DEFAULT_USERNAME/PASSWORD)。

第 3 步:SQL 改列 384→1024

先删掉所有依赖旧维度的索引,再改列类型:

DROP INDEX IF EXISTS idx_mu_emb_worl_2456e303ad634cb2;   -- memory_units 上 6 个部分索引
DROP INDEX IF EXISTS idx_mu_emb_expr_2456e303ad634cb2;
DROP INDEX IF EXISTS idx_mu_emb_obsv_2456e303ad634cb2;
DROP INDEX IF EXISTS idx_mu_emb_worl_59cfd0d169734880;
DROP INDEX IF EXISTS idx_mu_emb_expr_59cfd0d169734880;
DROP INDEX IF EXISTS idx_mu_emb_obsv_59cfd0d169734880;
DROP INDEX IF EXISTS idx_mental_models_embedding;        -- mental_models 上 1 个

ALTER TABLE memory_units  ALTER COLUMN embedding TYPE vector(1024) USING NULL;  -- 有数据,USING NULL 置空
ALTER TABLE mental_models ALTER COLUMN embedding TYPE vector(1024);              -- 空表直改

memory_units 有 26996 条数据,不能直接改类型,USING NULL 先把 embedding 全置空,后面重嵌。改完确认两表都是 vector(1024)

第 4 步:重嵌脚本(断点续传)

核心逻辑很朴素:取 embedding IS NULL 的行 → 批量调 Ollama → 用 UPDATE ... FROM unnest 批量回填。IS NULL 为循环条件天然就是断点续传,中断了重跑就行,不用怕跑一半白干:

cur.execute("SELECT id::text, text FROM memory_units WHERE embedding IS NULL ORDER BY id LIMIT %s", (BATCH,))
vecs = embed([r[1] for r in rows])   # POST /v1/embeddings, model=qwen3-embedding:0.6b, input=[...]
cur.execute(
    "UPDATE memory_units mu SET embedding = v.emb "
    "FROM unnest(%s::vector[], %s::uuid[]) AS v(emb, id) WHERE mu.id = v.id",
    ([json.dumps(v) for v in vecs], [r[0] for r in rows]))

两个容易翻车的点:

  • unnest 不能直接写在 SET 里,批量关联更新必须 UPDATE ... FROM unnest(...)
  • batch 选 20 就好,别贪大——实测吞吐和 batch 大小关系不大(下面有数据)。

第 5 步:重建索引 + 改配置 + 验证

重嵌完成后重建 HNSW 索引(HNSW 是近似最近邻索引,1024 维余弦相似度,把向量检索从全表扫描变成毫秒级):

CREATE INDEX ... ON memory_units USING hnsw (embedding vector_cosine_ops) WHERE fact_type = ... AND bank_id = ...;

然后停掉维护容器,改 docker-compose.yml

- HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
- HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL=http://192.168.31.165:11434/v1
- HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL=qwen3-embedding:0.6b
- HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=ollama      # ollama 不校验,占位
|- HINDSIGHT_API_RERANKER_LOCAL_MODEL=BAAI/bge-reranker-v2-m3   # 多语言精排(568M,BEIR 62.7,含中文;2026-09-08 从 ms-marco-MiniLM-L-6-v2 80M 英文切换)
- HINDSIGHT_API_WORKER_ID=hermes-worker-1   # 固化 worker 身份,规避 recreate 幽灵阻塞(2026-08-29 实测 12 天阻塞,pending 3020)
# NO_PROXY 必须加宿主机 IP:192.168.31.165,否则 embedding 请求被 HTTP_PROXY(7890) 劫持

启动后验证三件事,全部通过:

  • 启动日志 Embeddings: OpenAI provider initialized (model: qwen3-embedding:0.6b, dim: 1024)迁移自动通过、维度匹配,无需干预
  • reranker 从 HuggingFace 下载 BAAI/bge-reranker-v2-m3(~1.1GB,走代理约 80s),启动日志 Reranker: local provider initialized (max_concurrent=4)
  • retain(写记忆)写入中文内容 + recall(查记忆)用中文查询「qwen3 embedding 1024 维切换测试」,正确命中新写入和历史相关记忆——中文语义检索正常了

四、性能实测:重嵌要多久,换完值不值

吞吐主瓶颈不是 batch,是文本长度。 短文本(同一句话重复)预热后 3 轮均值:

batch 平均耗时 单条 吞吐
10 1.51s 151ms 6.6/s
20 3.33s 167ms 6.0/s
32 5.25s 164ms 6.1/s
50 8.88s 178ms 5.6/s
100 18.30s 183ms 5.5/s

真实文本(memory_units.text,平均 136 字符)就掉下来了:

batch 平均耗时 单条 吞吐
10 2.93s 293ms 3.4/s
20 6.40s 320ms 3.1/s
32 11.07s 346ms 2.9/s
50 18.80s 376ms 2.7/s

真实文本吞吐比短文本降约 45%,batch 大小反而影响不大。26996 条按 ~2.7/s 算,ETA 约 164 分钟——实际跑了 11369 秒(3 小时 16 分,2.4/s),断点续传分 3 段跑完。

换完对日常使用的影响呢? 切换后实测:recall 平均 13.4s(5 次均值)、reflect 79-83s(8 次 LLM tool call)。embedding 切换引入的变化只有约 +0.5-1s(query embedding +100ms、rerank +0.3-0.7s),占 recall 的 ~5%,体感无差异——换来的是中文检索质量从"搜不到"到"正常命中"。

顺带验证:本地 LLM 换不换?

既然 Ollama 都上了,顺手测了把 LLM 从外部 deepseek-v4-flash 换成本地 ollama qwen3.5 会不会更快(同 Prompt「写约 100 字中文短文」):

方案 总耗时 生成速率 首 token 可用性
外部 deepseek-v4-flash(现用) 2.8s(含网络往返) ~40 tok/s 极快
ollama qwen3.5:0.8b(think:false 5.2s 18 tok/s 0.4s ❌ /v1 空响应
ollama qwen3.5:2b(think:false 12.1s 10.9 tok/s 3.8s ❌ /v1 空响应

结论:本地小模型不更快,反而更慢 + 直接不可用

  • 速度:外部云端 flash 生成快得多;hindsight 的 reflect 输入常是 10k-50k token,本地 CPU prefill + 多轮 tool call 总耗时预计远超外部;
  • qwen3.5 的 /v1 兼容缺陷:qwen3.5 是 thinking 模型,走 OpenAI 兼容 /v1/chat/completions 时输出全放 reasoning 字段、content 为空(ollama issue #17969),客户端拿到空响应。/api/chatthink:false 有效,但 Modelfile PARAMETER nothink 在 ollama 0.24.0 不支持。硬用就得升级 ollama 或加翻译代理,收益不值得;
  • 质量:0.8b/2b 做 hindsight 的结构化事实提取(JSON)+ reflect 推理,明显退化。

所以最终决定:hindsight 的 LLM 保持外部 deepseek-v4-flash,Ollama 只当 embedding provider

顺带修了个 ollama 的 CPU 线程问题:容器 CPU 满载 400% 只用了 4 线程,根因是 ollama 0.24.0 解析 cgroup v2 cpu.max="max"(无限制)失败、回退到 NumThreads=4。在 compose 里加 deploy.resources.limits.cpus: "16" 后 NumThreads 翻倍到 8,embedding 吞吐同步受益。后来升级 ollama 0.24.0 → 0.32.15,embedding 吞吐又提升约 3 倍(短文本 batch=20 从 6/s 到 18.4/s),模型 blob 完全兼容。

五、踩坑清单

  1. Hindsight embedding 维度不可带数据迁移migrations.py 检测到「维度不同 + 有数据」直接抛错。跨维度 = 手工 SQL 重建,没有官方 re-embed 工具;
  2. 原始记忆与 embedding 分离存储是这次能保数据的根基:chunks.chunk_text + memory_units.text 留全文,只重建 embedding 列即可;
  3. pg0 内嵌 postgres 手动启动要设 LD_LIBRARY_PATH,指向 <pg0>/installation/<ver>/lib,否则缺 libicuuc.so.70
  4. Ollama OpenAI 兼容端点可作 Hindsight openai provider 的 base_url(/v1/embeddings),API key 随便填,不校验;
  5. NO_PROXY 陷阱:hindsight 容器配了 HTTP_PROXY=...:7890,embedding base_url 是宿主机局域网 IP 的话必须加进 NO_PROXY,否则请求被代理劫持;
  6. 吞吐主瓶颈是文本长度:短文本 6/s、真实文本 2.7-3.4/s;batch 选 10-50 就行;
  7. 批量回填 vectorUPDATE ... FROM unnest(%s::vector[], %s::uuid[])unnest 不能直接进 SET
  8. recreate 产生幽灵 worker 致 consolidation 12 天阻塞HINDSIGHT_API_WORKER_ID 默认取容器 hostname,docker compose down/up --force-recreate 后旧 processing 任务仍被旧 ID 持有,bank-serialization 致全库 pending_consolidation 3020 停滞。 fix:docker-compose.yml:42 固化 HINDSIGHT_API_WORKER_ID=hermes-worker-1,并用 hindsight-admin decommission-worker <old_id> --yes 释放;CONSOLIDATION_MAX_SLOTS=2 限流避免风暴。

六、Reranker 升级:ms-marco → bge-reranker-v2-m3

Embedding 换完之后,顺手把 reranker 也升级了。原来的 reranker 是 cross-encoder/ms-marco-MiniLM-L-6-v2——80M 参数、纯英文,中文检索精排等于瞎排。

为什么换

Hindsight 的 recall 流程是:embedding 向量粗筛 → reranker 精排。embedding 已经换成 qwen3(中文强),但 reranker 还是英文模型,中文 query-doc 对的相关性打分不准,等于精排环节白做了。

选型对比

模型 参数 语言 BEIR 接口 许可
ms-marco-MiniLM-L-6-v2(旧) 80M 纯英文 ~56 CrossEncoder MIT
bge-reranker-v2-m3 568M 100+ 语言 62.7 CrossEncoder Apache 2.0
Qwen3-Reranker-0.6B 600M 100+ 语言 56.9 CrossEncoder Apache 2.0
jina-reranker-v3.5 600M 100+ 语言 63.2 ❌ 自定义接口 CC BY-NC 4.0

选 bge-reranker-v2-m3 的理由:

  • Hindsight 用 sentence_transformers.CrossEncoder 加载 reranker,jina-reranker-v3.5 虽然分数最高(63.2),但用的是 AutoModel + trust_remote_code 自定义接口,不兼容;
  • bge-reranker-v2-m3 是标准 CrossEncoder 格式,Hindsight 直接加载,零改造;
  • 568M 参数,BEIR 62.7,比 Qwen3-Reranker-0.6B(56.9)高近 6 个点;
  • Apache 2.0 许可,无商用限制(jina 是 CC BY-NC)。

改动

docker-compose.yml 只改两处:

# Reranker 换多语言模型
- HINDSIGHT_API_RERANKER_LOCAL_MODEL=BAAI/bge-reranker-v2-m3

# 内存提升(568M 模型 FP32 加载约 +2.2GB)
mem_limit: 6g    # 原 4g

结果

  • 容器 healthy,启动日志 Reranker: local provider initialized (max_concurrent=4)
  • 首次下载 ~1.1GB,走代理约 80 秒
  • 内存峰值 ~3.3GB / 6GB,余量充足
  • 中文精排现在由 bge-reranker-v2-m3 负责,recall 质量有明显提升
  • (后续 2026-09-09:这个模型在 CPU 上把 recall 拖死了,已换成 mmarco 小模型 + 候选 50,详见第七节)

Ollama 能跑 reranker 吗?

不能。Ollama 基于 llama.cpp,reranker 模型(CrossEncoder/SequenceClassification 架构)无法转换为 llama.cpp 格式。社区有 GGUF 量化包但实际不工作。Hindsight 的 reranker 走 sentence-transformers 进程内加载,不走 Ollama。

七、翻车实录:reranker 换大了,整站记忆查询全挂

上面第六节写完第二天(2026-09-09),opencode 突然"断网"了:跟任何模型说话都是打出 > build · xxx 然后零输出,转圈到我手动掐掉。第一反应是代理挂了,结果代理好好的,各家接口直连、走代理全是 200。

--pure 跑一下 8 秒就回,说明模型端没事,卡的是插件——opencode 每次对话前会调 hindsight 的 autoRecall。直接测 recall 接口:110 秒零响应,最小参数也一样。再看容器:CPU 干到 1554%,内存 4.1/6G。90 分钟日志里 recall 发起了 23 次,只回来 8 个取消,剩下的全烂在里面,新请求永远排队。

根因就是第六节换的 bge-reranker-v2-m3。recall 默认一次重排 300 个候选(low 预算也一样,一个不少),568M 的模型在 CPU 上跑 300 个要分钟级。autoRecall 加几个 cron 一并发,16 核直接打满,后面的请求全饿死。重启容器也没用,稳定复现。

修法两步:

  1. 模型换小:cross-encoder/mmarco-mMiniLMv2-L12-H384-v1,120M 级,多语言含中文,recall 从超时回到 18 秒;
  2. 候选砍到 50:HINDSIGHT_API_RERANKER_MAX_CANDIDATES=50,recall 18 秒 → 4.9 秒,opencode 整链 50 秒 → 13 秒,容器 CPU 回到 1.4%。

连带两个小坑:模型 ID 必须写全(cross-encoder/...),裸 ID 去 HF 直接 401,容器进崩溃循环;HF 模型缓存没挂卷,每次重建容器都要重下一次,大模型慎选。

八、结论

qwen3-embedding:0.6b 是本机(无 GPU CPU 推理)的最优解:中文强、1024 维、639MB,同维度下体积/速度/质量综合吊打 bge-m3。

全流程复盘:备份 → SQL 改列 → 重嵌(~2.5-3h)→ 重建索引 → 改配置 → 验证。26996 条记忆全部保留,只重算了 embedding 列,换来中文语义检索从"搜不到"到"正常命中",日常 recall/reflect 开销几乎无感知。

最大的教训就一条:换 embedding 模型不用怕,内容本来就是和向量分开存的。真正要防的是官方迁移逻辑的硬拒绝——早确认好维度方案,别等插完数据才发现要重来。

再加一条 reranker 的:CPU 机器上模型不是越大越好。BEIR 高 6 个点的 568M 模型,配默认 300 候选能把整站查询拖死;120M 小模型 + 候选 50,质量够用、速度 4.9 秒。选型不看 CPU 账单,等于白选。


本文链接:Hindsight 中文检索太废?换 qwen3-embedding,2.7 万条记忆一条不丢 - https://h89.cn/archives/731.html

版权声明:原创文章 遵循 CC 4.0 BY-SA 版权协议,转载请附上原文链接和本声明。

标签: deepseek, Hindsight, Embedding, pgvector, ollama, postgresql, qwen3-embedding, HNSW, reranker, bge-reranker-v2-m3

欸谨特公众号
微信扫码关注:欸谨特
Agent · 效率工具 · 实战笔记

添加新评论