news 2026/9/28 18:48:38

降级检索闸门实战(附 Chroma 踩坑全记录)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
降级检索闸门实战(附 Chroma 踩坑全记录)

JobPilot RAG 学习记录 · 2026-09-26

一句话概括今天:把 Chroma 从 Docker 迁到本地进程、踩透"集合 UUID"的坑;然后顺着 RAG 最小闭环,逐层吃透了配置类代理、端口/适配器、导入状态机、一致性双防线、降级检索闸门,并亲手给降级检索加了一道"关键词命中数闸门"并双向验证。


目录

  1. 环境与运维:Chroma 本地进程 + 集合 UUID 之谜
  2. HttpClientConfig:proxyBeanMethods = false到底省了什么
  3. 端口/适配器:换模型为什么不是"改个配置"那么简单
  4. 导入状态机:为什么要"先写 PROCESSING"
  5. 一致性:孤儿向量与"MySQL 是事实来源"的双防线
  6. 降级检索闸门:今天动手的重点
  7. 调试方法论(元技能)
  8. 遗留 TODO(明天起)
  9. 附录:关键命令速查

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 端只是"记住"它。

踩坑三连:

  1. 亡魂 UUID:迁移到本地后,配置里还留着 Docker 时代的59936ea9...,本地根本没有这个集合 → 导入失败:

    InvalidCollection: Collection 59936ea9-... does not exist.

    教训:错误不是"配地址"时爆的,是"用地址寄快递"(upsert)时才爆的。

  2. 重启炸弹:把配置清空 → 代码走"新建集合"分支,一次跑绿(READY)。但 UUID 只缓存在内存字段collectionId,重启即失忆,又去新建 → 本地集合已存在 → 撞 409 → 抛我们手写的防御错误:

    Chroma 集合已存在但无法按名获取,请在 jobpilot.rag.chroma-collection-id 配置集合 UUID:jobpilot_chunks
  3. 固化:查真实 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"换来了三样:

  1. 可观测性:失败也留了记录,能GET /documents/{id}读到errorMessage(今天就是这么看到InvalidCollection的)。若只在成功时 insert,失败就是查无此人的幽灵。
  2. 隔离半成品(灵魂):从 insert 到置 READY 之间的时间窗里,文档存在但检索看不见它(检索只认 READY)→ 半成品绝不进检索。类比"上菜中"桌牌,服务员只端"已就绪"的桌。
  3. 重试钩子: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. 调试方法论(元技能)

  1. 读报错分"根因 vs 级联":一个缺失符号(Scored)会引发一串报错(含吓人的Cannot infer type argument)。找"最先不认识"的那个根,修好根,级联自动消失。
  2. Error vs Warning:Error 挡编译,Warning 不挡(今天剩的是selectBatchIds过时 +@NonNull空安全猜测)。先清 0 Error,再决定理不理 Warning。
  3. 判断命令成败看退出码,不看有没有印 SUCCESS:-q安静模式会吞掉BUILD SUCCESS,但吞不掉退出码 →echo $LASTEXITCODE(run-java21.cmd末尾exit /b %ERRORLEVEL%透传)。
  4. 改 Java 代码必须重启进程:JVM 启动时把.class载入内存,改源文件不影响运行中的旧类(没配热重载)。
  5. 分层诊断"连接被拒":先确认是哪个端口的服务拒了(8080 Java vs 8000 Chroma),别把"应用没起"当成"数据库挂了"。
  6. 遥测/日志里的 ERROR 可能是噪音:chroma run那句Failed to send telemetry无害,判断服务好坏看Uvicorn running on ...:8000。
  7. 一次只改一个变量:控制组测试用纯英文 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 修掉,再回头把降级闸门补上单测。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 18:46:53

AI编码代理安全:构建机密上下文边界的实用指南

1. 为什么AI编码代理成了保密战争中最容易忽视的那道防线先说一个我亲眼见过的真实事件。有个团队在生产环境跑着GitHub Copilot&#xff0c;也接入了Cursor&#xff0c;代码库里混着几十个微服务&#xff0c;服务之间的调用签名写得到处都是。某天做安全审计&#xff0c;他们在…

作者头像 李华
网站建设 2026/9/28 18:46:48

Agent记忆组件实战:从“金鱼脑”到长期记忆分层架构

1. 为什么Agent突然变成了"金鱼脑"做Agent开发的朋友可能都有过这种体验&#xff1a;单次对话里Agent表现得像个资深专家&#xff0c;工具调用行云流水、推理步骤条理清晰&#xff0c;但只要会话一结束&#xff0c;或者上下文窗口一被截断&#xff0c;它立刻把你忘得…

作者头像 李华