JobPilot RAG 学习记录 · 2026-09-26
一句话概括今天:把 Chroma 从 Docker 迁到本地进程、踩透"集合 UUID"的坑;然后顺着 RAG 最小闭环,逐层吃透了配置类代理、端口/适配器、导入状态机、一致性双防线、降级检索闸门,并亲手给降级检索加了一道"关键词命中数闸门"并双向验证。
目录
- 环境与运维:Chroma 本地进程 + 集合 UUID 之谜
- HttpClientConfig:
proxyBeanMethods = false到底省了什么 - 端口/适配器:换模型为什么不是"改个配置"那么简单
- 导入状态机:为什么要"先写 PROCESSING"
- 一致性:孤儿向量与"MySQL 是事实来源"的双防线
- 降级检索闸门:今天动手的重点
- 调试方法论(元技能)
- 遗留 TODO(明天起)
- 附录:关键命令速查
1. 环境与运维:Chroma 本地进程 + 集合 UUID 之谜
1.1 弃用 Docker,改本地进程
开发期约定:外部依赖一律本地进程跑,不用 Docker。Chroma 用 pip 装进独立虚拟环境并钉死版本:
# 1. 建专用虚拟环境(在项目根目录下)python-m venv.venv-chroma# 2. 激活它(提示符前面会出现 (.venv-chroma) 字样——确认它出现了再往下).\.venv-chroma\Scripts\Activate.ps1# 3. 安装并钉版本(约几百 MB,耐心等)pip install chromadb==0.6.3# 版本必须钉死# 4. 验证版本python-c"import chromadb; print(chromadb.__version__)"chroma run--path chroma--port 8000- 为什么独立 venv:机器上默认
pip指向的是别的项目的 venv,chromadb 依赖很重,不能污染。 - 为什么钉死
0.6.3:最新版(镜像 latest)REST 路由换成了 CRN 格式(/api/v2/collections/{crn}/...),而我们的ChromaVectorStoreAdapter写的是 0.6.x 的/api/v1/collections/{uuid}/upsert。版本 = 适配器契约的一部分。 - 数据落哪:
chroma run --path chroma把数据存在当前工作目录下的./chroma/(含chroma.sqlite3)。工作目录变了,集合就"看不见"了。
1.2 集合 UUID 的真相(今天最大的坑)
核心事实:集合 UUID 由Chroma(Python)在建集合时生成(uuid4()),写进chroma.sqlite3,终身不变。Java 端只是"记住"它。
踩坑三连:
亡魂 UUID:迁移到本地后,配置里还留着 Docker 时代的
59936ea9...,本地根本没有这个集合 → 导入失败:InvalidCollection: Collection 59936ea9-... does not exist.教训:错误不是"配地址"时爆的,是"用地址寄快递"(upsert)时才爆的。
重启炸弹:把配置清空 → 代码走"新建集合"分支,一次跑绿(
READY)。但 UUID 只缓存在内存字段collectionId,重启即失忆,又去新建 → 本地集合已存在 → 撞 409 → 抛我们手写的防御错误:Chroma 集合已存在但无法按名获取,请在 jobpilot.rag.chroma-collection-id 配置集合 UUID:jobpilot_chunks固化:查真实 UUID 钉进yaml文件配置中,重启不再炸:
python-c"import chromadb; c=chromadb.PersistentClient(path='chroma'); print(c.get_collection('jobpilot_chunks').id)"# → d60c1651-7174-4abb-883f-310d385ffd2b(0.6.3 的"按名查询"REST 接口有缺陷返回 400,所以用
PersistentClient直接读本地 sqlite 绕过。)
决策代码——ChromaVectorStoreAdapter.ensureCollectionId()的两条分支:
privateStringensureCollectionId(){Stringcached=collectionId;if(cached!=null)returncached;synchronized(this){if(collectionId!=null)returncollectionId;// 分支 A:配置里有 UUID → 直接当真用(亡魂就死在这)if(configuredCollectionId!=null&&!configuredCollectionId.isBlank()){collectionId=configuredCollectionId;returncollectionId;}// 分支 B:配置为空 → 现场新建集合,拿回真实 UUIDMap<String,Object>createBody=Map.of("name",collectionName,"metadata",Map.of("hnsw:space","cosine"));CollectionDtocreated=restClient.post().uri("/api/v1/collections").contentType(MediaType.APPLICATION_JSON).body(createBody).retrieve().onStatus(HttpStatusCode::is4xxClientError,(req,res)->{/* 4xx 不抛 */}).body(CollectionDto.class);if(created==null||created.id()==null){thrownewIllegalStateException("Chroma 集合已存在但无法按名获取,"+"请在 jobpilot.rag.chroma-collection-id 配置集合 UUID:"+collectionName);}collectionId=created.id();returncollectionId;}}辨析:今天 e2e 截图里
80a64e7c...那种是MySQL 文档 ID(kb_document.id,MyBatisASSIGN_UUID生成),不是集合 UUID。两个命名空间,别混。
2. HttpClientConfig:proxyBeanMethods = false到底省了什么
@Configuration(proxyBeanMethods=false)publicclassHttpClientConfig{@BeanpublicClientHttpRequestFactoryragRequestFactory(){SimpleClientHttpRequestFactoryfactory=newSimpleClientHttpRequestFactory();factory.setConnectTimeout((int)Duration.ofSeconds(3).toMillis());// 连不上快速失败→触发降级factory.setReadTimeout((int)Duration.ofSeconds(120).toMillis());// 容忍本地模型冷加载returnfactory;}}关键区分(两件事,别混):
- A. 单例语义:
@Bean方法整个生命周期只被容器调一次,对象存进容器 ——false不破坏它,ragRequestFactory()依然只造一个、三个 Adapter 注入的是同一个对象(==成立)。 - B. 类内互调拦截:你在配置类里手写
otherBean()调用时,代理壳把它改道到容器取现成的 ——false关的是这个。
true(默认)= Spring 用CGLIB给你的类生成子类当代理壳(“总厨”),保证类内互调也拿单例;false= 类原样不动(“lite 轻量模式”),类内互调就是普通 Java 方法、每次 new。
结论:这里没有任何东西在类里手写调用ragRequestFactory(),B 保护无主可用 → 设false省掉 CGLIB 子类的运行时开销。不是"因为没人调用所以功能不同",而是"结果相同但省了代理代价"。
遗留思考(M-2):一个工厂 = 一套超时(连接 3s/读 120s)。Ollama(本地)、Chroma(本地)、未来的 DeepSeek(公网) 合理超时各不相同。"一个 bean 全员共享"迟早要拆。
3. 端口/适配器:换模型为什么不是"改个配置"那么简单
端口(业务层只依赖它,像墙上的插座标准):
publicinterfaceEmbeddingPort{List<Double>embed(Stringtext);intdimension();}适配器(把标准插头接到具体电路,像充电器):
// OllamaEmbeddingAdapter:Ollama 特有的协议Map.of("model",model,"input",List.of(text))// ← Ollama 的请求格式.uri("/api/embed")// ← Ollama 的路径...publicintdimension(){return1024;}// bge-m3 固定 1024 维换模型分两种情况(今天的核心认知):
- 同协议换模型(bge-m3 → 另一个 Ollama 模型):改配置
embedding-model基本够。 - 跨协议换供应商(→ OpenAI):路径/鉴权/JSON 全不同 →必须新写一个
OpenAiEmbeddingAdapter implements EmbeddingPort,光改配置名会 404/401。配置改不了物理插头。
还有一颗雷——维度:换 embedding 模型 = 向量维度可能变(1024→1536),而同一 Chroma 集合里不能混不同维度的向量→ 必须重建索引。这正是表里index_version字段、向量 IDdocId#seq#indexVersion后缀存在的意义:能力可以先不做,扩展点先留好。
端口/适配器隔离的是业务代码;它不能隔离"协议差异"(要新写适配器)和"维度变了要重建"(要动 index_version)。诚实承认这两点,抽象才不是魔法。
4. 导入状态机:为什么要"先写 PROCESSING"
DocumentIngestService.ingest骨架(ARCHITECTURE.md §7.3 的可重试状态机):
doc.setStatus("PROCESSING");documentMapper.insert(doc);// 先落库建"病历本"try{List<ChunkPart>parts=chunkSplitter.split(...);for(ChunkPartpart:parts)indexChunk(doc,part);doc.setStatus("READY");// 全部成功才置为可检索doc.setChunkCount(parts.size());}catch(Exceptione){doc.setStatus("FAILED");doc.setErrorMessage(truncate(e.getMessage()));}documentMapper.updateById(doc);"先写 PROCESSING"换来了三样:
- 可观测性:失败也留了记录,能
GET /documents/{id}读到errorMessage(今天就是这么看到InvalidCollection的)。若只在成功时 insert,失败就是查无此人的幽灵。 - 隔离半成品(灵魂):从 insert 到置 READY 之间的时间窗里,文档存在但检索看不见它(检索只认 READY)→ 半成品绝不进检索。类比"上菜中"桌牌,服务员只端"已就绪"的桌。
- 重试钩子:
FAILED+ 原因是将来"只重试这一篇"的落点(注意:目前只是预留,还没做重试入口)。
5. 一致性:孤儿向量与"MySQL 是事实来源"的双防线
indexChunk里先写 Chroma、后写 MySQL,且方法没有@Transactional:
StringvectorId=doc.getId()+"#"+part.seq()+"#"+doc.getIndexVersion();List<Double>vector=embeddingPort.embed(part.text());vectorStore.upsert(vectorId,vector,Map.of("user_id",doc.getUserId(),"document_id",doc.getId(),"doc_type",doc.getDocType(),"index_version",doc.getIndexVersion()));// ...chunkMapper.insert(chunk);// 若这步失败:Chroma 有向量、MySQL 无原文行 → 孤儿向量若 upsert 成功、insert 失败会怎样?会不会返回"有向量、查无原文"的幽灵结果?——不会,loadReadyChunks有双防线:
List<KbChunkEntity>chunks=chunkMapper.selectBatchIds(vectorIds);// 防线①:回捞不到原文行 → 丢弃// ...docWrapper.in("id",docIds).eq("status","READY");// 防线②:文档非 READY → 丢弃- 防线①(回捞原文)直接体现"引用原文以 MySQL 为准,向量不是事实来源"——Chroma 命中了又怎样,MySQL 拿不出原文就不算数。
- 防线②(READY 闸门):insert 抛异常 → 冒泡到 catch → 整篇
FAILED→ 被这道过滤挡下。 - 总保险是状态机本身:全部 chunk 成功才置 READY,任何半成品停在 FAILED,从没机会变"可检索"。状态机是大伞,两道过滤是伞下网眼。
诚实话:孤儿向量不影响正确性(被挡),但是脏数据。将来做 reindex 第一件事就是清孤儿向量。
6. 降级检索闸门:今天动手的重点
6.1 Bug:score=0.0绕过阈值 → 拒答失效
原降级把每条命中score写死 0.0、且跳过阈值截断。而RagAskService.ask的拒答判断只有一句if (evidence.isEmpty())。VECTOR 模式靠阈值把垃圾筛成空 → 拒答守得住;KEYWORD 模式没有这道筛 → 只要 LIKE 凑巧命中就非空 →拒答被绕过,弱证据硬喂给模型。
设计原则:降级结果可信度更低,本该被更严对待,而不是更松。
6.2 选方案:绝对命中数闸门(否决 IK 分词器)
三个候选里选了"检索层给降级结果算分 + 过阈值"。否决 IK:它是 ES 插件,而降级的意义就是"没有 ES/Chroma 也能兜底",把 ES 拉进降级 = 自毁降级。
架构铁律:降级路径绝不能反过来依赖它要绕开的那个组件。
打分信号用"命中的不同关键词个数"(绝对数,不吃分词质量、不受"被拆成几个词"摆布),比"覆盖率"稳。
6.3 配置化 + record 加字段的连锁
keywordMinHits进配置(调参旋钮,不硬编码),默认占位 2,待评测集校准:
# application.ymljobpilot:rag:similarity-threshold:0.45keyword-min-hits:2# 降级检索:至少命中 N 个不同关键词才算证据;待评测集校准// RagProperties(record 末尾加字段)publicrecordRagProperties(StringollamaBaseUrl,StringembeddingModel,StringchatModel,StringchromaBaseUrl,StringchromaCollection,StringchromaCollectionId,intchunkSize,intchunkOverlap,inttopK,doublesimilarityThreshold,intkeywordMinHits){}连锁反应:record 用位置参数构造,加一个字段 → 所有new RagProperties(...)构造点全要补参数(今天炸了 2 个测试文件,各补, 2)。这是 record 简洁的代价。
6.4 过早截断(Premature Truncation)
原 SQLORDER BY document_id, seq LIMIT topK会先把高命中但入库靠后的文档砍掉,再在 Java 里排序就毫无意义了。类比"HR 按投递时间砍到前 5,你再说要按能力排前 5"。修法:放大候选池,排序/淘汰交给 Java。
6.5 最终实现(KnowledgeRetrievalService.keywordSearch)
intcandidatePool=Math.max(topK*4,20);wrapper.orderByAsc("document_id","seq").last("LIMIT "+candidatePool);returnchunkMapper.selectList(wrapper).stream().map(chunk->newScored(chunk,countHits(chunk.getText(),keywords))).filter(s->s.hits()>=props.keywordMinHits())// 降级闸门:命中太少不算证据.sorted(Comparator.comparingInt(Scored::hits).reversed()).limit(topK).map(s->toRetrieved(s.chunk(),s.hits()))// score = 命中数,不再是写死的 0.0.toList();// 中转载体 + 计数工具privaterecordScored(KbChunkEntitychunk,inthits){}privateintcountHits(Stringtext,List<String>keywords){inthits=0;for(Stringkw:keywords){if(text.contains(kw))hits++;}returnhits;}sorted不用写"命中数相同再按 document_id",因为Stream.sorted是稳定排序,命中数相同的天然保持 SQL 的document_id, seq顺序。- 降级证据用独立的
keywordMinHits当闸门,不套用similarity-threshold(0.45)——因为命中数和余弦相似度量纲不同。
6.6 双向验证(今天最漂亮的一步)
停掉 Chroma 制造降级,测两个方向:
| 查询 | 结果 | 说明 |
|---|---|---|
Java 今天天气怎么样(只命中 Java=1) | KEYWORD_FALLBACK+citations:[]+ 拒答 | 闸门拦住垃圾✅ |
RAG MySQL Chroma(命中多个) | KEYWORD_FALLBACK+citations非空 + 真回答 | 闸门放过业主✅ |
一个把啥都拦死的闸门,和没闸门一样废——必须双向都验。
但控制组RAG 检索降级是怎么触发的返回了citations:[]—— 抓到一个真 bug:extractKeywords把中文按3 字滑窗拆成检索降/索降级/降级是...,而文档里是"检索""降级"这些两字词,3 字碎串一个都对不上,只剩RAG命中=1 → 被闸门拦下。闸门没错,是分词把召回饿死了——这正印证了当年"IK 分词"讨论的病理(只是药不能是 ES)。
7. 调试方法论(元技能)
- 读报错分"根因 vs 级联":一个缺失符号(
Scored)会引发一串报错(含吓人的Cannot infer type argument)。找"最先不认识"的那个根,修好根,级联自动消失。 - Error vs Warning:Error 挡编译,Warning 不挡(今天剩的是
selectBatchIds过时 +@NonNull空安全猜测)。先清 0 Error,再决定理不理 Warning。 - 判断命令成败看退出码,不看有没有印 SUCCESS:
-q安静模式会吞掉BUILD SUCCESS,但吞不掉退出码 →echo $LASTEXITCODE(run-java21.cmd末尾exit /b %ERRORLEVEL%透传)。 - 改 Java 代码必须重启进程:JVM 启动时把
.class载入内存,改源文件不影响运行中的旧类(没配热重载)。 - 分层诊断"连接被拒":先确认是哪个端口的服务拒了(8080 Java vs 8000 Chroma),别把"应用没起"当成"数据库挂了"。
- 遥测/日志里的 ERROR 可能是噪音:
chroma run那句Failed to send telemetry无害,判断服务好坏看Uvicorn running on ...:8000。 - 一次只改一个变量:控制组测试用纯英文 token 隔离,才把"分词问题"和"闸门问题"区分开。
8. 遗留 TODO(明天起)
- 治中文分词:让
extractKeywords对中文额外产出 2 字窗口(检索、降级能命中),且不引入 ES。改完复测RAG 检索降级是怎么触发的应能放行。 keywordMinHits用评测集校准(现在是拍脑袋的 2),同时回看similarity-threshold=0.45。- 降级闸门补单测:命中<N 被拦、命中≥N 放行,各一条。
- reindex / 清理孤儿向量(§5 留的活)。
- M-2:一个 HTTP 工厂三套超时的拆分(§2 遗留)。
9. 附录:关键命令速查
# 编译(安静模式)+ 用退出码确认成败.\run-java21.cmd-qtest-compileecho$LASTEXITCODE# 0 = 成功# 启动后端(改代码后必须重启).\run-java21.cmd spring-boot:run# 本地 Chroma(独立 venv、钉版本、数据落 ./chroma)python-m venv.venv-chroma.\.venv-chroma\Scripts\Activate.ps1 pip install chromadb==0.6.3 chroma run--path chroma--port 8000# 查真实集合 UUID(绕过 0.6.3 坏掉的按名查询接口)python-c"import chromadb; c=chromadb.PersistentClient(path='chroma'); print(c.get_collection('jobpilot_chunks').id)"# 降级问答测试(先 Ctrl+C 停 Chroma 制造降级)$body='{"userId":"u-demo","question":"RAG MySQL Chroma","topK":5}'Invoke-RestMethod-Uri http://localhost:8080/api/v1/knowledge/ask-Method Post `-ContentType'application/json; charset=utf-8'`-Body([Text.Encoding]::UTF8.GetBytes($body))|ConvertTo-Json-Depth 6明日开场:先做 §8 第一条(中文分词 2 字窗口),把今天控制组暴露的 bug 修掉,再回头把降级闸门补上单测。