langchain4j-RAG企业真实项目实战-检索生成 LangChain4j 实战系列第三篇也是我认为最见功力的一篇检索生成。前两篇我们把项目骨架和文档入库讲完了知识已经存进去了这一篇解决另一半问题——用户开口提问之后系统怎么把对的知识、以对的形式、稳定地送到模型面前再把答案流式吐回去。项目地址:项目github地址先摆问题一个最朴素的 RAG 实现是这样的用户问题 → embedding → 向量库找 Top-K → 塞进 prompt → 模型回答。能跑但你真拿自己的业务去试几下马上会发现三个问题第一用户不会好好提问。我们的业务是一个面料撮合交易平台用户真实的问题长这样“这个多少钱一公斤”“它有什么缺点”——单独看这句话什么信息都没有。不带上下文去检索召回的全是垃圾。第二纯向量检索查得广但查不准。面料行业一堆专业术语“32支精梳全棉”“不倒绒”“奥代尔拉架”向量模型对这些词的语义表征未必可靠经常给你召回一堆看起来相关但其实没用的段落。反过来精确的订单号、货号这类关键词语义检索又经常漏。第三链路太长哪一环都可能抽风。改写要调模型、检索要打 ES、图检索要走 Neo4j、重排要跑本地模型、生成要等大模型首 token。任何一环慢了挂了用户看到的就是一个转圈的聊天框。我的整条检索生成链路就是围绕这三个问题设计的。先看全景再逐段拆。完整链路泳道图入口是 SSE 流式接口POST /api/chat/conversations/{id}/messages/streamMySQLragStreamingChatModel(qwen-plus)IntentContentInjectorReRanker(bge-reranker ONNX)Neo4j/SQLElasticsearchGuardedContentRetrieverHybridSearchContentRetrieverIntentBasedQueryRouterQueryRewriteTransformerDefaultRetrievalAugmentorIntentRecognition(qwen-turbo)RagChatServiceImplChatRateLimiterRagChatController(SSE)用户(chat.html)MySQLragStreamingChatModel(qwen-plus)IntentContentInjectorReRanker(bge-reranker ONNX)Neo4j/SQLElasticsearchGuardedContentRetrieverHybridSearchContentRetrieverIntentBasedQueryRouterQueryRewriteTransformerDefaultRetrievalAugmentorIntentRecognition(qwen-turbo)RagChatServiceImplChatRateLimiterRagChatController(SSE)用户(chat.html)售前→ESNeo4j价格→ESNeo4jSQL售后/操作→ES熔断Bulkhead20超时5s任何异常降级空结果loop[流式推送]提问 [header带satoken]1限流(每用户10次/10秒)2USER消息落库 清记忆缓存3recognizeIntent(带多轮上下文)4PRE_SALES/POST_SALES/PRICE_INQUIRY/...5streamChat() [虚拟线程,意图经ThreadLocal传入]6步骤1 [RAG_STATUS]改写查询7加载最近3轮历史(每条截100字)8qwen-plus改写(≤20字,失败回退原查询)9步骤2 [RAG_STATUS]路由10复用已识别意图,GENERAL返回空列表11步骤3 [RAG_STATUS]检索知识库12knn向量(minScore 0.7) BM25 两路13RRF(k60)融合,按文本去重14权限过滤(accessibleBy,超量3倍先滤后截)15命中子片→回溯父片(Redis 24h缓存)16收集引用写ReferenceContext17(其他检索源经防护层)18Neo4j text2cypher / SQL text2sql19精排(只重排不过滤,minScore0)20按意图加载 prompt/{intent}.txt注入 {{contents}}21步骤4 [RAG_STATUS]生成回答22Flux 逐token23data: token24[RAG_REFERENCES] JSON 引用来源25event: done (messageId/tokenCount)26ASSISTANT消息落库(含rag_references)27整条链路严格来说是 LangChain4j 的DefaultRetrievalAugmentor在驱动Transformer → Router → Retriever → Aggregator → Injector → 模型我做的事情就是把这五个插槽每个都换成自己的实现。下面挨个讲。意图识别花小钱办大事第一个设计决策在检索之前先花一次极便宜的模型调用判断用户到底想干什么。用的是一个独立的AiService模型配的是最便宜的qwen-turbo记忆用的是独立的intentChatMemoryProvider窗口 10 条和业务对话记忆完全隔离AiService(wiringModeAiServiceWiringMode.EXPLICIT,chatModeltitleChatModel,chatMemoryProviderintentChatMemoryProvider)publicinterfaceIntentRecognitionService{SystemMessage( 你是一个面料撮合交易平台的用户意图识别助手。... 分类为以下五种意图之一 - PRE_SALES面料咨询、找布需求、面料推荐、样品索取... - POST_SALES退换货、质量问题、物流查询、投诉... - PRICE_INQUIRY价格查询、报价请求、大货价/剪版布价格... - SYSTEM_OPERATION平台使用方法、功能操作指引、账号问题... - GENERAL与面料交易无关的日常聊天 ...只输出枚举名称不要输出任何其他内容。)UserMessage(用户消息{{userMessage}})ChatIntentrecognizeIntent(MemoryIdStringconversationId,V(userMessage)StringuserMessage);}几个心得prompt 里塞例子比讲道理管用。我在分类规则后面跟了十来个真实样例精棉苏绒大货价多少钱一公斤→ PRICE_INQUIRY你好→ GENERAL小模型的分类准确率肉眼可见地稳了。只让它输出枚举名。下游是ChatIntent枚举解析多一个字的废话都会增加解析失败的面积。框架自动带上下文。MemoryId一挂这个会话的历史就自动进来了这个多少钱这种指代性提问模型看着上文就能判对是 PRICE_INQUIRY。意图识别完全失败怎么办RagChatServiceImpl里包了一层recognizeIntentSafely异常直接降级GENERAL——宁可让用户得到一句没有知识库支撑的普通回答也不让分类这个前置动作把整个请求卡死。查询改写把它有什么缺点变成能检索的话意图管走哪条路改写管用什么词去查。QueryRewriteTransformer实现的是 LangChain4j 的QueryTransformer接口核心就三件事从库里捞出该会话最近3 轮对话HISTORY_TURNS * 2条消息每条内容截 100 字拼成用户xxx / 助手xxx的文本有历史用带历史的 prompt没历史用无历史的 prompt共同要求是提取核心意图、用更专业的术语、不超过 20 个字、直接输出结果不要解释改写为空或抛异常一律回退原始查询效果就是注释里那两个例子上文聊过帮我找32支精梳全棉的卫衣布料 → 用户问这个多少钱 → 改写成“32支精梳全棉卫衣布料 价格”上文聊过有没有不倒绒的供应商 → 用户问它有什么缺点 → 改写成“不倒绒面料 缺点 特性”这里有个克制的地方我挺满意只返回一个改写后的 Query不做多查询扩展。多路扩写听起来美好但每多一个 query 就是把检索成本乘一遍而且多个改写版本之间还会互相稀释重排的效果。我现在的场景里改写准了一个就够了。意图路由与 ThreadLocal 的坑IntentBasedQueryRouter实现QueryRouter拿意图查映射表选检索器组合。GENERAL 直接返回空列表——不检索就是最快的检索。未配置的意图和一切异常统一兜到 PRE_SALES 的组合保证有得查。但这里藏着一个我踩过的小坑意图在 Controller 层已经识别过一次了管道内部 Router 还要用难道再花一次模型调用当然不行。我的做法是搞了一个IntentContextHolderThreadLocal主流程把意图、conversationId、userType 三个东西设进去Router 先getIntent()有就直接复用ChatIntentintentIntentContextHolder.getIntent();if(intentnull){intentintentRecognitionService.recognizeIntent(conversationId,query.text());}为什么这个 ThreadLocal 能一路传下去因为DefaultRetrievalAugmentor的管道是同步执行的改写、路由、检索、注入全在同一个线程里跑完我把整个管道包在虚拟线程里。管道终点在IntentContentInjector的finally里统一clear()——ThreadLocal 不清理线程一复用上一个用户的意图就漏给下一个用户了这种 bug 查起来能让人怀疑人生。路由映射表本身在RagConfig里装配PRE_SALES → [ES 混合检索, Neo4j 图检索]POST_SALES / SYSTEM_OPERATION → [ES]PRICE_INQUIRY → [ES, Neo4j, SQL]价格为什么要走三路因为大货价剪版布价这种数据一部分在合同文档里ES一部分是结构化的价格表SQL text2sql 去查表比查向量准得多一部分是面料-供应商-报价的关系Neo4j。让数据待在它最该在的地方检索的事交给路由。混合检索两路召回 应用层 RRF这是链路的腰眼。HybridSearchContentRetriever把 ES 一次打两枪// 1. 向量语义检索knn带 minScoreEmbeddingSearchRequestrequestEmbeddingSearchRequest.builder().queryEmbedding(queryEmbedding).maxResults(fetchSize).minScore(minScore)// 0.7.build();EmbeddingSearchResultTextSegmentvectorResultstore.search(request);// 2. BM25 关键词检索match text 字段ListBm25Hitbm25HitsexecuteBm25Search(query,fetchSize);// 3. 应用层 RRF 融合k 60doublerrfScore1.0/(rrfKrank1);rrfScores.merge(text,rrfScore,Double::sum);// 同一段文本两路都命中,分数相加为什么在应用层手写 RRF不用 ES 自带的因为 ES 原生 RRF 是白金版以上才给的功能我用的基础许可没有。而 RRF 这算法本身简单得要命——谁排得靠前谁的1/(krank)大两路都命中的直接加分按文本内容做 key 去重——应用层十几行就写了还不绑版本。BM25 那一路挂了怎么办我的executeBm25Search里整个包了 try-catch失败就返回空列表退化成纯向量检索。同理向量那路也有 minScore 0.7 卡着低质量命中。混合检索的优雅之处在于任何一路失灵系统自动退回单路而不是整个报错。权限过滤和超量召回的坑重点说。检索结果按 metadata 里的accessibleBy过滤但过滤这件事有个陷阱你是先截断 topK 再过滤还是先过滤再截断顺序错了低权限用户搜出来的东西会被无权文档白占名额截得七零八落。我的做法是超量取回 3 倍PERMISSION_OVERFETCH 3先融合、再权限过滤、最后才截断到 topK5。还有一个安全细节前面入库篇讲过metadata 缺失或没有 accessibleBy 的结果一律当最高密级处理只放行 STAFF权限的默认值必须是拒绝。父子回溯与引用收集粗召回之后还有一步兑现入库篇埋的伏笔命中子片回溯父片。resolveParentChunks的逻辑// 1. 收集命中子片的 parentChunkId// 2. 批量拿父片内容: Redis(rag:parent_chunk:{id}, TTL 24h) 优先, miss 回查 DB 并回填// 3. 移除命中的子片 结果里所有同父的兄弟片// 4. 父片以固定 score1.0 插回结果为什么要连兄弟片一起移除想一个场景一段 2000 字的父片切成 4 个子片向量检索一下命中了其中两个子片这俩子片的父片是同一个。你回溯出父片之后如果原来那两条子片还在结果里等于同一段内容在 prompt 里出现三遍token 白烧。所以同父的全部子/兄弟片干掉只留一份父片score 给 1.0它已经是最完整的上下文了排最前。父片内容走 Redis 缓存是因为同一个热文档的父片会被反复回溯别每次都回 MySQL 捞 LONGTEXT。同时这一步还会把命中的文档按 documentId 去重、取最高分写进ReferenceContext——这就是最后引用来源的数据底座。重排序与内容注入聚合环节用 LangChain4j 现成的ReRankingContentAggregator底下垫的是OnnxScoringModelHolder加载的bge-reranker-v2-m3 量化版resources/bge/里的 onnx tokenizer启动时解到临时目录maxTokens 8192。cross-encoder 把 query 和每个候选拼在一起打分比向量那种各算各的精确得多。一个参数选择说下理由重排minScore我配的是0.0也就是只重排、不按重排分过滤。过滤阈值是特别依赖场景的超参我不想让一个我调不准的数字悄悄杀掉本可以用的内容。要不要用交给模型自己判断。最后注入环节IntentContentInjector干的事按 ThreadLocal 里的意图加载对应模板文件把检索内容格式化成【内容 1】…“分节一条都没有就填”未检索到相关内容替换{{contents}}和{{userMessage}}返回一个新的UserMessageStringpromptTemplateloadPromptTemplate(intent);// classpath:/prompt/pre_sales.txtStringfinalPromptpromptTemplate.replace(CONTENTS_PLACEHOLDER,formattedContents).replace(USER_MESSAGE_PLACEHOLDER,userMessageText);returnUserMessage.from(finalPrompt);每个意图一个 prompt 文件售前的讲推荐要带克重成分报价区间、售后的讲先安抚再给流程、价格的讲只报库里的数不许编——这些我放在 resources/prompt/ 下慢慢调改模板不用动代码。模板缓存进ConcurrentHashMap但有个刻意的设计加载失败时返回内置默认模板且不写入缓存——不然某次瞬时 IO 故障就把一个兜底模板永久缓存住了。流式生成、SSE 事件协议与记忆装配到KnowEngineChatAiService里跑的模型是qwen-plus流式版超时 120s返回FluxString。主服务订阅它每个 token 经SseEmitter推给前端。这里我把 SSE 定义成了一个小小的协议一共四类帧普通data:帧 —— 答案 token前端直接追加渲染[RAG_STATUS]{step,totalSteps,title,...}—— 管道进度改写/路由/检索/生成四步前端渲染成步骤条缓解等待焦虑[RAG_REFERENCES]{json}—— 答案讲完先推这个引用文档列表去重、最高分、降序前端进侧栏event: done—— 收尾元数据messageId、用的模型、token 数RagStatusContext的原理也是 ThreadLocal主流程把发射函数设进去管道深处每步emitStatus(...)就能直接把进度打到这个请求的 SSE 通道上。弹性上最后强调一次那个流式的特殊处理ResilientStreamingChatModel只熔断、绝不重试。同步调用失败了重试没事流式都吐了一半 token 了你重试用户就看到半截话重复播一遍。熔断上报也是手动tryAcquirePermission 在 onComplete/onError 时拿整段流的实际耗时去记账AtomicBoolean 保证只记一次因为首 token 秒回但中途卡死这种情况按调用发起时间算根本不准。记忆用DbBackedChatMemory窗口 20 条messages()直接倒序查rag_chat_message再反转add()是空的——持久化统一由主服务落库负责记忆的写路径只有一条就不会出现两边状态对不齐。读频繁就垫了个 Redis 60s 的ChatMemoryCache新消息落库后主动 evict。小结检索生成这条链路我总结成三句话问的准意图识别定路线查询改写补指代都是在花小钱换检索质量。查得全又掐得细knn BM25 双路 RRF 融合、bge 精排、子片命中父片回填、权限过滤超量取回先滤后截每一层都在修上一层的盲区。塌不了每个组件失败都有明确的降级去向——改写失败用原查询、识别失败当 GENERAL、Neo4j 挂了走 ES、BM25 挂了走纯向量、检索超时 5 秒切空结果。管道保证永远有输出只是输出的知识浓度会波动。而且你会发现 LangChain4j 的插槽式抽象在里面起了很大作用Transformer、Router、Retriever、Aggregator、Injector我五个实现全是往标准接口里填自己的逻辑想换哪个拔哪个。这就是我第一篇结尾说的从好用往可扩展走的那一步。至此三条主线里的两条主线入库、检索生成都讲完了。最后一篇番外我打算聊聊这个项目的可观测和限权rag 指标怎么埋、Prometheus 看什么、以及 Sa-Token 三级用户 accessibleBy 下沉 ES 这一整套权限是怎么串起来的。感兴趣关注不迷路评论区见。