Spring AI 模块化 RAG 实战:从手动检索到 Advisor 管道的演进之路
引言IntelliRAG 是一个基于 Spring AI Alibaba 构建的多租户 RAG 知识库平台。在最近的重构中,我们将整个 RAG 问答链路从手工拼接的模式迁移到了 Spring AI 的 Advisor 架构,并在此基础上实现了 ES 混合检索、Redis 对话记忆和查询重写。本文将复盘整个过程,分享架构设计和踩坑心得。 背景:重构前的架构痛点重构前,RagChatServiceImpl 的核心逻辑长这样: 1234567891011121314// 手动调用 VectorStore 检索List<Document> documents = vectorStore.similaritySearch( SearchRequest.builder().query(userQuery).topK(5).build());// 手动拼接 PromptString context = documents.stream() .map(Document::getText) .collect(Collectors.joining("\n\n&...
事件驱动的 ASR → LLM → ES 回写:设计拆解与实践
引言最近在学习一个语音分析系统的架构设计,整个流程是:录音转成文字,然后用大模型做智能分析,最后把结果存到 Elasticsearch 里供检索。流程看起来简单,但真正落地时会遇到不少问题: ASR 识别结果和业务规则耦合在一起,改个过滤逻辑就要动识别器代码 LLM 调用散落在各处,模型切换时改得头昏脑涨 ES 写入失败后没有补偿机制,数据就丢了 各个阶段的状态流转混乱,出问题时很难排查 这篇文章记录一下这个系统是怎么通过事件驱动的方式解决这些问题的,重点分析设计思想和实现模式。 核心设计思路整个系统可以抽象成三个核心阶段:语音识别 → 智能分析 → 索引回写。关键是要让每个阶段职责清晰、互不干扰,同时保证数据最终一致性。 系统采用了事件驱动架构,把每个阶段的完成作为事件发布出去,由专门的处理器负责后续工作。这样做的好处是: 各阶段可以独立演进,识别器换了不影响分析逻辑 某个阶段失败不会阻塞其他阶段 每个阶段都有明确的输入输出,便于测试和排查 失败了可以重试,不会丢数据 模块职责划分先梳理一下各个模块的职责,这样后面看代码会更清晰。 ASR Gateway:负责把音频提交...
从 ASR 结果到可用输入:清洗、归一化与扩展点设计
本文是 事件驱动的 ASR → LLM → ES 回写:设计拆解与实践 系列的第一篇,重点讲解 ASR 结果的清洗与归一化设计。 学习一个语音处理系统时,最容易被忽略的一步不是”如何识别”,而是”识别之后怎么继续处理”。 如果把 ASR 理解成”把声音翻译成文字”,那后处理就是”把翻译稿整理成可以继续分析的材料”。真正决定后续分析质量的,往往不是识别引擎本身,而是这一段整理逻辑。 这篇文章重点回答三个问题: 为什么原始 ASR 结果不能直接往下走 应该怎样对结果做清洗和归一化 为什么扩展点是处理这类问题的合适方式 为什么不能把原始结果直接交给后续流程第一次接触 ASR 时,很容易产生一个直觉:只要拿到转写文本,后面的分析应该就能直接做了。实际上,工程里很少能这么简单。 原始识别结果通常会带着这些问题: 分句粒度不稳定,有时太碎,有时太长 语义连续但时间上被拆开,阅读起来不自然 噪声词、提示音、播报词混在正文中 不同场景对”保留/删除”的规则不一样 后续分析可能需要的元信息还没有整理好 所以更合理的做法是把流程拆成两段: 识别器负责把音频转成结构化文本 ...
分析结果如何可靠回写 ES:状态流转与失败补偿
本文是 事件驱动的 ASR → LLM → ES 回写:设计拆解与实践 系列的第三篇,重点讲解 ES 回写的状态流转与失败补偿设计。 当分析链路已经跑起来之后,最后一个关键问题就是:结果怎么可靠落到 ES 上。 这一步看起来像”写一次索引”这么简单,但真正工程里,它往往决定了整个链路是否稳定。因为只要 ES 写入失败、顺序乱掉或者状态不同步,整个分析链路就会出现”看起来完成了,实际上没完成”的问题。 为什么 ES 回写要单独设计很多人会把 ES 当成一个普通存储,但在分析系统里,它更像一个”可检索的结果视图”。 这意味着: 前面的分析阶段要不断把结果回填进去 某些字段会在不同阶段被多次更新 状态字段需要和业务状态保持一致 一旦写失败,不能简单丢掉 所以 ES 回写必须独立设计,而不能散在各个分析步骤里。 先改状态,再写结果一个比较稳妥的方式是: 先把状态改成”正在分析” 再逐步写入各阶段结果 全部完成后再进入结束态 这样做的价值是让外部能一眼看懂当前进度。 比如: 待分析 正在分析 分析完成 分析失败 待重试 这些状态并不是装饰,而是整个链路的”进度条”。 统...
把 LLM 放进分析链路:阶段编排与事件触发
本文是 事件驱动的 ASR → LLM → ES 回写:设计拆解与实践 系列的第二篇,重点讲解 LLM 分析的阶段编排与事件驱动设计。 当一个系统里需要同时完成摘要、场景识别、质检分析和字段提取时,最容易出问题的地方不是模型本身,而是流程怎么组织。 这篇文章从学习者视角拆一个核心问题:为什么要把 LLM 分析拆成多个阶段,并且用事件把每一段结果串起来。 先把”一个大问题”拆成多个小问题如果把所有分析都塞进一次调用里,问题会很多: 输出很难稳定 失败后难以重试局部步骤 不同分析任务之间会互相干扰 后续回写也不好分字段处理 更好的做法,是把任务拆开: 先做摘要 再做场景识别 再做质检判断 最后做字段提取 每个阶段都只回答一个问题,这样 LLM 的输出会更清晰,后面的结果也更容易落地。 为什么要有阶段编排阶段编排的本质,是让复杂流程变得可控。 如果没有编排,系统会变成这样: 入口调用一大堆逻辑 中间结果乱放 哪一步失败了很难定位 后续处理分不清先后关系 有了编排之后,流程就会变得更像一条工厂流水线: 准备输入 执行第一阶段 保存第一阶段结果 触发下一阶段 重复直到...
极客工作流:使用 GitHub Actions 将 Obsidian 笔记无缝同步至 Hexo 博客
一直以来,我的个人知识管理和博客输出是割裂的。我在私密的纯 Markdown 仓库(配合 Obsidian)里记录日常和技术踩坑,但如果想发布一篇博客,就需要手动把文档复制到 Hexo 仓库的 source/_posts 目录下,再走一遍部署流程。 为了贯彻“能自动化就绝不手敲”的开发原则,今天折腾了一套优雅的解决方案:利用 GitHub Actions 实现从私有笔记仓库到公开 Hexo 博客的单向全自动同步。 这篇文章,就是在这套全新工作流下测试发布的第一篇文章!🎉 1. 架构思路:单向镜像同步目前的仓库状态: 私有笔记仓库 (personal-note):存放我所有的 Obsidian 笔记、本地配置以及草稿。这是唯一的真相之源。 公开博客仓库 (MyBlog):存放 Hexo 框架源代码,通过 Vercel 自动部署。 目标:在 personal-note 中划定一个公开目录(例如 BlogPublic/),每次在本地更新这个目录里的 Markdown 文件并 Push 后,GitHub Actions 自动提取这些文件,精准推送到 MyBlog 的 source/...
排查 MyBatis 分页查询出现完全重复记录的问题
背景描述在测试用户列表的分页查询接口时,发现返回的 records 列表中存在两条完全一模一样的数据。 返回的 JSON 结构简化如下: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556{ "data": { "records": [ { "userId": "10086...", "userName": "UserA", "phone": "138****0000", "roleId": "65498...", "statusCd": "1000", ......
记一次线上 ElasticSearch多条件查询失效问题的排查过程
背景说明在近期的业务维护中,遇到了一个典型的多环境查询表现不一致的问题。 问题现象: 在生产环境的某个业务分页查询页面中,仅使用“时间范围”条件进行查询时,能够正常返回数据;但当附加特定的过滤条件(如:设备名称、员工姓名、对话内容)时,查询结果直接为空。而在研发和测试环境中,相同的代码段和查询条件,均能正常过滤并返回正确数据。研发与生产环境相互隔离。 核心架构背景: 该业务模块的底层数据存储采用了 MySQL + Elasticsearch 的双存储架构。系统设计了一套动态路由机制,根据传入的查询条件决定请求是走 DB 还是走 ES。 结论前置该问题是由 “代码层面的查询路由策略” 与 “生产/研发环境 ES 索引 Mapping 不一致” 共同叠加导致的一个隐蔽 Bug。 具体原因如下: 路由触发条件: 代码中配置了模糊查询拦截开关。仅通过日期查询时,请求路由至 MySQL,正常返回;附加设备名称等模糊查询字段后,请求切换至 ES。 底层 Mapping 差异: 在封装 ES 查询条件时,代码默认追加了系统来源标识(sourceSystem)作为 term 精...
深度解析:Java 与 Go 字符串处理的本质差异
引言在开发编译器词法分析器的过程中,我遇到了一个经典的字符串处理问题:为什么 Go 语言需要先将 string 转换为 []rune 才能正确处理字符,而 Java 却可以直接通过索引访问? 这个问题背后涉及两种语言在字符串设计哲学、内存管理和编码方式上的根本差异。本文将深入探讨这些技术细节,帮助开发者更好地理解和使用这两种语言。 问题的起源Go 语言的词法分析场景在编写编译器词法分析器时,我们需要逐个字符地扫描源代码: 1234567891011121314// Go 代码示例func analyze(code string) { runes := []rune(code) // 为什么要转换? n := len(runes) for i := 0; i < n; { c := runes[i] // 判断字符类型:字母、数字、运算符... if unicode.IsLetter(c) { // 处理标识符或关键字 } ...
基于Mapper-Framework开发Camera Mapper
1. 工程初始化使用官方脚手架生成标准目录结构。 1.1 克隆与生成在 WSL 环境中执行以下步骤: 123456789# 1. 克隆官方 Mapper 框架git clone https://github.com/kubeedge/mapper-framework.gitcd mapper-framework# 2. 切换到匹配的分支 (推荐与 KubeEdge 版本一致,这里使用 release-1.20)git checkout release-1.20 # 3. 运行交互式生成器make generate 1.2 生成选项配置在交互式命令行中输入以下配置: Mapper Name: camera-mapper Build Method: nostream 为什么要选 nostream? 虽然业务涉及视频流,但在 KubeEdge 定义中,stream 模式用于通过 MQTT/HTTP 数据通道上传高频传感器数据。视频流数据量过大,会阻塞 EdgeCore。 架构策略: 控制面(分辨率、状态):走 KubeEdge 标准通道 (nostream)。 ...