Hindsight 中文检索太废?换 qwen3-embedding,2.7 万条记忆一条不丢
- 一、选型:为什么是 qwen3-embedding
- 二、动手前的关键查证
- 三、执行五步走
- 四、性能实测:重嵌要多久,换完值不值
- 五、踩坑清单
- 六、Reranker 升级:ms-marco → bge-reranker-v2-m3
- 七、翻车实录:reranker 换大了,整站记忆查询全挂
- 八、结论
我的 Hindsight 记忆库(给 Hermes 用的记忆存储)默认 embedding 模型是 BAAI/bge-small-en-v1.5——纯英文、384 维。中文检索?基本形同虚设,存进去的中文记忆几乎搜不回来。
本机 LLM 走 DeepSeek,没有 embedding 端点,所以 embedding 只能自己想办法。换模型的路上有两条硬约束:
- 中文语义检索质量要提上来;
- 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,三张核心表:
chunks:chunk_text保留原始对话分段(4024 条);memory_units:text存结构化事实文本(26996 条),embedding是独立的vector(384)列;mental_models:embedding 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/chat的think:false有效,但 ModelfilePARAMETER 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 完全兼容。
五、踩坑清单
- Hindsight embedding 维度不可带数据迁移:
migrations.py检测到「维度不同 + 有数据」直接抛错。跨维度 = 手工 SQL 重建,没有官方 re-embed 工具; - 原始记忆与 embedding 分离存储是这次能保数据的根基:
chunks.chunk_text+memory_units.text留全文,只重建 embedding 列即可; - pg0 内嵌 postgres 手动启动要设
LD_LIBRARY_PATH,指向<pg0>/installation/<ver>/lib,否则缺libicuuc.so.70; - Ollama OpenAI 兼容端点可作 Hindsight
openaiprovider 的 base_url(/v1/embeddings),API key 随便填,不校验; - NO_PROXY 陷阱:hindsight 容器配了
HTTP_PROXY=...:7890,embedding base_url 是宿主机局域网 IP 的话必须加进NO_PROXY,否则请求被代理劫持; - 吞吐主瓶颈是文本长度:短文本 6/s、真实文本 2.7-3.4/s;batch 选 10-50 就行;
- 批量回填 vector 用
UPDATE ... FROM unnest(%s::vector[], %s::uuid[]),unnest不能直接进SET。 - 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 核直接打满,后面的请求全饿死。重启容器也没用,稳定复现。
修法两步:
- 模型换小:
cross-encoder/mmarco-mMiniLMv2-L12-H384-v1,120M 级,多语言含中文,recall 从超时回到 18 秒; - 候选砍到 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 版权协议,转载请附上原文链接和本声明。