Browse Source

refactor: 自造轮子改用 Spring AI 标准组件、清理死代码并修复既有缺陷

组件替换(改用 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<IntentResult>,保留原有降级语义(空输入/解析失败 → RAG)
- 推荐问题:SuggestionGenerator 的哨兵字符串 + 代码块剥离 + 按行降级解析改用
  ChatClient.entity(ParameterizedTypeReference<List<String>>),整体删除 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 已不成立
Spring-AI-1.1.2
wanghanlin 3 days ago
parent
commit
84eea7564f
  1. 33
      CLAUDE.md
  2. 2
      DEPLOY.md
  3. 31
      README.md
  4. 43
      src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java
  5. 5
      src/main/java/com/wok/supportbot/app/ChatResult.java
  6. 31
      src/main/java/com/wok/supportbot/app/SuggestionGenerator.java
  7. 294
      src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java
  8. 14
      src/main/java/com/wok/supportbot/controller/McpServerConfigController.java
  9. 73
      src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java
  10. 67
      src/main/java/com/wok/supportbot/document/transform/MyTokenTextSplitter.java
  11. 252
      src/main/java/com/wok/supportbot/document/transform/OverlapTokenTextSplitter.java
  12. 36
      src/main/java/com/wok/supportbot/mcp/McpToolCallback.java
  13. 73
      src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java
  14. 50
      src/main/java/com/wok/supportbot/rag/RerankerService.java
  15. 34
      src/main/java/com/wok/supportbot/service/DocumentProcessingService.java
  16. 13
      src/main/java/com/wok/supportbot/service/DocumentService.java
  17. 100
      src/main/java/com/wok/supportbot/service/IntentRouter.java

33
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<String>` 形态;废弃的 `Flux<ServerSentEvent>``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 地址后,点击「获取模型」即可自动填充模型名称下拉列表(`<datalist>` 支持搜索选择 + 自定义输入)
### 依赖版本
- 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<ApplicationReadyEvent>`,启动时检查 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<IntentResult>` 生成 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` 配置,启动时自动检测不匹配并告警**

2
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)编写。*

31
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

43
src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java

@ -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<String, Object> 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;
}
}

5
src/main/java/com/wok/supportbot/app/ChatResult.java

@ -10,8 +10,9 @@ import java.util.List;
* 除了回答文本还携带本次触发的 MCP 工具调用事件让同步对话也能像流式对话一样
* 展示工具调用过程原仅流式 {@code appendMcpToolEvents} 追加事件同步路径无事件
* <p>
* 新增 {@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 工具调用事件无调用时为空列表

31
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<String>可兼容 ```json 代码块包裹
List<String> raw = getChatClient().prompt()
.system(prompt)
.user("请根据历史对话生成推荐问题")
.call()
.chatResponse()
.getResult()
.getOutput()
.getText();
.entity(new ParameterizedTypeReference<List<String>>() {});
List<String> suggestions = SuggestionResponseParser.parseSuggestionsJson(raw.strip());
List<String> 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<String> normalize(List<String> 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 代码块或序号(输出格式由系统统一约束)";
}
/**

294
src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java

@ -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
* <p>
* 哨兵标记为 <code>___SUGGESTIONS___</code>之后为 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() {
}
/**
* 查找最佳哨兵位置优先严格哨兵其次兼容哨兵
* <p>
* 兼容哨兵必须后面紧跟 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<String> suggestions = parseSuggestionsJson(suggestionsPart);
return new ParsedResponse(answer, suggestions);
}
/**
* 流式路径从原始 Flux 中分离 answer suggestions
* <p>
* 采用安全缓冲区策略防止哨兵泄漏到 UI
* <ol>
* <li>每次 chunk 追加到累积缓冲区</li>
* <li>安全区域 = 缓冲区去掉末尾 SENTINEL.length() 字符预留哨兵跨 chunk 截断空间</li>
* <li>在安全区域内检查哨兵找到则发出哨兵前的剩余内容后续全进 suggestionsBuffer</li>
* <li>未找到哨兵则发出安全区域内容</li>
* <li>doOnComplete 哨兵已发现则解析 suggestions未发现则发出安全缓冲区内剩余内容</li>
* </ol>
*
* @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<String> parseFromStream(Flux<String> rawStream, SuggestionCache cache, String chatId) {
StreamState state = new StreamState();
return rawStream
.concatMap(chunk -> Flux.<String>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<String> 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<String> 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<String> parsed = objectMapper.readValue(trimmed, new TypeReference<List<String>>() {});
List<String> 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<String> parseByLines(String text) {
List<String> 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 建议问题列表03
*/
public record ParsedResponse(String answer, List<String> suggestions) {
}
}

14
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<String, Object> 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<Map<String, Object>> refreshAll() {
try {
mcpClientManager.refreshAll();
// MCP 工具清单可能变化清空 AssistantApp ChatClient 缓存避免继续使用旧工具集
assistantApp.clearCache();
return ResponseEntity.ok(Map.of(
"success", true,
"message", "MCP 客户端连接已刷新"

73
src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java

@ -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<Document> 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<Document> 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("."));
}
}

67
src/main/java/com/wok/supportbot/document/transform/MyTokenTextSplitter.java

@ -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<Document> splitDocuments(List<Document> 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<Document> splitDocuments(List<Document> 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<Document> splitCustomized(List<Document> documents) {
TokenTextSplitter splitter = new TokenTextSplitter(
chunkConfig.getChunkSize(),
chunkConfig.getOverlap(),
chunkConfig.getMinChunkSizeChars(),
chunkConfig.getMaxNumChunks(),
chunkConfig.isKeepSeparator()
);
return splitter.apply(documents);
}
}

252
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 分块器
*
* <p>Spring AI {@link org.springframework.ai.transformer.splitter.TokenTextSplitter}
* 不支持 overlap 它的 5 参构造器与 Builder 均无该形参社区 PR #4054 已标记不向 1.x 回迁
* 本类直接继承标准基类 {@link TextSplitter}复刻 TokenTextSplitter 的全部切分语义
* 标点截断最小分块长度分块数上限分隔符处理仅把前进步长 chunkSize
* 改为 {@code chunkSize - overlap}从而让重叠真正生效
*
* <p>元数据继承{@code parent_document_id} / {@code chunk_index} / {@code total_chunks}
* {@link TextSplitter} 基类统一处理本类只负责文本切分
*
* <p>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
* <p>
* 防止标点截断overlap叠加后把前进步长压到 1 token 导致分块数爆炸
* 截断后必须仍能前进这么多 token否则放弃本次截断宁可切断句子也要保证分块数量可控
* <p>
* 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<String> splitText(String text) {
if (text == null || text.trim().isEmpty()) {
return new ArrayList<>();
}
List<Integer> tokens = getEncodedTokens(text);
List<String> 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<Integer> getEncodedTokens(String text) {
Assert.notNull(text, "Text must not be null");
return this.encoding.encode(text).boxed();
}
private String decodeTokens(List<Integer> 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);
}
}
}

36
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<List<ToolCallEvent>> EVENTS = ThreadLocal.withInitial(ArrayList::new);
/**
* 获取当前线程收集的所有工具调用事件并清空收集器
*/
public static List<ToolCallEvent> drainEvents() {
List<ToolCallEvent> events = EVENTS.get();
List<ToolCallEvent> 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 的执行入口
* <p>
* 优先 ToolContext 读取 AssistantApp 注入的事件收集器与轮次计数器
* 解决 Reactor 流式场景下 ThreadLocal 跨线程丢失的问题读取不到时回退 ThreadLocal
* ToolContext 读取 AssistantApp 注入的事件收集器与轮次计数器
* 解决 Reactor 流式场景下 ThreadLocal 跨线程丢失的问题读取不到时事件被丢弃轮次回退线程级计数
*/
@Override
@SuppressWarnings("unchecked")
public String call(String toolInput, ToolContext toolContext) {
List<ToolCallEvent> collector = EVENTS.get();
List<ToolCallEvent> collector = new ArrayList<>();
AtomicInteger rounds = null;
Map<String, Object> ctx = toolContext != null ? toolContext.getContext() : null;
if (ctx != null) {

73
src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java

@ -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<String> messageEvent(String data) {
return ServerSentEvent.<String>builder()
.event("message")
.data(data)
.build();
}
/**
* 构建工具调用开始事件
*/
public static ServerSentEvent<String> toolCallStartEvent(String toolName, String input) {
String json = String.format("{\"tool\":\"%s\",\"input\":\"%s\"}",
escapeJson(toolName), escapeJson(input));
return ServerSentEvent.<String>builder()
.event("tool_call_start")
.data(json)
.build();
}
/**
* 构建工具调用结果事件
*/
public static ServerSentEvent<String> toolCallResultEvent(String toolName, String result, long latencyMs) {
String json = String.format("{\"tool\":\"%s\",\"result\":\"%s\",\"latencyMs\":%d}",
escapeJson(toolName), escapeJson(result), latencyMs);
return ServerSentEvent.<String>builder()
.event("tool_call_result")
.data(json)
.build();
}
/**
* 构建错误事件
*/
public static ServerSentEvent<String> errorEvent(String message) {
return ServerSentEvent.<String>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");
}
}

50
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 客户端
* <p>
* 原实现用 {@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<Map> 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<Map> 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<Map> postWithTimeout(String url, HttpHeaders headers, Map<String, Object> body) {
RestTemplate restTemplate = new RestTemplate();
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
// RestTemplate 默认无超时此处依赖连接/读取超时由底层控制
// Spring Boot 3.x 中可使用 RestClient 替代以获得更好的超时支持
return restTemplate.exchange(url, HttpMethod.POST, entity, Map.class);
private Map<String, Object> postWithTimeout(String url, HttpHeaders headers, Map<String, Object> body) {
return restClient.post()
.uri(url)
.headers(h -> h.addAll(headers))
.body(body)
.retrieve()
.body(Map.class);
}
/**

34
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

13
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<Document> documents = tikaDocumentReader.readFromFile(savedFile);
// 使用 Spring AI 官方 TikaDocumentReaderget() 内部为 List.of(toDocument(...))恒定返回 1 Document
List<Document> documents = new TikaDocumentReader(new FileSystemResource(savedFile)).get();
// 剥离路径前缀文件夹上传时浏览器 filename 携带相对路径含根目录名否则标题会带目录/前缀
String sourceName = stripPath(file.getOriginalFilename());
String fileType = getFileExtension(sourceName);

100
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: 闲聊 简单对话
*
* <p>结构化输出使用 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<IntentResult> 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;
}
}
Loading…
Cancel
Save