From 84eea7564fa67b2869d142ea5a77908e9ba93100 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Thu, 10 Sep 2026 17:29:31 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E8=87=AA=E9=80=A0=E8=BD=AE?= =?UTF-8?q?=E5=AD=90=E6=94=B9=E7=94=A8=20Spring=20AI=20=E6=A0=87=E5=87=86?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E3=80=81=E6=B8=85=E7=90=86=E6=AD=BB=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=B9=B6=E4=BF=AE=E5=A4=8D=E6=97=A2=E6=9C=89=E7=BC=BA?= =?UTF-8?q?=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 组件替换(改用 1.1.x 标准组件) - 文档提取:删除自写 TikaDocumentReader(内部直接 new org.apache.tika.Tika()), 改用官方 org.springframework.ai.reader.tika.TikaDocumentReader。 pom 早已引入 spring-ai-tika-document-reader 却从未使用其类,属典型「引了标准依赖却手写实现」 - 意图识别:IntentRouter 的手写正则解析(含去 BOM/零宽字符/全角空格等约 60 行防御逻辑) 改用标准 BeanOutputConverter,保留原有降级语义(空输入/解析失败 → RAG) - 推荐问题:SuggestionGenerator 的哨兵字符串 + 代码块剥离 + 按行降级解析改用 ChatClient.entity(ParameterizedTypeReference>),整体删除 SuggestionResponseParser - 分块:删除 MyTokenTextSplitter 薄壳,新增 OverlapTokenTextSplitter 分块器 overlap 缺陷修复(本次验证中发现并修复) - 旧实现把 knowledge.chunk.overlap 传进了 TokenTextSplitter 第 2 个形参 minChunkSizeChars 位, overlap 从未生效(Spring AI 的 TokenTextSplitter 根本没有 overlap 形参) - 新实现继承标准 TextSplitter,复刻 TokenTextSplitter 全部切分语义,仅把前进步长改为 chunkSize - overlap,使重叠真正生效 - 验证中发现:标点截断会缩短本块消耗的 token 数,与 overlap 叠加后可把前进步长压到 1 个 token,分块数膨胀 10 倍(实测 349 块 vs 修复后 44 块)。故仅当截断后仍能前进 至少 (chunkSize - overlap) / 2 个 token 时才采用该截断,否则宁可切断句子 - overlap=0 时与标准 TokenTextSplitter 逐块一致(已对比验证) - ⚠️ 存量文档需重新分块 + 重新向量化(POST /document/batch/reprocess),否则新旧向量口径混杂 其他既有缺陷修复 - RerankerService:原用 new RestTemplate() 且无任何超时,慢 provider 会把检索线程拖到 TCP 超时;改用 RestClient + JdkClientHttpRequestFactory 显式设置 connect/read 超时(各 3s) - McpServerConfigController:MCP Server 增删改/启停/全量刷新后未清 AssistantApp 的 ChatClient 缓存,导致继续使用旧工具集;现补调 clearCache() 死代码清理(均已 grep 确认零引用) - 删除 FileBasedChatMemory、ReReadingAdvisor(零装配且 before() 逻辑为 no-op)、 SseEventBuilder(零引用)、SuggestionResponseParser - McpToolCallback 删除 EVENTS ThreadLocal 与 drainEvents()/resetEvents()(零调用), 保留在用的 MCP_EVENTS_KEY/MCP_ROUNDS_KEY ToolContext 机制 文档 - 同步更新 CLAUDE.md / README.md / DEPLOY.md 的版本号、组件说明与架构描述 - 修正 CLAUDE.md 中与代码不符的既有描述:主启动类并未排除 PgVectorStoreAutoConfiguration (项目用的是非 starter 坐标,classpath 上本就没有该自动配置);分类过滤实现已完成, 原「Spring AI filter 支持有限」的 TODO 已不成立 --- CLAUDE.md | 33 +- DEPLOY.md | 2 +- README.md | 31 +- .../supportbot/advisor/ReReadingAdvisor.java | 43 --- .../com/wok/supportbot/app/ChatResult.java | 5 +- .../supportbot/app/SuggestionGenerator.java | 31 +- .../app/SuggestionResponseParser.java | 294 ------------------ .../controller/McpServerConfigController.java | 14 + .../document/extract/TikaDocumentReader.java | 73 ----- .../transform/MyTokenTextSplitter.java | 67 ---- .../transform/OverlapTokenTextSplitter.java | 252 +++++++++++++++ .../wok/supportbot/mcp/McpToolCallback.java | 36 +-- .../wok/supportbot/mcp/SseEventBuilder.java | 73 ----- .../wok/supportbot/rag/RerankerService.java | 50 ++- .../service/DocumentProcessingService.java | 34 +- .../supportbot/service/DocumentService.java | 13 +- .../wok/supportbot/service/IntentRouter.java | 100 ++---- 17 files changed, 436 insertions(+), 715 deletions(-) delete mode 100644 src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java delete mode 100644 src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java delete mode 100644 src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java delete mode 100644 src/main/java/com/wok/supportbot/document/transform/MyTokenTextSplitter.java create mode 100644 src/main/java/com/wok/supportbot/document/transform/OverlapTokenTextSplitter.java delete mode 100644 src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java diff --git a/CLAUDE.md b/CLAUDE.md index af43d11..87558f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,8 +33,8 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支 ## 核心架构决策 -### 主启动类排除了 PgVectorStoreAutoConfiguration -`SupportBotApplication.java` 中 `@SpringBootApplication(exclude = PgVectorStoreAutoConfiguration.class)`,因为项目在 `PgVectorStoreConfig` 中手动配置 PgVectorStore Bean(标记 `@Primary`),不使用自动配置。另有一个 `InMemoryVectorStoreConfig` 作为开发备选。 +### 手动配置 PgVectorStore(未引入自动配置) +`PgVectorStoreConfig` 手动配置 PgVectorStore Bean(标记 `@Primary`),`SupportBotApplication` 是裸 `@SpringBootApplication`、无任何 exclude —— 因为项目依赖的是**非 starter** 的 `spring-ai-pgvector-store`(只有实现类,不含 `spring-ai-autoconfigure-vector-store-pgvector`),classpath 上本就没有 `PgVectorStoreAutoConfiguration`。另有一个 `InMemoryVectorStoreConfig` 作为开发备选。 ### Spring AI 集成模式 - **ChatClient Builder**: 所有对话通过 `ChatClient.builder(chatModelFactory.getChatModel("CHAT"))` 构建,ChatModel 由 `ChatModelFactory` 按 DB 活跃配置动态创建 @@ -43,7 +43,7 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支 - **SSE 流式**: 仅保留 `Flux` 形态;废弃的 `Flux` 和 `SseEmitter` 已移除 ### ChatMemory 持久化 -当前使用 `DatabaseChatMemory`(PostgreSQL 持久化),`FileBasedChatMemory`(Kryo 序列化)已注释掉。 +当前使用 `DatabaseChatMemory`(PostgreSQL 持久化),无文件型 ChatMemory(早期的 `FileBasedChatMemory` 已删除,Kryo 依赖一并移除)。 ### 统一对话管道(重构后) 对话管道由 `ChatPipeline`(编排层)+ `RagPipeline`(RAG 检索层)+ `AssistantApp`(执行层)组成: @@ -67,7 +67,7 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支 - **Open API**: `OpenApiController` 已接入 `ChatPipeline`,补齐角色/RAG/FAQ/MCP/分类隔离能力 ### 文档处理管道 -`DocumentService.uploadDocument()` 统一流程:文档提取 → `MyTokenTextSplitter` 分块 → 为每块写 metadata → 按批向量化(默认 50 块/批,配置项 `knowledge.vector.batch-size`)`pgVectorVectorStore.add(batch)` 入库。每个分块的 metadata 注入 `documentId`、`chunkIndex`、`sourceName`、`title`、`categoryId`、`enabled` 关联 `knowledge_document` 表。 +`DocumentService.uploadDocument()` 统一流程:文档提取(官方 `org.springframework.ai.reader.tika.TikaDocumentReader` / `MarkdownDocumentReader` / `JsonReader`)→ `OverlapTokenTextSplitter` 分块 → 为每块写 metadata → 按批向量化(默认 50 块/批,配置项 `knowledge.vector.batch-size`)`pgVectorVectorStore.add(batch)` 入库。每个分块的 metadata 注入 `documentId`、`chunkIndex`、`sourceName`、`title`、`categoryId`、`enabled` 关联 `knowledge_document` 表。 **向量化加固**(`DocumentProcessingService`):逐批 try-catch 隔离,失败批只记录缺失区间后继续,已入库块保留;`chunk_count` 记实际入库块数,`error_message` 聚合"已入库 x/y 块 + 缺失区间 + 原因";文档级失败自动整体重试至多 2 次(仅瞬时/限流/超时类错误,4xx 不空转),重试前先清残留向量再重建。**无逐块 AI 关键词提取环节**(`MyKeywordEnricher` 已移除,其产出 `excerpt_keywords` 全库无检索消费点)。 @@ -81,6 +81,10 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支 - PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象 `'{}'`,非数组 `'[]'`) - **向量维度**: 由 `knowledge.vector.dimension` 配置(默认 1024)。修改后需执行 `DROP TABLE IF EXISTS vector_store CASCADE` 重建向量表,并重新上传知识库文档。距离类型: COSINE_DISTANCE,索引: HNSW - **分块配置**: `knowledge.chunk.*` 配置项(`ChunkConfig`),默认 chunkSize=200, overlap=100, minChunkSizeChars=10, maxNumChunks=5000, keepSeparator=true +- **分块器 `OverlapTokenTextSplitter`**: Spring AI 的 `TokenTextSplitter` **不支持 overlap**(构造器与 Builder 均无该形参,社区 PR #4054 不向 1.x 回迁)。项目继承标准 `TextSplitter` 基类自研了 `OverlapTokenTextSplitter`(`document/transform/`),复刻 `TokenTextSplitter` 全部切分语义,仅把前进步长由 `chunkSize` 改为 `chunkSize - overlap`,并用 jtokkit(CL100K_BASE,与上游同库)做 token 编码。 + - **前进步长必须有下限(`minAdvance()`)**: 标点截断会缩短本块消耗的 token 数,而步长 = `消耗量 - overlap`;若截断点靠前,步长会被压到 1 个 token,分块数成倍膨胀(实测 `chunkSize=60/overlap=30` 时 349 块 vs 修复后 44 块)。因此**仅当截断后仍能前进至少 `(chunkSize - overlap) / 2` 个 token 时才采用该截断**,否则宁可切断句子。`overlap=0` 时该下限取 1,与标准 `TokenTextSplitter` 行为**逐块一致**(已有对比验证)。 + - **历史缺陷已修复**: 旧 `MyTokenTextSplitter` 因形参错位,把 `overlap` 传进了 `minChunkSizeChars` 位,导致 `knowledge.chunk.overlap` 从未生效。修复后重叠真正生效,**分块边界与块数会变化**,存量文档需重新分块+向量化(`POST /document/batch/reprocess`)。 + - `minChunkLengthToEmbed` 固定为 10(`DocumentProcessingService.MIN_CHUNK_LENGTH_TO_EMBED`,ChunkConfig 无对应配置项) - **上传校验**: `ALLOWED_EXTENSIONS` 白名单 + 50MB 大小限制(`spring.servlet.multipart` 配置),前后端双重校验 - **文档去重**: `KnowledgeDocument.contentHash` 字段(SHA-256),上传时自动计算并查重 - **数据库自动初始化**: `DatabaseInitConfig` 在启动时检查并创建 `knowledge_category`/`knowledge_document`/`ai_model_config` 等表,对已存在的 `knowledge_document` 表会自动补加 `content_hash` 列。注意 `knowledge-base.sql` 脚本为早期版本,缺少此列,实际以 `DatabaseInitConfig` 为准 @@ -94,14 +98,19 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支 - **ChatModel 运行时切换**: 通过 `ChatModelFactory` 按 DB 活跃配置动态创建/缓存 ChatModel(包括 DashScope,不再复用 yml 自动配置的 Bean),配置变更时立即生效(无需重启)。**OpenAI 兼容路径使用自定义 `completionsPath`**(与 EmbeddingModelFactory 的 embeddingsPath 对应),baseUrl 已含版本段的厂商(moonshot `/v1`、volcengine `/api/v3`、zhipu `/api/paas/v4`)设为 `/chat/completions`,其余使用默认 `/v1/chat/completions` - **EmbeddingModel 运行时切换**: 通过 `EmbeddingModelFactory` + `DynamicEmbeddingModel` 代理,按 DB 活跃配置动态创建/缓存 EmbeddingModel,`PgVectorStoreConfig` 和 `InMemoryVectorStoreConfig` 注入 `DynamicEmbeddingModel`,向量化模型配置变更后无需重启即可生效 - **多提供商支持**: DashScope(通义千问)+ OpenAI 兼容提供商(DeepSeek / 豆包 / Kimi / 智谱 / OpenAI),ChatModel 和 EmbeddingModel 均通过对应 API 手动构建 -- **缓存刷新**: 配置增删改激活时 Controller 自动调用 `ChatModelFactory.clearCache()` + `EmbeddingModelFactory.clearCache()` + `AssistantApp.clearCache()` +- **缓存刷新**: 模型配置增删改激活时 Controller 自动调用 `ChatModelFactory.clearCache()` + `EmbeddingModelFactory.clearCache()` + `AssistantApp.clearCache()`;MCP Server 增删改/启停/全量刷新时 `McpServerConfigController` 亦会调用 `AssistantApp.clearCache()`(避免继续使用旧的 MCP 工具集) - **动态模型列表获取**: `ModelListFetcher` 通过调用各提供商的 `/v1/models` 兼容端点(DashScope 用 `/compatible-mode/v1/models`),动态获取可用模型列表。前端填入 API Key + API 地址后,点击「获取模型」即可自动填充模型名称下拉列表(`` 支持搜索选择 + 自定义输入) ### 依赖版本 -- Spring AI BOM: `1.0.1`,统一管理所有 `org.springframework.ai` 依赖版本 -- `spring-ai-alibaba-starter-dashscope`: `1.0.0.4`(新版 starter,替代老版 `spring-ai-alibaba-starter` M6.1) -- `spring-ai-openai`: BOM 管理(OpenAI 兼容提供商支持) +- Spring Boot: `3.5.8`(Spring AI Alibaba 1.1.2.2 官方基线) +- Spring AI BOM: `1.1.2`(properties 中的 `spring-ai.version`),统一管理所有 `org.springframework.ai` 依赖版本 +- `spring-ai-alibaba-starter-dashscope`: `1.1.2.2`。**必须显式写版本号** —— `spring-ai-alibaba-bom` 并不管理该模块(它只管理 agentscope / graph / studio 等 8 个模块),因此项目**不导入** SAA BOM +- `spring-ai-openai`: BOM 管理(OpenAI 兼容提供商支持)。**刻意使用非 starter 坐标**:项目手动构建 ChatModel/EmbeddingModel,用 starter 会额外引入 `-model-openai` / `-chat-client` / `-chat-memory` 三个自动配置,可能生成与手写工厂、`DatabaseChatMemory` 冲突的 Bean。`spring-ai-pgvector-store` 同理 - `spring-ai-alibaba-starter` (M6.1) 已移除,不再使用 +- **MCP SDK 必须锁 0.18.3**(`pom.xml` 中排除传递依赖 + 显式声明):Spring AI 1.1.2 的 `spring-ai-mcp` 仍锁 MCP SDK **0.17.0**,而 0.17.0 的 JsonMapper 包名是 `io.modelcontextprotocol.json.jackson`(无 `jackson2`),项目 `McpClientManager` 用的是 0.18.0 起才有的 `...json.jackson2.JacksonMcpJsonMapper`。`spring-ai-mcp` 只引用 `McpSyncClient/McpAsyncClient/McpClient/McpTransportContext/McpSchema/Assert`,这些类 0.18.3 均存在,故覆盖安全 +- `com.github.victools:jsonschema-generator`: 不再显式声明,由 `spring-ai-model:1.1.2` 传递引入(4.38.0,结构化输出 `BeanOutputConverter` 依赖它) +- `com.knuddels:jtokkit:1.1.0`: 显式声明,`OverlapTokenTextSplitter` 直接使用 +- `com.esotericsoftware:kryo` 与 `org.springframework.security:spring-security-oauth2-client` 均已移除(唯一使用者/唯一用途已消失;后者曾是 Spring AI 1.0.x `ToolCallingAutoConfiguration` 的 ClassNotFound workaround,1.1.2 已无该耦合) ### EmbeddingModel 架构 - **EmbeddingConfigFixer**:`ApplicationListener`,启动时检查 EMBEDDING 配置合理性、**校验 EmbeddingModel 实际维度与配置维度是否一致**,不一致时 WARN 告警并给出修复步骤。**不再强制修正非 DashScope 配置**,尊重用户在 DB 中配置的提供商和模型 @@ -316,7 +325,7 @@ catch (e) { toast('操作失败', 'error') } - **会话导出**: `ConversationService.exportConversation()` 导出的 TXT 中包含反馈信息 ### 意图识别 + FAQ 精准匹配(P0-003) -- **IntentRouter**: LLM 单次调用做意图分类(FAQ/RAG/CHITCHAT),解析失败降级为 RAG +- **IntentRouter**: LLM 单次调用做意图分类(FAQ/RAG/CHITCHAT);结构化输出由标准组件 `BeanOutputConverter` 生成 JSON Schema 指令并反序列化结果,解析失败/结果非法降级为 RAG - **FaqMatchEngine**: 三级匹配策略 — 精确匹配 → 关键词匹配 → 向量语义匹配(阈值 `knowledge.faq.semantic-threshold`,默认 0.85) - **FAQ 向量化**: 复用现有 `DynamicEmbeddingModel`,向量存入 `faq_embedding` 表,新增/修改 FAQ 时异步计算 - **similar_questions 字段**: 使用 String 类型存储 JSON 数组字符串(PostgresJsonTypeHandler 期望对象格式,故不用 typeHandler) @@ -326,7 +335,7 @@ catch (e) { toast('操作失败', 'error') } - **SearchMode**: 枚举 VECTOR(默认)/ KEYWORD / HYBRID,向后兼容 - **HybridSearchService**: 多模式检索核心,KEYWORD 使用 PostgreSQL `tsvector` 全文检索,HYBRID 使用双路检索 + RRF 融合 - **RrfFusion**: RRF 融合算法 `score = Σ 1/(k + rank_i)`,k=60 -- **RerankerService**: 支持 DashScope + OpenAI 兼容提供商,通过 `ai_model_config` 表 RERANK 类型配置,超时 3s 自动 fallback +- **RerankerService**: 支持 DashScope + OpenAI 兼容提供商,通过 `ai_model_config` 表 RERANK 类型配置;HTTP 由 `RestClient` + `JdkClientHttpRequestFactory` 显式设置 connect/read 超时(各 3s,原 `RestTemplate` 无任何超时),超时/异常自动 fallback 到 RRF 原始排序 - **vector_store 全文检索**: 新增 `content_tsvector` 列 + GIN 索引 + PostgreSQL 触发器自动维护 - **前端**: `DocSearch.js` 增加检索模式下拉选择(向量/关键词/混合),结果标注来源模式 @@ -342,7 +351,7 @@ catch (e) { toast('操作失败', 'error') } ## 已知 TODO - `DocumentService.updateDocumentMetadata()`: Spring AI 无直接更新 vector_store metadata 的 API,向量元数据同步留后续 -- `DocumentService.searchDocuments()`: Spring AI 1.0.1 的 filter 支持有限,分类过滤暂未实现 +- `DocumentService.searchDocuments()`: **分类过滤已实现**(`FilterExpressionBuilder` 组合 `enabled` + `categoryId` 过滤表达式,向量检索异常时回退到本地 metadata 过滤)—— 原「Spring AI filter 支持有限」的 TODO 已不成立 - `CompressionQueryRewriter`: 当前传入空历史列表 -- MyBatis Plus 3.5.12 的 `mybatis-plus-spring-boot3-starter` 不含 `PaginationInnerInterceptor`,分页通过 SQL `LIMIT/OFFSET` 手动实现 +- MyBatis Plus `mybatis-plus-spring-boot3-starter` 不含 `PaginationInnerInterceptor`,分页通过 SQL `LIMIT/OFFSET` 手动实现 - `PgVectorStoreConfig.dimensions(1024)` 硬编码了向量维度,切换非 1024 维的 Embedding 模型时需修改并重建 vector_store 表 → **已修复:维度由 `knowledge.vector.dimension` 配置,启动时自动检测不匹配并告警** diff --git a/DEPLOY.md b/DEPLOY.md index 6294164..0e07ea8 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -749,4 +749,4 @@ sudo DB_PASS=your_strong_password /opt/support-bot/deploy.sh --- -*本教程基于项目当前代码状态(Spring Boot 3.4.4 / Java 17 / Spring AI 1.0.1 / PGVector)编写。* +*本教程基于项目当前代码状态(Spring Boot 3.5.8 / Java 17 / Spring AI 1.1.2 / Spring AI Alibaba 1.1.2.2 / PGVector)编写。* diff --git a/README.md b/README.md index dc4e2dd..c475ef0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 🤖 基于 Spring AI 和阿里云通义千问的智能客服机器人 > 🧠 支持 RAG 知识库检索 · 多种对话模式 · 结构化数据提取 · 知识库管理 -> 🧱 技术栈:Java 17 · Spring Boot 3.4.4 · Spring AI Alibaba · PGVector +> 🧱 技术栈:Java 17 · Spring Boot 3.5.8 · Spring AI Alibaba · PGVector ## 📌 项目简介 @@ -29,16 +29,15 @@ | 技术分类 | 技术组件 | 版本 | 用途说明 | |---------|---------|------|---------| -| **后端框架** | Spring Boot | 3.4.4 | 主框架,提供依赖注入和自动配置 | -| **AI框架** | Spring AI Alibaba | 1.0.0-M6.1 | AI集成框架,简化大模型调用 | +| **后端框架** | Spring Boot | 3.5.8 | 主框架,提供依赖注入和自动配置 | +| **AI框架** | Spring AI Alibaba | 1.1.2.2 | AI集成框架,简化大模型调用(Spring AI BOM 1.1.2) | | **大语言模型** | 阿里云通义千问 | qwen-turbo | 对话生成和文本理解 | | **Embedding模型** | 阿里云DashScope | text-embedding-v2 | 文本向量化(1024维) | | **数据库** | PostgreSQL + PGVector | 12+ | 关系数据存储 + 向量存储 | | **ORM框架** | MyBatis Plus | 3.5.12 | 数据库操作和对象映射 | -| **API文档** | Knife4j | 4.4.0 | Swagger UI增强版 | -| **工具库** | Hutool | 5.8.37 | 常用工具类集合 | -| **序列化** | Kryo | 5.6.2 | 高性能序列化框架 | -| **文档解析** | Apache Tika | 1.0.0 | 多格式文档内容提取 | +| **API文档** | Knife4j | 4.5.0 | Swagger UI增强版 | +| **工具库** | Hutool | 5.8.41 | 常用工具类集合 | +| **文档解析** | Apache Tika | 经 spring-ai-tika-document-reader | 多格式文档内容提取(使用 Spring AI 官方 `TikaDocumentReader`) | ## 🗃️ 数据库设计 @@ -162,12 +161,11 @@ src/main/java/com/wok/supportbot/ ├── SupportBotApplication.java # 主启动类 ├── advisor/ # AI对话增强器 │ ├── MyLoggerAdvisor.java # 日志记录顾问 -│ └── ReReadingAdvisor.java # 重读机制顾问 +│ └── ContentSafetyAdvisor.java # 内容安全顾问 ├── app/ # 核心应用服务 │ └── AssistantApp.java # 智能客服应用 ├── chatmemory/ # 聊天记忆管理 -│ ├── DatabaseChatMemory.java # 数据库记忆存储 -│ └── FileBasedChatMemory.java # 文件记忆存储 +│ └── DatabaseChatMemory.java # 数据库记忆存储 ├── config/ # 系统配置 │ ├── CorsConfig.java # 跨域请求配置 │ ├── MybatisPlusConfig.java # MyBatis Plus 配置 @@ -182,13 +180,12 @@ src/main/java/com/wok/supportbot/ │ ├── KnowledgeDocumentMapper.java # 知识文档 Mapper │ └── KnowledgeCategoryMapper.java # 知识分类 Mapper ├── document/ # 文档处理管道 -│ ├── extract/ # 文档提取器 -│ │ ├── TikaDocumentReader.java # Tika 多格式解析 -│ │ ├── MarkdownDocumentLoader.java # Markdown 解析 -│ │ ├── JsonDocumentLoader.java # JSON 解析(3种模式) +│ ├── extract/ # 文档提取器(均基于 Spring AI 官方读取器) +│ │ ├── MarkdownDocumentLoader.java # Markdown 解析(MarkdownDocumentReader) +│ │ ├── JsonDocumentLoader.java # JSON 解析(JsonReader,3种模式) │ │ └── SimpleStringDocumentReader.java # 纯文本读取 │ └── transform/ # 文档转换器 -│ └── MyTokenTextSplitter.java # Token 分块器 +│ └── OverlapTokenTextSplitter.java # 带重叠的 Token 分块器(继承标准 TextSplitter) ├── entity/ # 数据实体类 │ ├── ChatMessage.java # 聊天消息实体 │ ├── KnowledgeDocument.java # 知识文档实体 @@ -229,9 +226,9 @@ src/main/resources/ ``` 上传文件/文本 ↓ -[文档提取器] Tika / Markdown / JSON / 纯文本 +[文档提取器] Tika(官方 TikaDocumentReader)/ Markdown / JSON / 纯文本 ↓ -[Token 分块] MyTokenTextSplitter (200 token / 100 overlap) +[Token 分块] OverlapTokenTextSplitter (chunkSize=200, overlap=100) ↓ [元数据标注] metadata.documentId / chunkIndex / sourceName / title / categoryId / enabled ↓ diff --git a/src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java b/src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java deleted file mode 100644 index 3bff56a..0000000 --- a/src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.wok.supportbot.advisor; - -import org.springframework.ai.chat.client.ChatClientRequest; -import org.springframework.ai.chat.client.ChatClientResponse; -import org.springframework.ai.chat.client.advisor.api.AdvisorChain; -import org.springframework.ai.chat.client.advisor.api.BaseAdvisor; - -import java.util.HashMap; -import java.util.Map; - -/** - * 自定义 Re2 Advisor(适配 Spring AI 1.0.1 新 Advisor API) - * 可提高大型语言模型的推理能力 - */ -public class ReReadingAdvisor implements BaseAdvisor { - - @Override - public String getName() { - return this.getClass().getSimpleName(); - } - - @Override - public int getOrder() { - return 0; - } - - @Override - public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) { - // Re2 策略:将用户问题重复一次以增强推理 - // 通过 context 传递原始查询,在 prompt 中追加重复指令 - Map newContext = new HashMap<>(request.context()); - newContext.put("re2_enabled", true); - return ChatClientRequest.builder() - .prompt(request.prompt()) - .context(newContext) - .build(); - } - - @Override - public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) { - return response; - } -} diff --git a/src/main/java/com/wok/supportbot/app/ChatResult.java b/src/main/java/com/wok/supportbot/app/ChatResult.java index ab6a3e6..dc5e5ab 100644 --- a/src/main/java/com/wok/supportbot/app/ChatResult.java +++ b/src/main/java/com/wok/supportbot/app/ChatResult.java @@ -10,8 +10,9 @@ import java.util.List; * 除了回答文本,还携带本次触发的 MCP 工具调用事件,让同步对话也能像流式对话一样 * 展示工具调用过程(原仅流式 {@code appendMcpToolEvents} 追加事件,同步路径无事件)。 *

- * 新增 {@code suggestions} 字段:AI 推荐问题列表(suggest-message-list), - * 由 {@link SuggestionResponseParser} 从 LLM 输出中分离,非 LLM 路径(FAQ/熔断)为空列表。 + * 新增 {@code suggestions} 字段:AI 推荐问题列表(suggest-message-list)。 + * 主链路(AssistantApp / ChatPipeline)恒为空列表,推荐问题改由 {@link SuggestionGenerator} + * 在 AI 回复结束后按需异步生成,非 LLM 路径(FAQ/熔断)亦为空。 * * @param text AI 回答文本 * @param mcpEvents 本次触发的 MCP 工具调用事件,无调用时为空列表 diff --git a/src/main/java/com/wok/supportbot/app/SuggestionGenerator.java b/src/main/java/com/wok/supportbot/app/SuggestionGenerator.java index c1806b2..29cbced 100644 --- a/src/main/java/com/wok/supportbot/app/SuggestionGenerator.java +++ b/src/main/java/com/wok/supportbot/app/SuggestionGenerator.java @@ -9,6 +9,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.MessageType; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -110,16 +111,15 @@ public class SuggestionGenerator { String historyText = formatHistory(history); String prompt = buildPrompt(historyText); - String raw = getChatClient().prompt() + // 标准结构化输出:BeanOutputConverter 自动把 JSON Schema 指令追加进 prompt, + // 并把模型返回的 JSON 数组反序列化为 List(可兼容 ```json 代码块包裹) + List raw = getChatClient().prompt() .system(prompt) .user("请根据历史对话生成推荐问题") .call() - .chatResponse() - .getResult() - .getOutput() - .getText(); + .entity(new ParameterizedTypeReference>() {}); - List suggestions = SuggestionResponseParser.parseSuggestionsJson(raw.strip()); + List suggestions = normalize(raw); log.info("推荐问题生成成功: chatId={}, count={}", chatId, suggestions.size()); return suggestions; } catch (Exception e) { @@ -128,6 +128,21 @@ public class SuggestionGenerator { } } + /** + * 规范化模型输出:过滤空白项、去首尾空白、去重,最多保留 3 条。 + */ + private List normalize(List raw) { + if (raw == null) { + return Collections.emptyList(); + } + return raw.stream() + .filter(StringUtils::hasText) + .map(String::strip) + .distinct() + .limit(3) + .collect(Collectors.toList()); + } + /** * 格式化历史消息为纯文本上下文。 */ @@ -171,8 +186,8 @@ public class SuggestionGenerator { "输出要求:\n" + "1. 问题必须与对话主题相关,引导用户深入咨询\n" + "2. 每条问题用中文,不超过 30 个字\n" + - "3. 只输出 JSON 数组格式,例如:[\"问题1\", \"问题2\", \"问题3\"]\n" + - "4. 不要输出任何其他解释、markdown 代码块或序号"; + "3. 每条问题是一个独立、可直接点击发送的完整问句\n" + + "4. 不要输出任何解释、markdown 代码块或序号(输出格式由系统统一约束)"; } /** diff --git a/src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java b/src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java deleted file mode 100644 index dccae92..0000000 --- a/src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java +++ /dev/null @@ -1,294 +0,0 @@ -package com.wok.supportbot.app; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.wok.supportbot.cache.SuggestionCache; -import lombok.extern.slf4j.Slf4j; -import reactor.core.publisher.Flux; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -/** - * 建议问题解析器:从 LLM 原始输出中分离 answer 和 suggestions。 - *

- * 哨兵标记为 ___SUGGESTIONS___,之后为 JSON 字符串数组。 - * 支持同步路径(直接分割完整文本)和流式路径(滑动窗口检测哨兵)。 - */ -@Slf4j -public final class SuggestionResponseParser { - - /** 主哨兵:严格匹配提示词要求的下划线分隔标记 */ - private static final String SENTINEL = "___SUGGESTIONS___"; - - /** 兼容哨兵:模型偶尔不按指令输出时的大写变形 */ - private static final String LOOSE_SENTINEL = "SUGGESTIONS"; - - private static final ObjectMapper objectMapper = new ObjectMapper(); - - private SuggestionResponseParser() { - } - - /** - * 查找最佳哨兵位置:优先严格哨兵,其次兼容哨兵。 - *

- * 兼容哨兵必须后面紧跟 JSON 数组特征('[' 或空白 + '['), - * 避免正文中出现 "SUGGESTIONS" 普通单词时误触发。 - * - * @param text 要搜索的文本 - * @return 哨兵起始位置,未找到返回 -1 - */ - private static int findSentinelIndex(String text) { - int strictIdx = text.lastIndexOf(SENTINEL); - if (strictIdx >= 0) { - return strictIdx; - } - - // 兼容模式:从后往前找大写 SUGGESTIONS,且后续需连接 JSON 数组 - int idx = text.lastIndexOf(LOOSE_SENTINEL); - while (idx >= 0) { - int after = idx + LOOSE_SENTINEL.length(); - if (after < text.length()) { - char c = text.charAt(after); - // 允许 SUGGESTIONS[...]、SUGGESTIONS [...]、SUGGESTIONS:\n[...] - if (c == '[' || Character.isWhitespace(c) || c == ':' || c == '-') { - return idx; - } - } - // 继续向前查找更早的兼容哨兵 - idx = text.lastIndexOf(LOOSE_SENTINEL, idx - 1); - } - return -1; - } - - /** - * 同步路径:按最后一次出现哨兵的位置分割完整文本。 - * - * @param rawText LLM 原始输出 - * @return 解析结果(answer + suggestions) - */ - public static ParsedResponse parse(String rawText) { - if (rawText == null || rawText.isEmpty()) { - return new ParsedResponse(rawText != null ? rawText : "", Collections.emptyList()); - } - - int lastIdx = findSentinelIndex(rawText); - if (lastIdx < 0) { - // 无哨兵标记,整段作为 answer - return new ParsedResponse(rawText, Collections.emptyList()); - } - - String answer = rawText.substring(0, lastIdx).trim(); - // 按实际匹配的哨兵长度截取后续内容(严格哨兵或兼容哨兵长度不同) - int matchedSentinelLength = rawText.startsWith(SENTINEL, lastIdx) ? SENTINEL.length() : LOOSE_SENTINEL.length(); - String suggestionsPart = rawText.substring(lastIdx + matchedSentinelLength).trim(); - - List suggestions = parseSuggestionsJson(suggestionsPart); - return new ParsedResponse(answer, suggestions); - } - - /** - * 流式路径:从原始 Flux 中分离 answer 和 suggestions。 - *

- * 采用「安全缓冲区」策略防止哨兵泄漏到 UI: - *

    - *
  1. 每次 chunk 追加到累积缓冲区
  2. - *
  3. 安全区域 = 缓冲区去掉末尾 SENTINEL.length() 字符(预留哨兵跨 chunk 截断空间)
  4. - *
  5. 在安全区域内检查哨兵:找到则发出哨兵前的剩余内容,后续全进 suggestionsBuffer
  6. - *
  7. 未找到哨兵则发出安全区域内容
  8. - *
  9. doOnComplete 时:哨兵已发现则解析 suggestions;未发现则发出安全缓冲区内剩余内容
  10. - *
- * - * @param rawStream LLM 原始输出流 - * @param cache 建议缓存 - * @param chatId 会话 ID - * @return 纯 answer 的 Flux(不含哨兵及之后的 suggestions) - */ - /** - * 可变状态容器(供 lambda 内部修改)。 - */ - private static class StreamState { - final StringBuilder buf = new StringBuilder(); - int emitted = 0; - boolean sentinelFound = false; - } - - public static Flux parseFromStream(Flux rawStream, SuggestionCache cache, String chatId) { - StreamState state = new StreamState(); - - return rawStream - .concatMap(chunk -> Flux.create(sink -> { - if (state.sentinelFound) { - sink.complete(); - return; - } - state.buf.append(chunk); - - int maxSentinelLen = Math.max(SENTINEL.length(), LOOSE_SENTINEL.length()); - int safeEnd = Math.max(0, state.buf.length() - maxSentinelLen); - if (safeEnd <= state.emitted) { - sink.complete(); - return; - } - - String safeRegion = state.buf.substring(state.emitted, safeEnd); - int sentinelIdx = findSentinelIndex(safeRegion); - - if (sentinelIdx >= 0) { - state.sentinelFound = true; - String beforeSentinel = safeRegion.substring(0, sentinelIdx); - if (!beforeSentinel.isEmpty()) { - sink.next(beforeSentinel); - } - // 确定实际匹配到的哨兵长度 - int matchedLen = safeRegion.startsWith(SENTINEL, sentinelIdx) - ? SENTINEL.length() - : LOOSE_SENTINEL.length(); - // 哨兵在安全区域内的位置 sentinelIdx,相对全缓冲区即 emitted + sentinelIdx - // emitted 推进到哨兵结束后,后续内容进 suggestions 解析 - state.emitted = state.emitted + sentinelIdx + matchedLen; - log.debug("流式路径检测到哨兵: chatId={}, pos={}, matchedLen={}", chatId, state.emitted, matchedLen); - } else { - if (!safeRegion.isEmpty()) { - sink.next(safeRegion); - } - state.emitted = safeEnd; - } - sink.complete(); - })) - // 流结束后发出安全缓冲区内未发出的残留内容(无哨兵场景) - .concatWith(Flux.defer(() -> { - if (!state.sentinelFound && state.emitted < state.buf.length()) { - String residual = state.buf.substring(state.emitted); - if (!residual.isEmpty()) { - return Flux.just(residual); - } - } - return Flux.empty(); - })) - .doOnComplete(() -> { - if (state.sentinelFound) { - String suggestionsPart = state.buf.length() > state.emitted - ? state.buf.substring(state.emitted) : ""; - List suggestions = parseSuggestionsJson(suggestionsPart.strip()); - if (!suggestions.isEmpty()) { - cache.put(chatId, suggestions); - log.info("流式 suggestions 解析成功: chatId={}, count={}", chatId, suggestions.size()); - } else { - log.debug("流式 suggestions 解析为空: chatId={}", chatId); - } - } - }); - } - - /** - * 容错解析 suggestions JSON 数组。 - * 先尝试标准 JSON 解析,失败则按行切分取前 3 条非空行。 - */ - static List parseSuggestionsJson(String jsonPart) { - if (jsonPart == null || jsonPart.isBlank()) { - return Collections.emptyList(); - } - - // 1. 尝试标准 JSON 解析 - String trimmed = jsonPart.strip(); - // 剥离旧版哨兵标记(___SUGGESTIONS___ 及兼容大写变形),取哨兵后的 JSON 部分。 - // 旧版 suggestion_prompt 会要求模型输出哨兵,若未剥离会被按行降级解析成垃圾条目。 - int sentinelIdx = findSentinelIndex(trimmed); - if (sentinelIdx >= 0) { - int matchedLen = trimmed.startsWith(SENTINEL, sentinelIdx) - ? SENTINEL.length() - : LOOSE_SENTINEL.length(); - trimmed = trimmed.substring(sentinelIdx + matchedLen).strip(); - } - // 去掉可能的 markdown 代码块包裹 - trimmed = trimCodeBlock(trimmed); - - try { - List parsed = objectMapper.readValue(trimmed, new TypeReference>() {}); - List result = new ArrayList<>(); - for (String s : parsed) { - if (s != null && !s.isBlank()) { - result.add(s.strip()); - } - } - if (!result.isEmpty()) { - return result.size() <= 3 ? result : result.subList(0, 3); - } - } catch (Exception e) { - log.debug("标准 JSON 解析 suggestions 失败,尝试按行切分: {}", e.getMessage()); - } - - // 2. 降级:按行切分,取前 3 条 - return parseByLines(trimmed); - } - - /** - * 降级解析:按行切分,去掉序号前缀后取前 3 条非空行。 - */ - private static List parseByLines(String text) { - List lines = text.lines() - .map(String::strip) - .map(SuggestionResponseParser::stripNumberPrefix) - .map(SuggestionResponseParser::stripQuotes) - .filter(s -> !s.isBlank()) - .collect(Collectors.toList()); - - if (lines.isEmpty()) { - return Collections.emptyList(); - } - return lines.size() <= 3 ? lines : lines.subList(0, 3); - } - - /** 去掉可能的 markdown 代码块包裹(支持 3 个及以上反引号) */ - private static String trimCodeBlock(String s) { - if (s.startsWith("```") && s.endsWith("```")) { - // 计算开头的反引号数量 - int openCount = 0; - while (openCount < s.length() && s.charAt(openCount) == '`') openCount++; - if (openCount >= 3 && s.endsWith("`".repeat(openCount))) { - String inner = s.substring(openCount, s.length() - openCount).strip(); - if (inner.startsWith("json")) { - inner = inner.substring(4).strip(); - } else if (inner.startsWith("JSON")) { - inner = inner.substring(4).strip(); - } - return inner; - } - } - return s; - } - - /** 去掉行首序号,如 "1." "2." "3." "1、" "2、" "3、" */ - private static String stripNumberPrefix(String s) { - if (s.length() >= 2 && Character.isDigit(s.charAt(0)) && - (s.charAt(1) == '.' || s.charAt(1) == '、' || s.charAt(1) == ')')) { - return s.substring(2).strip(); - } - return s; - } - - /** 去掉首尾引号 */ - private static String stripQuotes(String s) { - if (s.length() >= 2) { - char first = s.charAt(0); - char last = s.charAt(s.length() - 1); - if ((first == '"' && last == '"') || (first == '\'' && last == '\'') || - (first == '“' && last == '”')) { // 中文引号 " " - return s.substring(1, s.length() - 1).strip(); - } - } - return s; - } - - /** - * 解析结果值对象。 - * - * @param answer LLM 回答正文(不含哨兵及之后的 suggestions) - * @param suggestions 建议问题列表(0~3 条) - */ - public record ParsedResponse(String answer, List suggestions) { - } -} diff --git a/src/main/java/com/wok/supportbot/controller/McpServerConfigController.java b/src/main/java/com/wok/supportbot/controller/McpServerConfigController.java index 48b6880..f07b964 100644 --- a/src/main/java/com/wok/supportbot/controller/McpServerConfigController.java +++ b/src/main/java/com/wok/supportbot/controller/McpServerConfigController.java @@ -1,5 +1,6 @@ package com.wok.supportbot.controller; +import com.wok.supportbot.app.AssistantApp; import com.wok.supportbot.config.McpClientManager; import com.wok.supportbot.entity.McpServerConfig; import com.wok.supportbot.mcp.McpToolCallbackAdapter; @@ -29,6 +30,9 @@ public class McpServerConfigController { @Autowired private McpClientManager mcpClientManager; + @Autowired + private AssistantApp assistantApp; + // ==================== 分页列表 ==================== /** @@ -161,6 +165,8 @@ public class McpServerConfigController { } catch (Exception e) { log.warn("新建配置后创建客户端失败(不影响配置保存): {}", e.getMessage()); } + // MCP 工具清单可能变化,清空 AssistantApp 的 ChatClient 缓存,避免继续使用旧工具集 + assistantApp.clearCache(); } return ResponseEntity.ok(Map.of( "success", true, @@ -198,6 +204,8 @@ public class McpServerConfigController { Map updated = mcpServerConfigService.updateConfig(id, config); // 增量操作:仅重建更新的配置客户端,不影响其他已有连接 mcpClientManager.rebuildClient(id); + // MCP 工具清单可能变化,清空 AssistantApp 的 ChatClient 缓存,避免继续使用旧工具集 + assistantApp.clearCache(); return ResponseEntity.ok(Map.of( "success", true, "data", updated, @@ -231,6 +239,8 @@ public class McpServerConfigController { mcpServerConfigService.deleteConfig(id); // 增量操作:仅移除被删除的配置客户端,不影响其他已有连接 mcpClientManager.removeClient(id); + // MCP 工具清单可能变化,清空 AssistantApp 的 ChatClient 缓存,避免继续使用旧工具集 + assistantApp.clearCache(); return ResponseEntity.ok(Map.of( "success", true, "message", "配置删除成功" @@ -282,6 +292,8 @@ public class McpServerConfigController { } else { mcpClientManager.disableClient(id); } + // MCP 工具清单可能变化,清空 AssistantApp 的 ChatClient 缓存,避免继续使用旧工具集 + assistantApp.clearCache(); return ResponseEntity.ok(Map.of( "success", true, "message", active ? "配置已启用" : "配置已禁用" @@ -335,6 +347,8 @@ public class McpServerConfigController { public ResponseEntity> refreshAll() { try { mcpClientManager.refreshAll(); + // MCP 工具清单可能变化,清空 AssistantApp 的 ChatClient 缓存,避免继续使用旧工具集 + assistantApp.clearCache(); return ResponseEntity.ok(Map.of( "success", true, "message", "MCP 客户端连接已刷新" diff --git a/src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java b/src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java deleted file mode 100644 index d1ff34c..0000000 --- a/src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.wok.supportbot.document.extract; - -import lombok.extern.slf4j.Slf4j; -import org.apache.tika.Tika; -import org.apache.tika.exception.TikaException; -import org.springframework.ai.document.Document; -import org.springframework.core.io.Resource; -import org.springframework.core.io.FileSystemResource; -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; - -import java.io.File; -import java.io.IOException; -import java.util.Collections; -import java.util.List; -import java.util.UUID; - -@Component -@Slf4j -public class TikaDocumentReader { - - /** - * 从已保存的文件解析内容 - */ - public List readFromFile(File file) { - try { - Tika tika = new Tika(); - String text = tika.parseToString(new java.io.FileInputStream(file)); - - Document doc = Document.builder() - .id(UUID.randomUUID().toString()) - .text(text) - .build(); - - return Collections.singletonList(doc); - - } catch (IOException | TikaException e) { - log.error("Tika 文件解析失败", e); - throw new RuntimeException("Tika 文件解析失败", e); - } - } - - /** - * 从 MultipartFile 解析内容(兼容旧接口) - */ - public List read(MultipartFile file) { - try { - Tika tika = new Tika(); - String text = tika.parseToString(file.getInputStream()); - - Document doc = Document.builder() - .id(UUID.randomUUID().toString()) - .text(text) - .build(); - - return Collections.singletonList(doc); - - } catch (IOException | TikaException e) { - log.error("Tika 文件解析失败", e); - throw new RuntimeException("Tika 文件解析失败", e); - } - } - - /** - * 获取文件扩展名(含点号),如 ".pptx"。无扩展名时返回 ".tmp" - */ - private String getExtension(String filename) { - if (filename == null || !filename.contains(".")) { - return ".tmp"; - } - return filename.substring(filename.lastIndexOf(".")); - } -} diff --git a/src/main/java/com/wok/supportbot/document/transform/MyTokenTextSplitter.java b/src/main/java/com/wok/supportbot/document/transform/MyTokenTextSplitter.java deleted file mode 100644 index bbc7d53..0000000 --- a/src/main/java/com/wok/supportbot/document/transform/MyTokenTextSplitter.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.wok.supportbot.document.transform; - -import com.wok.supportbot.config.ChunkConfig; -import org.springframework.ai.document.Document; -import org.springframework.ai.transformer.splitter.TokenTextSplitter; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -import java.util.List; - -/** - * 自定义基于 Token 的切词器 - * 支持通过 ChunkConfig 动态调整分块参数 - */ -@Component -public class MyTokenTextSplitter { - - @Autowired - private ChunkConfig chunkConfig; - - /** - * 使用全局配置参数创建分割器 - */ - public List splitDocuments(List documents) { - TokenTextSplitter splitter = new TokenTextSplitter( - chunkConfig.getChunkSize(), - chunkConfig.getOverlap(), - chunkConfig.getMinChunkSizeChars(), - chunkConfig.getMaxNumChunks(), - chunkConfig.isKeepSeparator() - ); - return splitter.apply(documents); - } - - /** - * 使用自定义参数创建分割器(覆盖全局配置) - * - * @param documents 文档列表 - * @param chunkSize 分块大小 - * @param overlap 重叠大小 - */ - public List splitDocuments(List documents, Integer chunkSize, Integer overlap) { - int cs = chunkSize != null ? chunkSize : chunkConfig.getChunkSize(); - int ol = overlap != null ? overlap : chunkConfig.getOverlap(); - TokenTextSplitter splitter = new TokenTextSplitter( - cs, ol, - chunkConfig.getMinChunkSizeChars(), - chunkConfig.getMaxNumChunks(), - chunkConfig.isKeepSeparator() - ); - return splitter.apply(documents); - } - - /** - * 使用自定义参数创建分割器(全参数覆盖) - */ - public List splitCustomized(List documents) { - TokenTextSplitter splitter = new TokenTextSplitter( - chunkConfig.getChunkSize(), - chunkConfig.getOverlap(), - chunkConfig.getMinChunkSizeChars(), - chunkConfig.getMaxNumChunks(), - chunkConfig.isKeepSeparator() - ); - return splitter.apply(documents); - } -} diff --git a/src/main/java/com/wok/supportbot/document/transform/OverlapTokenTextSplitter.java b/src/main/java/com/wok/supportbot/document/transform/OverlapTokenTextSplitter.java new file mode 100644 index 0000000..342d374 --- /dev/null +++ b/src/main/java/com/wok/supportbot/document/transform/OverlapTokenTextSplitter.java @@ -0,0 +1,252 @@ +package com.wok.supportbot.document.transform; + +import com.knuddels.jtokkit.Encodings; +import com.knuddels.jtokkit.api.Encoding; +import com.knuddels.jtokkit.api.EncodingType; +import com.knuddels.jtokkit.api.IntArrayList; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.transformer.splitter.TextSplitter; +import org.springframework.util.Assert; + +import java.util.ArrayList; +import java.util.List; + +/** + * 带重叠(overlap)的 Token 分块器。 + * + *

Spring AI 的 {@link org.springframework.ai.transformer.splitter.TokenTextSplitter} + * 不支持 overlap —— 它的 5 参构造器与 Builder 均无该形参(社区 PR #4054 已标记不向 1.x 回迁)。 + * 本类直接继承标准基类 {@link TextSplitter},复刻 TokenTextSplitter 的全部切分语义 + * (标点截断、最小分块长度、分块数上限、分隔符处理),仅把「前进步长」由 chunkSize + * 改为 {@code chunkSize - overlap},从而让重叠真正生效。 + * + *

元数据继承({@code parent_document_id} / {@code chunk_index} / {@code total_chunks}) + * 由 {@link TextSplitter} 基类统一处理,本类只负责文本切分。 + * + *

Token 编码与 TokenTextSplitter 保持一致:jtokkit + CL100K_BASE。 + */ +public class OverlapTokenTextSplitter extends TextSplitter { + + private static final Logger log = LoggerFactory.getLogger(OverlapTokenTextSplitter.class); + + /** 默认分块大小(token 数) */ + private static final int DEFAULT_CHUNK_SIZE = 800; + + /** 默认重叠 token 数(0 表示不重叠,等价于 TokenTextSplitter 行为) */ + private static final int DEFAULT_OVERLAP = 0; + + /** 默认最小分块字符数 */ + private static final int MIN_CHUNK_SIZE_CHARS = 350; + + /** 默认丢弃阈值:长度不超过该值的分块不入库 */ + private static final int MIN_CHUNK_LENGTH_TO_EMBED = 5; + + /** 默认单篇文档最大分块数 */ + private static final int MAX_NUM_CHUNKS = 10000; + + /** 默认保留分隔符 */ + private static final boolean KEEP_SEPARATOR = true; + + private final Encoding encoding = Encodings.newLazyEncodingRegistry().getEncoding(EncodingType.CL100K_BASE); + + /** 目标分块大小(token 数,不含重叠部分) */ + private final int chunkSize; + + /** 相邻分块的重叠 token 数 */ + private final int overlap; + + /** 最小分块字符数:仅当剩余 token 数超过 chunkSize 时才按标点截断,且截断点须大于该值 */ + private final int minChunkSizeChars; + + /** 丢弃长度不超过该值的分块 */ + private final int minChunkLengthToEmbed; + + /** 单篇文档最大分块数 */ + private final int maxNumChunks; + + /** 是否保留分隔符(false 时把换行替换为空格) */ + private final boolean keepSeparator; + + private OverlapTokenTextSplitter(int chunkSize, int overlap, int minChunkSizeChars, + int minChunkLengthToEmbed, int maxNumChunks, boolean keepSeparator) { + Assert.isTrue(chunkSize > 0, "chunkSize 必须大于 0"); + this.chunkSize = chunkSize; + // 重叠必须严格小于分块大小,否则前进步长会 <= 0 导致死循环;此处收敛到合法区间而非直接报错 + int clamped = Math.max(0, Math.min(overlap, chunkSize - 1)); + if (clamped != overlap) { + log.warn("overlap({}) 超出合法区间 [0, chunkSize-1={}],已收敛为 {};" + + "该配置会使前进步长退化为 1 个 token,产生大量分块,请检查 knowledge.chunk.overlap", + overlap, chunkSize - 1, clamped); + } + this.overlap = clamped; + this.minChunkSizeChars = minChunkSizeChars; + this.minChunkLengthToEmbed = minChunkLengthToEmbed; + this.maxNumChunks = maxNumChunks; + this.keepSeparator = keepSeparator; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * 标点截断允许的最小前进步长(token 数)。 + *

+ * 防止「标点截断」与「overlap」叠加后把前进步长压到 1 个 token 导致分块数爆炸: + * 截断后必须仍能前进这么多 token,否则放弃本次截断(宁可切断句子也要保证分块数量可控)。 + *

+ * overlap=0 时返回 1,即允许任意截断,与标准 {@code TokenTextSplitter} 行为完全一致。 + */ + private int minAdvance() { + if (this.overlap == 0) { + return 1; + } + return Math.max(1, (this.chunkSize - this.overlap) / 2); + } + + @Override + protected List splitText(String text) { + if (text == null || text.trim().isEmpty()) { + return new ArrayList<>(); + } + + List tokens = getEncodedTokens(text); + List chunks = new ArrayList<>(); + int numChunks = 0; + + while (!tokens.isEmpty() && numChunks < this.maxNumChunks) { + int windowSize = Math.min(this.chunkSize, tokens.size()); + boolean lastWindow = windowSize >= tokens.size(); + String chunkText = decodeTokens(tokens.subList(0, windowSize)); + + // 空白块直接跳过(不产出,也不做重叠回退) + if (chunkText.trim().isEmpty()) { + tokens = tokens.subList(windowSize, tokens.size()); + continue; + } + + // 仅当剩余 token 数超过 chunkSize 时才做标点截断,避免小文本被无谓切分 + if (tokens.size() > this.chunkSize) { + int lastPunctuation = Math.max(chunkText.lastIndexOf('.'), + Math.max(chunkText.lastIndexOf('?'), + Math.max(chunkText.lastIndexOf('!'), chunkText.lastIndexOf('\n')))); + if (lastPunctuation != -1 && lastPunctuation > this.minChunkSizeChars) { + String candidate = chunkText.substring(0, lastPunctuation + 1); + // 前进步长 = 截断后消耗的 token 数 - overlap。截断会缩短消耗量, + // 若不加约束,截断点靠前时步长会被压到 1 个 token,分块数成倍膨胀 + // (实测 chunkSize=60/overlap=30 时可达 10 倍)。此处要求截断后仍能前进至少 minAdvance()。 + if (getEncodedTokens(candidate).size() - this.overlap >= minAdvance()) { + chunkText = candidate; + } + } + } + + String chunkTextToAppend = this.keepSeparator + ? chunkText.trim() + : chunkText.replace(System.lineSeparator(), " ").trim(); + if (chunkTextToAppend.length() > this.minChunkLengthToEmbed) { + chunks.add(chunkTextToAppend); + } + numChunks++; + + // 本窗口已覆盖全部剩余 token,无需再产出重叠块 + if (lastWindow) { + tokens = new ArrayList<>(); + break; + } + + // 本块实际消耗的 token 数(可能因标点截断而少于窗口大小) + int consumed = getEncodedTokens(chunkText).size(); + // 前进步长 = 消耗量 - 重叠量;至少前进 1 个 token,避免死循环 + int step = Math.max(1, consumed - this.overlap); + tokens = tokens.subList(step, tokens.size()); + } + + // 处理剩余 token(与 TokenTextSplitter 保持一致,用于达到 maxNumChunks 上限的场景) + if (!tokens.isEmpty()) { + String remainingText = decodeTokens(tokens).replace(System.lineSeparator(), " ").trim(); + if (remainingText.length() > this.minChunkLengthToEmbed) { + chunks.add(remainingText); + } + } + + return chunks; + } + + private List getEncodedTokens(String text) { + Assert.notNull(text, "Text must not be null"); + return this.encoding.encode(text).boxed(); + } + + private String decodeTokens(List tokens) { + Assert.notNull(tokens, "Tokens must not be null"); + IntArrayList tokensIntArray = new IntArrayList(tokens.size()); + tokens.forEach(tokensIntArray::add); + return this.encoding.decode(tokensIntArray); + } + + /** + * 分块器构建器 + */ + public static final class Builder { + + private int chunkSize = DEFAULT_CHUNK_SIZE; + + private int overlap = DEFAULT_OVERLAP; + + private int minChunkSizeChars = MIN_CHUNK_SIZE_CHARS; + + private int minChunkLengthToEmbed = MIN_CHUNK_LENGTH_TO_EMBED; + + private int maxNumChunks = MAX_NUM_CHUNKS; + + private boolean keepSeparator = KEEP_SEPARATOR; + + private Builder() { + } + + /** 目标分块大小(token 数) */ + public Builder withChunkSize(int chunkSize) { + this.chunkSize = chunkSize; + return this; + } + + /** 相邻分块的重叠 token 数 */ + public Builder withOverlap(int overlap) { + this.overlap = overlap; + return this; + } + + /** 最小分块字符数(标点截断的下限) */ + public Builder withMinChunkSizeChars(int minChunkSizeChars) { + this.minChunkSizeChars = minChunkSizeChars; + return this; + } + + /** 丢弃长度不超过该值的分块 */ + public Builder withMinChunkLengthToEmbed(int minChunkLengthToEmbed) { + this.minChunkLengthToEmbed = minChunkLengthToEmbed; + return this; + } + + /** 单篇文档最大分块数 */ + public Builder withMaxNumChunks(int maxNumChunks) { + this.maxNumChunks = maxNumChunks; + return this; + } + + /** 是否保留分隔符 */ + public Builder withKeepSeparator(boolean keepSeparator) { + this.keepSeparator = keepSeparator; + return this; + } + + public OverlapTokenTextSplitter build() { + return new OverlapTokenTextSplitter(this.chunkSize, this.overlap, this.minChunkSizeChars, + this.minChunkLengthToEmbed, this.maxNumChunks, this.keepSeparator); + } + + } + +} diff --git a/src/main/java/com/wok/supportbot/mcp/McpToolCallback.java b/src/main/java/com/wok/supportbot/mcp/McpToolCallback.java index daf68b8..ce290d4 100644 --- a/src/main/java/com/wok/supportbot/mcp/McpToolCallback.java +++ b/src/main/java/com/wok/supportbot/mcp/McpToolCallback.java @@ -37,8 +37,6 @@ public class McpToolCallback implements ToolCallback { */ private static final int MAX_LOG_LENGTH = 1000; - // ==================== 工具调用事件收集 ==================== - /** * 工具调用事件记录 */ @@ -55,29 +53,6 @@ public class McpToolCallback implements ToolCallback { */ public static final String MCP_ROUNDS_KEY = "mcp_tool_rounds"; - /** - * 线程级事件收集器:在同一请求线程中收集所有工具调用事件 - * 作为无 toolContext 场景(同步调用 / 兼容旧逻辑)的兜底。 - */ - private static final ThreadLocal> EVENTS = ThreadLocal.withInitial(ArrayList::new); - - /** - * 获取当前线程收集的所有工具调用事件,并清空收集器 - */ - public static List drainEvents() { - List events = EVENTS.get(); - List copy = new ArrayList<>(events); - events.clear(); - return copy; - } - - /** - * 重置事件收集器(每个新请求开始时调用) - */ - public static void resetEvents() { - EVENTS.remove(); - } - /** * MCP Server 配置 ID(用于路由调用到正确的 MCP Client) */ @@ -184,20 +159,21 @@ public class McpToolCallback implements ToolCallback { */ @Override public String call(String toolInput) { - // 无 ToolContext 时回退到 ThreadLocal 收集器(同步调用 / 兼容旧逻辑) - return doCall(toolInput, EVENTS.get(), null); + // 接口要求的单参重载:无 ToolContext 时事件无处可传,用一次性列表承接后丢弃 + // (生产路径始终走 call(String, ToolContext),由 AssistantApp 注入收集器) + return doCall(toolInput, new ArrayList<>(), null); } /** * 带 ToolContext 的执行入口。 *

- * 优先从 ToolContext 读取 AssistantApp 注入的事件收集器与轮次计数器, - * 解决 Reactor 流式场景下 ThreadLocal 跨线程丢失的问题;读取不到时回退 ThreadLocal。 + * 从 ToolContext 读取 AssistantApp 注入的事件收集器与轮次计数器, + * 解决 Reactor 流式场景下 ThreadLocal 跨线程丢失的问题;读取不到时事件被丢弃、轮次回退线程级计数。 */ @Override @SuppressWarnings("unchecked") public String call(String toolInput, ToolContext toolContext) { - List collector = EVENTS.get(); + List collector = new ArrayList<>(); AtomicInteger rounds = null; Map ctx = toolContext != null ? toolContext.getContext() : null; if (ctx != null) { diff --git a/src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java b/src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java deleted file mode 100644 index 814c4f9..0000000 --- a/src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.wok.supportbot.mcp; - -import org.springframework.http.codec.ServerSentEvent; - -/** - * SSE 事件构建器 - * 用于在 MCP 工具调用流程中构建标准 SSE 事件, - * 让前端能区分文本内容与工具调用状态。 - * - * 事件类型: - * - message: 普通文本内容 - * - tool_call_start: 工具调用开始 - * - tool_call_result: 工具调用结果返回 - * - error: 错误信息 - */ -public class SseEventBuilder { - - /** - * 构建普通文本消息事件 - */ - public static ServerSentEvent messageEvent(String data) { - return ServerSentEvent.builder() - .event("message") - .data(data) - .build(); - } - - /** - * 构建工具调用开始事件 - */ - public static ServerSentEvent toolCallStartEvent(String toolName, String input) { - String json = String.format("{\"tool\":\"%s\",\"input\":\"%s\"}", - escapeJson(toolName), escapeJson(input)); - return ServerSentEvent.builder() - .event("tool_call_start") - .data(json) - .build(); - } - - /** - * 构建工具调用结果事件 - */ - public static ServerSentEvent toolCallResultEvent(String toolName, String result, long latencyMs) { - String json = String.format("{\"tool\":\"%s\",\"result\":\"%s\",\"latencyMs\":%d}", - escapeJson(toolName), escapeJson(result), latencyMs); - return ServerSentEvent.builder() - .event("tool_call_result") - .data(json) - .build(); - } - - /** - * 构建错误事件 - */ - public static ServerSentEvent errorEvent(String message) { - return ServerSentEvent.builder() - .event("error") - .data("{\"message\":\"" + escapeJson(message) + "\"}") - .build(); - } - - /** - * JSON 字符串转义 - */ - private static String escapeJson(String s) { - if (s == null) return ""; - return s.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t"); - } -} diff --git a/src/main/java/com/wok/supportbot/rag/RerankerService.java b/src/main/java/com/wok/supportbot/rag/RerankerService.java index bf47e5d..715b8ad 100644 --- a/src/main/java/com/wok/supportbot/rag/RerankerService.java +++ b/src/main/java/com/wok/supportbot/rag/RerankerService.java @@ -7,8 +7,10 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.*; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.RestClient; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import java.net.http.HttpClient; import java.time.Duration; import java.util.*; import java.util.concurrent.ConcurrentHashMap; @@ -26,9 +28,29 @@ public class RerankerService { @Autowired private AiModelConfigMapper aiModelConfigMapper; - /** HTTP 超时时间(秒) */ + /** HTTP 读超时(秒):与下方 RestClient 的 read timeout 一致,超过即 fallback 到 RRF 原始排序 */ private static final int TIMEOUT_SECONDS = 3; + /** HTTP 连接超时(秒):Rerank 在检索链路内同步调用,必须快速失败 */ + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3); + + /** + * 带超时的 HTTP 客户端。 + *

+ * 原实现用 {@code new RestTemplate()}(无任何超时),慢 provider 会把检索线程拖到 TCP 超时。 + * 此处改用 RestClient + JdkClientHttpRequestFactory 显式设置 connect/read 超时。 + */ + private final RestClient restClient; + + public RerankerService() { + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .build(); + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(Duration.ofSeconds(TIMEOUT_SECONDS)); + this.restClient = RestClient.builder().requestFactory(requestFactory).build(); + } + /** RERANK 活跃配置本地缓存,避免每次 Rerank 调用都查询 ai_model_config 表 */ private volatile AiModelConfig cachedRerankConfig; private volatile long lastCacheTime = 0; @@ -132,10 +154,8 @@ public class RerankerService { headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(config.getApiKey()); - ResponseEntity response = postWithTimeout(url, headers, body); - // 解析响应:{"output":{"results":[{"index":0,"relevance_score":0.95},...]}} - Map responseBody = response.getBody(); + Map responseBody = postWithTimeout(url, headers, body); if (responseBody == null) { throw new RuntimeException("DashScope Rerank 响应为空"); } @@ -183,10 +203,8 @@ public class RerankerService { headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(config.getApiKey()); - ResponseEntity response = postWithTimeout(url, headers, body); - // 解析响应:{"results":[{"index":0,"relevance_score":0.95},...]} - Map responseBody = response.getBody(); + Map responseBody = postWithTimeout(url, headers, body); if (responseBody == null) { throw new RuntimeException("OpenAI 兼容 Rerank 响应为空"); } @@ -227,15 +245,17 @@ public class RerankerService { } /** - * 带超时的 HTTP POST 请求 + * 带 connect/read 超时的 HTTP POST 请求。 + * 使用 JDK HttpClient + JdkClientHttpRequestFactory,超时后抛异常由调用方 fallback 到 RRF 原始排序。 */ @SuppressWarnings("unchecked") - private ResponseEntity postWithTimeout(String url, HttpHeaders headers, Map body) { - RestTemplate restTemplate = new RestTemplate(); - HttpEntity> entity = new HttpEntity<>(body, headers); - // RestTemplate 默认无超时,此处依赖连接/读取超时由底层控制 - // Spring Boot 3.x 中可使用 RestClient 替代以获得更好的超时支持 - return restTemplate.exchange(url, HttpMethod.POST, entity, Map.class); + private Map postWithTimeout(String url, HttpHeaders headers, Map body) { + return restClient.post() + .uri(url) + .headers(h -> h.addAll(headers)) + .body(body) + .retrieve() + .body(Map.class); } /** diff --git a/src/main/java/com/wok/supportbot/service/DocumentProcessingService.java b/src/main/java/com/wok/supportbot/service/DocumentProcessingService.java index 89c9504..1b07c11 100644 --- a/src/main/java/com/wok/supportbot/service/DocumentProcessingService.java +++ b/src/main/java/com/wok/supportbot/service/DocumentProcessingService.java @@ -1,8 +1,9 @@ package com.wok.supportbot.service; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.wok.supportbot.config.ChunkConfig; import com.wok.supportbot.dao.KnowledgeDocumentMapper; -import com.wok.supportbot.document.transform.MyTokenTextSplitter; +import com.wok.supportbot.document.transform.OverlapTokenTextSplitter; import com.wok.supportbot.entity.KnowledgeDocument; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.document.Document; @@ -32,6 +33,12 @@ public class DocumentProcessingService { @Value("${knowledge.vector.batch-size:50}") private int embedBatchSize = 50; + /** + * 分块丢弃阈值(字符):长度不超过该值的分块不入库。 + * ChunkConfig 无对应配置项,此处沿用历史生效值(旧实现因形参错位,实际也是 10)。 + */ + private static final int MIN_CHUNK_LENGTH_TO_EMBED = 10; + /** * 文档级自动重试次数(不含首次尝试)。 * 注意:批内 EmbeddingModel 已自带 3 次指数退避重试(EmbeddingModelFactory.createRetryTemplate), @@ -50,7 +57,7 @@ public class DocumentProcessingService { private KnowledgeDocumentMapper documentMapper; @Autowired - private MyTokenTextSplitter myTokenTextSplitter; + private ChunkConfig chunkConfig; @Autowired private VectorStore pgVectorVectorStore; @@ -88,7 +95,7 @@ public class DocumentProcessingService { // 新文档首次处理无需清理(尚无向量);若中途失败触发整体重试,框架内会先清残留向量再重建 runPipelineWithRetry(doc, meta, false, - () -> myTokenTextSplitter.splitDocuments(documents, chunkSize, overlap)); + () -> buildSplitter(chunkSize, overlap).apply(documents)); } /** @@ -117,10 +124,29 @@ public class DocumentProcessingService { if (doc.getExtraConfig().get("chunkSize") instanceof Number cs) chunkSize = cs.intValue(); if (doc.getExtraConfig().get("overlap") instanceof Number ol) overlap = ol.intValue(); } - return myTokenTextSplitter.splitDocuments(documents, chunkSize, overlap); + return buildSplitter(chunkSize, overlap).apply(documents); }); } + /** + * 构建分块器:per-doc 参数优先,缺省回退全局 ChunkConfig。 + * + * @param chunkSize 分块大小(token 数,可为 null) + * @param overlap 重叠大小(token 数,可为 null) + */ + private OverlapTokenTextSplitter buildSplitter(Integer chunkSize, Integer overlap) { + int cs = chunkSize != null ? chunkSize : chunkConfig.getChunkSize(); + int ol = overlap != null ? overlap : chunkConfig.getOverlap(); + return OverlapTokenTextSplitter.builder() + .withChunkSize(cs) + .withOverlap(ol) + .withMinChunkSizeChars(chunkConfig.getMinChunkSizeChars()) + .withMinChunkLengthToEmbed(MIN_CHUNK_LENGTH_TO_EMBED) + .withMaxNumChunks(chunkConfig.getMaxNumChunks()) + .withKeepSeparator(chunkConfig.isKeepSeparator()) + .build(); + } + /** * 文档级处理框架(外层兜底):切分 → 分批向量化 → 失败自动整体重试有限次数。 * 外层 try-catch 保证任何未预期异常下文档状态都从 PROCESSING 收敛到 FAILED, diff --git a/src/main/java/com/wok/supportbot/service/DocumentService.java b/src/main/java/com/wok/supportbot/service/DocumentService.java index 5d54498..92a762c 100644 --- a/src/main/java/com/wok/supportbot/service/DocumentService.java +++ b/src/main/java/com/wok/supportbot/service/DocumentService.java @@ -6,8 +6,6 @@ import com.wok.supportbot.dao.KnowledgeDocumentMapper; import com.wok.supportbot.document.extract.JsonDocumentLoader; import com.wok.supportbot.document.extract.MarkdownDocumentLoader; import com.wok.supportbot.document.extract.SimpleStringDocumentReader; -import com.wok.supportbot.document.extract.TikaDocumentReader; -import com.wok.supportbot.document.transform.MyTokenTextSplitter; import com.wok.supportbot.entity.CategoryNode; import com.wok.supportbot.entity.KnowledgeCategory; import com.wok.supportbot.entity.KnowledgeDocument; @@ -15,10 +13,12 @@ import com.wok.supportbot.entity.KnowledgeFolder; import com.wok.supportbot.entity.SearchResult; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.document.Document; +import org.springframework.ai.reader.tika.TikaDocumentReader; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.FileSystemResource; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -52,12 +52,6 @@ public class DocumentService { @Autowired private VectorStore pgVectorVectorStore; - @Autowired - private MyTokenTextSplitter myTokenTextSplitter; - - @Autowired - private TikaDocumentReader tikaDocumentReader; - @Autowired private SimpleStringDocumentReader simpleStringDocumentReader; @@ -167,7 +161,8 @@ public class DocumentService { java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile(); // 2. 使用保存的文件进行解析(避免 MultipartFile 的临时文件问题) - List documents = tikaDocumentReader.readFromFile(savedFile); + // 使用 Spring AI 官方 TikaDocumentReader:get() 内部为 List.of(toDocument(...)),恒定返回 1 个 Document + List documents = new TikaDocumentReader(new FileSystemResource(savedFile)).get(); // 剥离路径前缀:文件夹上传时浏览器 filename 携带相对路径(含根目录名),否则标题会带「目录/」前缀 String sourceName = stripPath(file.getOriginalFilename()); String fileType = getFileExtension(sourceName); diff --git a/src/main/java/com/wok/supportbot/service/IntentRouter.java b/src/main/java/com/wok/supportbot/service/IntentRouter.java index 3c17a0d..5c9c88e 100644 --- a/src/main/java/com/wok/supportbot/service/IntentRouter.java +++ b/src/main/java/com/wok/supportbot/service/IntentRouter.java @@ -1,7 +1,5 @@ package com.wok.supportbot.service; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import com.wok.supportbot.config.ChatModelFactory; import lombok.AllArgsConstructor; import lombok.Data; @@ -9,6 +7,7 @@ import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -18,6 +17,10 @@ import org.springframework.stereotype.Service; * - FAQ: 常见问题 → FaqMatchEngine 精准匹配 * - RAG: 知识库检索 → 现有 RAG 流程 * - CHITCHAT: 闲聊 → 简单对话 + * + *

结构化输出使用 Spring AI 标准组件 {@link BeanOutputConverter}: + * 由它把 JSON Schema 指令追加进 Prompt,并把模型返回的 JSON 反序列化为 {@link IntentResult}, + * 不再手写正则解析。{@code ChatClient.entity(...)} 内部即是同一套机制。 */ @Service @Slf4j @@ -26,15 +29,23 @@ public class IntentRouter { @Autowired private ChatModelFactory chatModelFactory; - /** 意图分类 Prompt 模板 */ + /** 结构化输出转换器(无状态,可安全复用):生成 Schema 指令 + 反序列化模型响应 */ + private static final BeanOutputConverter INTENT_CONVERTER = + new BeanOutputConverter<>(IntentResult.class); + + /** + * 意图分类 Prompt 模板。 + * 输出格式约束(JSON Schema)由 {@code BeanOutputConverter.getFormat()} 统一追加,模板中不再硬编码。 + */ private static final String INTENT_PROMPT_TEMPLATE = """ 你是一个意图分类器。根据用户问题,判断其属于以下哪个意图: - FAQ: 常见问题,如产品功能、价格、退换货政策、服务流程等标准问答 - RAG: 需要查阅文档/知识库才能回答的专业问题或细节问题 - CHITCHAT: 闲聊、问候、感谢、告别等非业务话题 - 仅返回JSON格式: {"intent":"FAQ|RAG|CHITCHAT","confidence":0.0-1.0} 用户问题: %s + + %s """; // ==================== 意图结果内部类 ==================== @@ -67,82 +78,37 @@ public class IntentRouter { try { ChatModel chatModel = chatModelFactory.getChatModel("CHAT"); - String promptText = INTENT_PROMPT_TEMPLATE.formatted(userQuestion); - Prompt prompt = new Prompt(promptText); + String promptText = INTENT_PROMPT_TEMPLATE.formatted(userQuestion, INTENT_CONVERTER.getFormat()); - String response = chatModel.call(prompt).getResult().getOutput().getText(); + String response = chatModel.call(new Prompt(promptText)).getResult().getOutput().getText(); log.debug("意图分类原始响应: {}", response); - return parseIntentResponse(response); - } catch (Exception e) { - log.error("意图分类失败,降级为 RAG: question={}", userQuestion, e); - return new IntentResult("RAG", 0.0); - } - } - - // ==================== 解析方法 ==================== - - /** 从 LLM 原始响应中提取 intent 字段值 */ - private static final Pattern INTENT_PATTERN = Pattern.compile("\"intent\"\\s*:\\s*\"(FAQ|RAG|CHITCHAT)\""); - /** 从 LLM 原始响应中提取 confidence 字段值 */ - private static final Pattern CONFIDENCE_PATTERN = Pattern.compile("\"confidence\"\\s*:\\s*(0?\\.\\d+|1\\.0|[01])"); - - /** - * 解析 LLM 返回的意图分类结果。 - * 不依赖 JSON 结构完整性,使用正则直接从原始文本提取 intent 和 confidence 字段值, - * 可正确处理 LLM 输出的各种畸变:重复拼接、缺少逗号、额外文本、BOM/零宽字符等。 - * 如果解析失败,默认返回 RAG(降级到现有流程) - */ - private IntentResult parseIntentResponse(String response) { - try { - // 1. 预处理:去除 BOM、零宽字符等不可见干扰字符 - String cleaned = response.trim() - .replace("", "") - .replace("​", "") - .replace("‌", "") - .replace("‍", "") - .replace(" ", " ") - .replace("‎", "") - .replace("‏", "") - .replace("⁠", ""); - - // 2. 去除 markdown 代码块包裹 - if (cleaned.startsWith("```")) { - cleaned = cleaned.replaceAll("^```(?:json)?\\s*", "").replaceAll("\\s*```$", ""); - } - - // 3. 正则提取 intent(取第一个匹配,应对 LLM 重复拼接 JSON 的情况) - Matcher intentMatcher = INTENT_PATTERN.matcher(cleaned); - String intent = intentMatcher.find() ? intentMatcher.group(1) : null; - - // 4. 正则提取 confidence(取第一个匹配) - Matcher confidenceMatcher = CONFIDENCE_PATTERN.matcher(cleaned); - double confidence = confidenceMatcher.find() ? Double.parseDouble(confidenceMatcher.group(1)) : 0.5; - - if (intent == null) { - log.warn("未能从 LLM 响应中提取到 intent 字段,降级为 RAG"); - return new IntentResult("RAG", 0.0); - } - - if (!isValidIntent(intent)) { - log.warn("无效的意图类型: {}, 降级为 RAG", intent); + IntentResult result = INTENT_CONVERTER.convert(response); + if (result == null || !isValidIntent(result.getIntent())) { + log.warn("意图分类结果无效,降级为 RAG: rawResponse={}", abbreviate(response)); return new IntentResult("RAG", 0.5); } - - return new IntentResult(intent, confidence); + return result; } catch (Exception e) { - String truncated = response != null && response.length() > 200 - ? response.substring(0, 200) + "..." - : response; - log.warn("意图分类解析异常,降级为 RAG: rawResponse={}", truncated, e); + log.warn("意图分类失败,降级为 RAG: question={}", abbreviate(userQuestion), e); return new IntentResult("RAG", 0.0); } } /** - * 校验意图类型是否有效 + * 校验意图类型是否有效(模型可能返回枚举外的值) */ private boolean isValidIntent(String intent) { return "FAQ".equals(intent) || "RAG".equals(intent) || "CHITCHAT".equals(intent); } + + /** + * 日志截断,避免回显整段响应 + */ + private static String abbreviate(String text) { + if (text == null) { + return null; + } + return text.length() > 200 ? text.substring(0, 200) + "..." : text; + } }