文章目录项目场景CTI-RAG时踩过的 3 个坑向量召回退化、子图爆炸与跨模态重排坑一向量变多后召回率反而下降原因分析修正先用 Milvus filter 缩小候选空间再做向量召回坑二图召回时子图爆炸原因分析修正先控制子图规模再把原始图变成可读证据坑三图和向量结果不能直接混在一起重排序原因分析修正先对子图做摘要再参与统一重排回头看这三个坑其实是同一个问题1. 检索前先缩小搜索空间2. 图召回先控规模再谈覆盖3. 异构证据先统一表达再做统一排序结尾项目场景CTI-RAG时踩过的 3 个坑向量召回退化、子图爆炸与跨模态重排做CTI-RAG这个项目时我一开始的想法其实很直接把传统 RAG 往前推一步在网络威胁情报CTI场景里把向量检索、知识图谱和大模型回答串起来尽量让系统既能召回文本证据也能给出结构化关系。在小规模数据上这条链路看起来是成立的。文档切块之后进向量库实体关系抽取之后进 Neo4j查询进来先走检索再把结果喂给模型生成回答。最初的问题不是“能不能跑起来”而是“看起来都能跑”。真正的麻烦基本都是在数据和图规模上来之后才出现的。这篇文章不打算完整介绍CTI-RAG的所有模块而是只复盘三个对我影响最大的坑向量数量变多之后召回率反而下降了。图召回一旦放开子图规模很容易爆炸。图结果和向量结果看起来都叫“召回结果”但其实并不能直接放在一起做重排序。用户问题向量召回图召回Milvus Filter 缩小候选范围文本候选块子图裁剪/规模控制子图摘要统一重排序最终上下文LLM 生成回答这三个问题表面上分别发生在向量检索、图检索和重排序阶段但后来我回头看它们本质上都指向同一件事RAG 系统一旦进入真实规模问题就不再只是“能不能召回”而是“能不能把召回控制在可用范围内”。CTI-RAG这套链路不是单纯的“向量库 LLM”。它更接近一个面向 CTI 场景的 GraphRAG文档会先切块并写入向量库用来做文本层面的语义召回。文档中的实体和关系会被抽出来写进图数据库用来补足多跳关系和结构化证据。查询进来之后系统会按配置决定是否做知识库召回、图召回以及最后的重排序和上下文融合。这个方向本身没有问题问题在于我一开始对“召回”的理解太理想化了。我默认觉得数据更多召回应该更好图更丰富证据应该更全重排序放在后面应该可以自动把坏结果压下去。后来我发现这三个判断都只在规模还小的时候成立。坑一向量变多后召回率反而下降最早做知识库检索的时候我的直觉非常朴素既然向量检索是做语义相似度匹配那数据越多理论上可选证据就越丰富召回效果应该更稳才对。结果恰好相反。随着文档量增长、chunk 数量变多我开始越来越频繁地遇到一种情况用户问题明明是对的知识库里也确实有相关内容但 top-k 里会混进大量“看起来相似、实际上没用”的 chunk。更糟的是这些噪声 chunk 往往并不是完全不相关它们只是停留在同主题、同术语、同场景的表面相似层面恰好足以把真正关键的片段挤出去。原因分析一开始我把问题想歪了。我先怀疑 embedding 模型不够强后面又怀疑切块粒度不对甚至一度想过是不是要继续堆更复杂的 reranker。后来回头看真正的问题没有那么“模型化”反而更工程一些检索空间太大了。当向量库里同时存在不同来源、不同主题、不同文件层级的 chunk 时单纯依赖全局相似度搜索其实是在一个过大的候选空间里做 top-k 竞争。问题不是模型完全分不出相关和不相关而是当大量“半相关”候选同时存在时真正高价值的片段很容易被噪声稀释。修正先用 Milvus filter 缩小候选空间再做向量召回这件事想通之后调整方向就变了我不再把重点放在“怎么让模型更懂”而是先解决“怎么别让模型在错误范围里找”。具体做法就是在 Milvus 侧加filter先用元数据约束候选集合再执行相似度检索。比如先限制知识库范围而不是全库混搜。按文件、来源、任务上下文或实体关联范围做预过滤。把本次问题无关的数据块尽量挡在相似度计算之外。defretrieve_from_milvus(query,db_id,file_idsNone,sourceNone):filter_parts[fdb_id {db_id}]iffile_ids:file_expr, .join([f{fid}forfidinfile_ids])filter_parts.append(ffile_id in [{file_expr}])ifsource:filter_parts.append(fsource {source})filter_expr and .join(filter_parts)returnmilvus_client.search(collection_namethreatrag_chunks,data[embed(query)],anns_fieldvector,limit10,filterfilter_expr,output_fields[file_id,text,source],)重点不是这段代码本身而是检索顺序变了。以前我的思路更像是resultsmilvus.search(query_vector,top_k10)resultsrerank(results)后来我更接受这种顺序candidate_scopefilter_by_metadata(db_id,file_ids,source)resultsmilvus.search(query_vector,filtercandidate_scope,top_k10)resultsrerank(results)这样做的价值不在于让模型突然变聪明而在于让 top-k 的竞争环境变干净了。原来是“所有相似候选一起抢位置”加了 filter 之后变成“更有可能相关的一小批候选里再排前几名”。从实际效果看召回结果的稳定性会明显好很多真正相关片段进入前几位的概率也更高。这一步对我的启发很大因为它改变了我看待知识库召回的方式在知识库检索里先约束搜索空间往往比继续调 embedding 更有效。很多时候召回退化不是模型不够强而是搜索空间不够干净。坑二图召回时子图爆炸向量召回的问题还算常规图召回的问题就更“GraphRAG”了。我最初对图召回的设计是既然 CTI 问题经常涉及攻击者、样本、基础设施、漏洞、攻击手法这些多跳关系那就可以围绕命中的实体向外扩展邻居做一个局部子图把结构化证据一起送给下游模型。这个思路在小图上是好用的。只要节点和边不多局部扩展出来的子图很快就能把关系链补齐模型看到的上下文也更完整。原因分析但图一旦变大问题会迅速出现。尤其是当某些核心实体本身连接度很高时多跳扩展会带来非常典型的组合爆炸hop 稍微放大候选节点数会迅速增长。边的数量往往比节点涨得更快。最终送进下游的上下文不再是“围绕当前问题的证据图”而更像是“某个实体附近能碰到的所有关系堆积”。这类问题最麻烦的地方在于它不是完全错误的召回。你很难说这些边绝对无关因为它们从图结构上确实都连着但它们又经常并不真正服务当前问题。结果就是 token 飙升、噪声增加、答案变得更不稳定。一开始我也犯过一个很典型的误判我以为图召回的问题在于“还不够全”所以我尝试过放大 hop、增加种子实体、尽量多保留边。后来发现这恰好走反了。GraphRAG 很多时候的问题不是召不到而是召太多。修正先控制子图规模再把原始图变成可读证据图召回这块我最后不是靠一个单点优化解决的而是把思路从“尽量多拿”改成“尽量可控”。我主要做了几件事先限制起始实体不让所有命中实体都同时扩展。控制 hop不把多跳扩展默认当成越大越好。给边数和子图规模设置上限避免局部高连接节点把整个上下文拖爆。最关键的一点是不再把原始子图直接交给下游重排序和生成。这最后一点特别重要。因为原始子图本身并不是适合直接消费的证据形式。它更像是“候选结构”不是“最终上下文”。如果把图数据库里拉出来的节点和边原样扔给后面的模块后面看到的其实是一堆结构碎片而不是一组可比较、可压缩、可推理的证据单元。所以我后面做的不只是“裁剪子图”而是把图召回的目标从“拿到子图”改成“拿到足够小、足够关键、可表达的子图”。从结果看这种做法带来的收益很直接一方面上下文规模能压住另一方面关键关系并没有因为压缩就全部丢掉。对图召回来说真正有用的不是“图有多大”而是“当前问题真正需要看到多少结构证据”。坑三图和向量结果不能直接混在一起重排序第三个坑算是前两个问题叠加之后自然暴露出来的。当时我的想法很自然既然最终都要给模型一个“更优的上下文”那就把文本召回结果和图召回结果汇总起来一起丢给 reranker让它做统一排序不就行了听起来非常合理但真正做起来效果并不好。原因也很简单图结果和向量结果虽然都叫召回结果但它们根本不是同一种东西。原因分析向量召回返回的通常是自然语言 chunk。它本身就是可读文本句子结构、上下文边界、语义密度都比较接近用户问题。图召回返回的则往往是节点、边、关系片段或者是非常碎的三元组表达。它在结构上是对的但在语义层面并不是面向阅读和比较优化过的。问题就出在这里。如果把自然语言段落和图关系碎片直接放在一起让 reranker 打分排序本身会变得很不稳定文本 chunk 往往因为表达完整更容易拿到高分。图边虽然可能关键但因为粒度太碎语义上不够“像答案证据”分数容易偏低。即使图边进入排序结果最终上下文也容易变成“文本片段 结构碎片混排”对生成模型并不友好。这个问题后来让我意识到异构证据并不能因为都进入了“召回阶段”就自动进入同一个比较空间。重排序不是万能胶。修正先对子图做摘要再参与统一重排真正有效的改法不是“继续换更强的 reranker”而是先解决输入形式的问题。我的做法是先把图召回出来的子图转成摘要或关系描述块再把它和文本 chunk 一起交给 reranker。这样做的核心不是美化输出而是把图证据先压缩成一个可以被比较的语义单元。这个过程可以理解成两步图召回负责把结构范围缩小到一个可控子图。子图摘要负责把结构化关系翻译成自然语言证据块。等到了这一步重排序才真正有意义。因为 reranker 面对的不再是“自然语言段落 vs 图数据库边”而是“不同来源的证据块”。它们虽然来源不同但至少在表达层面已经更接近了。这样做之后排序结果会稳定很多。最终送给生成模型的上下文也更像一组有组织的证据而不是把结构数据硬拼进去的混合杂糅物。这件事带给我的结论非常明确不要让 reranker 直接面对裸图结构先把图压成可比较的语义单元。如果异构证据不先对齐表达分数本身就不可靠。回头看这三个坑其实是同一个问题写到这里再回头看我觉得这三个问题虽然发生在不同阶段但底层逻辑很一致。向量召回退化说到底是搜索空间没有被约束。子图爆炸说到底是结构召回没有被控制。图和向量无法稳定混排说到底是异构证据没有先做表达对齐。所以如果现在让我把这段经历压缩成三条经验我会这么总结1. 检索前先缩小搜索空间不要默认“更多候选 更好召回”。在知识库规模变大之后约束候选集合本身就是召回质量的一部分。Milvus filter 这类能力很多时候不是优化项而是必要项。2. 图召回先控规模再谈覆盖GraphRAG 不是把更多边塞进上下文里就会更强。图证据的价值来自相关性不来自体积。只要子图失控后面的总结、重排和生成都会跟着一起失控。3. 异构证据先统一表达再做统一排序文本和图都可以成为证据但它们不应该在原始状态下直接竞争排序分数。先把结构化结果压成可读、可比较的语义块再做统一排序整个链路会稳定得多。结尾CTI-RAG这个项目让我很直观地体会到一件事RAG 系统的难点从来不只是在“召回”和“生成”这两个词本身而是在真实规模下怎么把召回结果控制在一个下游模型真正能消费的范围里。我以前会更关注“有没有召回来”后来越来越关注“召回的是不是干净”“子图是不是已经失控”“这些证据能不能被统一处理”。从这个角度看这篇文章里写的三个坑其实不是某个模型、某个库、某个参数的问题而是系统设计视角的问题。如果后面还继续迭代CTI-RAG我大概率也还是会围绕这条线继续做先控制范围再组织证据最后再谈模型效果。因为在真实系统里很多时候不是模型太弱而是上下文太乱。