Browse Source

优化回答规则提示词并修复SSE流式换行丢失

master
wanghanlin 1 week ago
parent
commit
b9e14a2547
  1. 2
      client/dist/chatbot-sdk.js.map
  2. 17
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  3. 29
      src/main/java/com/wok/supportbot/config/RagPromptConfig.java
  4. 9
      src/main/resources/application.yml
  5. 101
      src/main/resources/static/js/utils.js
  6. 2
      src/main/resources/static/sdk/chatbot-sdk.js.map

2
client/dist/chatbot-sdk.js.map
File diff suppressed because it is too large
View File

17
src/main/java/com/wok/supportbot/app/AssistantApp.java

@ -5,6 +5,7 @@ import com.wok.supportbot.advisor.MyLoggerAdvisor;
import com.wok.supportbot.advisor.ReReadingAdvisor; import com.wok.supportbot.advisor.ReReadingAdvisor;
import com.wok.supportbot.chatmemory.DatabaseChatMemory; import com.wok.supportbot.chatmemory.DatabaseChatMemory;
import com.wok.supportbot.config.ChatModelFactory; import com.wok.supportbot.config.ChatModelFactory;
import com.wok.supportbot.config.RagPromptConfig;
import com.wok.supportbot.rag.preretrieval.CompressionQueryRewriter; import com.wok.supportbot.rag.preretrieval.CompressionQueryRewriter;
import com.wok.supportbot.rag.preretrieval.MultiQueryExpanderRewriter; import com.wok.supportbot.rag.preretrieval.MultiQueryExpanderRewriter;
import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter; import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter;
@ -21,6 +22,7 @@ import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.document.Document; import org.springframework.ai.document.Document;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor; import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter; import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter;
@ -69,6 +71,10 @@ public class AssistantApp {
@Resource @Resource
private McpToolCallbackAdapter mcpToolCallbackAdapter; private McpToolCallbackAdapter mcpToolCallbackAdapter;
/** RAG 回答规则(保真护栏),通过 application.yml 的 knowledge.rag.answer-rules 配置 */
@Resource
private RagPromptConfig ragPromptConfig;
/** MCP 工具开关,默认启用,可通过 application.yml 的 chat.mcp.enabled 关闭 */ /** MCP 工具开关,默认启用,可通过 application.yml 的 chat.mcp.enabled 关闭 */
@Value("${chat.mcp.enabled:true}") @Value("${chat.mcp.enabled:true}")
private boolean enableMcpTools; private boolean enableMcpTools;
@ -673,10 +679,7 @@ public class AssistantApp {
return base; return base;
} }
return base + "\n\n【RAG回答硬性规则】\n" return base + "\n\n【RAG回答硬性规则】\n"
+ "- 只能依据下方【知识库资料】回答用户问题,不要调用模型自身记忆补充资料之外的内容。\n"
+ "- 回答诗词、制度、流程等具体内容时,必须从【知识库资料】中逐字引用或转述,不得使用未检索到的诗句、篇名、作者或步骤。\n"
+ "- 如果【知识库资料】中没有用户要求的内容,请明确说明未在当前知识库中检索到足够资料,并提示补充或调整知识库。\n"
+ "- 引用来源应能支撑回答内容;不要让回答内容和下方引用来源不一致。\n\n"
+ ragPromptConfig.getAnswerRules() + "\n\n"
+ "【知识库资料】\n" + context; + "【知识库资料】\n" + context;
} }
@ -714,6 +717,11 @@ public class AssistantApp {
private Advisor buildRetrievalAdvisor(int topK, List<Long> categoryIds) { private Advisor buildRetrievalAdvisor(int topK, List<Long> categoryIds) {
Filter.Expression filterExpression = buildCategoryFilterExpression(categoryIds); Filter.Expression filterExpression = buildCategoryFilterExpression(categoryIds);
// 自定义模板替换 Spring AI 默认模板注入与 MULTI_QUERY 路径一致的 RAG 回答规则
PromptTemplate qaTemplate = new PromptTemplate(
"【RAG回答硬性规则】\n" + ragPromptConfig.getAnswerRules() + "\n\n"
+ "【知识库资料】\n---------------------\n{context}\n---------------------\n\n"
+ "用户问题:{query}\n\n请按上述规则回答:");
return RetrievalAugmentationAdvisor.builder() return RetrievalAugmentationAdvisor.builder()
.documentRetriever(new VectorStoreDocumentRetriever( .documentRetriever(new VectorStoreDocumentRetriever(
pgVectorVectorStore, pgVectorVectorStore,
@ -722,6 +730,7 @@ public class AssistantApp {
() -> filterExpression)) () -> filterExpression))
.queryAugmenter(ContextualQueryAugmenter.builder() .queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(false) .allowEmptyContext(false)
.promptTemplate(qaTemplate)
.build()) .build())
.build(); .build();
} }

29
src/main/java/com/wok/supportbot/config/RagPromptConfig.java

@ -0,0 +1,29 @@
package com.wok.supportbot.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* RAG 提示词配置
* 通过 application.yml knowledge.rag.answer-rules 动态调整 RAG 回答规则保真护栏无需改代码重新编译
*/
@Component
@ConfigurationProperties(prefix = "knowledge.rag")
@EnableConfigurationProperties(RagPromptConfig.class)
@Data
public class RagPromptConfig {
/**
* RAG 回答规则保真护栏注入 system 提示词与 ContextualQueryAugmenter 自定义模板
* 默认值在忠于资料不编造基础上强调覆盖全部相关要点不遗漏步骤
* yml 未配置时使用此默认值yml 配置后覆盖
*/
private String answerRules = """
- 完整性优先必须覆盖下方知识库资料中所有与用户问题相关的要点按资料本身的条理分点结构化输出不得只摘取其中一两段或几条而遗漏其余相关的步骤条件注意事项话术示例多段资料从不同角度回答同一问题时需综合全部相关内容作答
- 忠于资料只能依据下方知识库资料回答不得调用模型自身记忆补充资料之外的内容资料中包含流程步骤话术制度等具体内容时优先逐字引用或完整转述不得使用未检索到的诗句篇名作者或步骤
- 来源对应回答中每个要点都应能在知识库资料中找到对应来源不得让回答内容与下方引用来源不一致也不得将多步流程压缩到只剩一两条
- 资料不足如果知识库资料中没有用户要求的内容请明确说明未在当前知识库中检索到足够资料并提示补充或调整知识库
- 排版规范使用 Markdown 结构化输出分点用有序或无序列表`1.` / `-`不同要点之间空一行分段涉及多来源或多角度时可用二级标题`##`区分严禁把所有内容挤在一段内""";
}

9
src/main/resources/application.yml

@ -64,6 +64,15 @@ knowledge:
faq: faq:
# FAQ 语义匹配阈值(0-1),低于此值自动降级到 RAG 检索 # FAQ 语义匹配阈值(0-1),低于此值自动降级到 RAG 检索
semantic-threshold: 0.85 semantic-threshold: 0.85
rag:
# RAG 回答规则(保真护栏):所有角色统一生效,注入到 RAG system 提示词与 ContextualQueryAugmenter 模板。
# 调整后无需改代码,重启即生效。规则需引用「下方【知识库资料】」这一占位块。
answer-rules: |-
- 完整性优先:必须覆盖下方【知识库资料】中所有与用户问题相关的要点,按资料本身的条理分点结构化输出;不得只摘取其中一两段或几条而遗漏其余相关的步骤、条件、注意事项、话术示例。多段资料从不同角度回答同一问题时,需综合全部相关内容作答。
- 忠于资料:只能依据下方【知识库资料】回答,不得调用模型自身记忆补充资料之外的内容;资料中包含流程、步骤、话术、制度等具体内容时,优先逐字引用或完整转述,不得使用未检索到的诗句、篇名、作者或步骤。
- 来源对应:回答中每个要点都应能在【知识库资料】中找到对应来源;不得让回答内容与下方引用来源不一致,也不得将多步流程压缩到只剩一两条。
- 资料不足:如果【知识库资料】中没有用户要求的内容,请明确说明未在当前知识库中检索到足够资料,并提示补充或调整知识库。
- 排版规范:使用 Markdown 结构化输出——分点用有序或无序列表(`1.` / `-`),不同要点之间空一行分段,涉及多来源或多角度时可用二级标题(`##`)区分;严禁把所有内容挤在一段内。
storage: storage:
# 上传文件本地存储根路径(Windows: D:/uploads, Linux: /data/uploads) # 上传文件本地存储根路径(Windows: D:/uploads, Linux: /data/uploads)
# 各环境可在 application-{env}.yml 中覆盖 # 各环境可在 application-{env}.yml 中覆盖

101
src/main/resources/static/js/utils.js

@ -133,22 +133,44 @@ export async function readSSEStream(url, onChunk, onDone, headers) {
const reader = res.body.getReader() const reader = res.body.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
let buffer = '' let buffer = ''
// SSE 规范:同一事件内的多行 data: 字段用 \n 拼接,事件间用空行分隔
let eventDataLines = []
const flushEvent = () => {
if (eventDataLines.length === 0) return
const text = eventDataLines.join('\n')
eventDataLines = []
if (text === '[DONE]') return
// 空事件视为 LLM 流式输出的换行符(Spring 将 "\n" 编码为单条空 data: 事件)
onChunk(text || '\n')
}
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (done) break
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n') const lines = buffer.split('\n')
buffer = lines.pop() || '' buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data:')) {
const data = line.slice(5).trim()
if (data && data !== '[DONE]') onChunk(data)
} else if (line.trim() && !line.startsWith(':')) {
// Flux<String> 模式,非 SSE 标准格式,直接作为内容
onChunk(line)
for (let line of lines) {
// 兼容 \r\n 行结束符
if (line.endsWith('\r')) line = line.slice(0, -1)
if (line === '') {
// 空行 = SSE 事件边界
flushEvent()
} else if (line.startsWith('data:')) {
// 累积 data 字段;仅剥离 data: 后的一个可选空格,保留 markdown 列表缩进
let data = line.slice(5)
if (data.startsWith(' ')) data = data.slice(1)
eventDataLines.push(data)
} else if (!line.startsWith(':')) {
// Flux<String> 模式(非标准 SSE),先把已累积的 SSE 事件 flush 再处理
flushEvent()
if (line.trim()) onChunk(line)
} }
} }
} }
// 流结束,flush 末尾未以空行收尾的事件
flushEvent()
if (onDone) onDone() if (onDone) onDone()
} }
@ -172,44 +194,63 @@ export async function readSSEStreamWithEvents(url, handlers, headers) {
const reader = res.body.getReader() const reader = res.body.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
let buffer = '' let buffer = ''
let currentEvent = 'message' // 默认事件类型
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
let currentEvent = 'message'
// SSE 规范:同一事件内的多行 data: 字段用 \n 拼接
let eventDataLines = []
for (const line of lines) {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim()
} else if (line.startsWith('data:')) {
const data = line.slice(5).trim()
if (!data || data === '[DONE]') continue
const flushEvent = () => {
if (eventDataLines.length === 0) return
const ev = currentEvent
currentEvent = 'message'
const raw = eventDataLines.join('\n')
eventDataLines = []
if (raw === '[DONE]') return
// 空事件视为换行符(仅 message 事件);JSON 事件(工具调用等)空内容不应出现
const text = raw || '\n'
switch (currentEvent) {
switch (ev) {
case 'tool_call_start': case 'tool_call_start':
if (onToolCallStart) onToolCallStart(JSON.parse(data))
if (onToolCallStart) onToolCallStart(JSON.parse(text))
break break
case 'tool_call_result': case 'tool_call_result':
if (onToolCallResult) onToolCallResult(JSON.parse(data))
if (onToolCallResult) onToolCallResult(JSON.parse(text))
break break
case 'error': case 'error':
if (onError) onError(JSON.parse(data))
if (onError) onError(JSON.parse(text))
break break
case 'message': case 'message':
default: default:
if (onMessage) onMessage(data)
if (onMessage) onMessage(text)
break break
} }
currentEvent = 'message' // 重置为默认
} else if (line.trim() && !line.startsWith(':')) {
// Flux<String> 模式,非 SSE 标准格式
if (onMessage) onMessage(line)
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (let line of lines) {
if (line.endsWith('\r')) line = line.slice(0, -1)
if (line === '') {
// 空行 = SSE 事件边界
flushEvent()
} else if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim()
} else if (line.startsWith('data:')) {
let data = line.slice(5)
if (data.startsWith(' ')) data = data.slice(1)
eventDataLines.push(data)
} else if (!line.startsWith(':')) {
// Flux<String> 模式(非标准 SSE)
flushEvent()
if (line.trim() && onMessage) onMessage(line)
} }
} }
} }
flushEvent()
if (onDone) onDone() if (onDone) onDone()
} }

2
src/main/resources/static/sdk/chatbot-sdk.js.map
File diff suppressed because it is too large
View File

Loading…
Cancel
Save