feat: Revamp conversation configuration management and enhance localization

- Removed outdated DEVELOPMENT.md, consolidating development instructions into relevant documentation.
- Updated conversation configuration to include new parameters such as max rounds, embedding thresholds, and rewrite prompts.
- Enhanced UI components to support new conversation settings, including AgentSettings.vue and related views.
- Improved localization in English, Russian, and Chinese for new conversation configuration fields and UI elements.
- Implemented logic to apply tenant-specific conversation settings, ensuring defaults are respected.
This commit is contained in:
wizardchen
2025-11-18 23:18:43 +08:00
parent 60240ee337
commit ddf6b4e748
36 changed files with 2941 additions and 1914 deletions
-267
View File
@@ -1,267 +0,0 @@
# WeKnora 开发环境快速入门
> 无需重新构建 Docker 镜像,实现秒级代码热更新!
## 🚀 快速开始(3 种方式)
### 方式 1:推荐 - 使用 Make 命令
**终端 1** - 启动基础设施:
```bash
make dev-start
```
**终端 2** - 启动后端:
```bash
make dev-app
```
**终端 3** - 启动前端:
```bash
make dev-frontend
```
访问 http://localhost:5173 开始开发!
### 方式 2:使用脚本命令
```bash
# 终端 1
./scripts/dev.sh start
# 终端 2
./scripts/dev.sh app
# 终端 3
./scripts/dev.sh frontend
```
### 方式 3:一键启动(交互式)
```bash
./scripts/quick-dev.sh
```
按照提示选择是否启动后端和前端。
## 📋 常用命令
| 命令 | 说明 |
|------|------|
| `make dev-start` | 启动基础设施(postgres, redis, minio 等) |
| `make dev-stop` | 停止所有服务 |
| `make dev-restart` | 重启所有服务 |
| `make dev-status` | 查看服务状态 |
| `make dev-logs` | 查看服务日志 |
| `make dev-app` | 启动后端(需先启动基础设施) |
| `make dev-frontend` | 启动前端(需先启动基础设施) |
## 🎯 访问地址
| 服务 | 地址 |
|------|------|
| 前端开发服务器 | http://localhost:5173 |
| 后端 API | http://localhost:8080 |
| PostgreSQL | localhost:5432 |
| Redis | localhost:6379 |
| MinIO Console | http://localhost:9001 |
| Neo4j Browser | http://localhost:7474 |
| Jaeger UI | http://localhost:16686 |
## 💡 开发工作流对比
### ❌ 旧方式(慢)
```bash
# 每次修改代码后
sh scripts/build_images.sh -p # 重新构建镜像(2-5分钟)
sh scripts/start_all.sh --no-pull # 重启容器
```
### ✅ 新方式(快)
```bash
# 首次启动(只需一次)
make dev-start
# 修改后端代码
# → Ctrl+C 停止 → make dev-app 重启(5-10秒)
# 修改前端代码
# → 自动热重载(无需任何操作)
```
**时间对比**
- 旧方式:每次修改 2-5 分钟
- 新方式首次:1-2 分钟
- 新方式后续:
- 后端修改:5-10 秒
- 前端修改:实时热重载
## 🔥 进阶:后端热重载
安装 Air 实现后端代码修改后自动重启:
```bash
# 安装 Air
go install github.com/cosmtrek/air@latest
# 确保 $GOPATH/bin 在 PATH 中
export PATH=$PATH:$(go env GOPATH)/bin
# 使用 Air 启动(自动检测)
make dev-app
# 或直接运行
air
```
修改 Go 代码后,Air 会自动重新编译和重启,无需手动操作!
## 📝 开发场景示例
### 场景 1:只修改前端
```bash
# 一次性启动基础设施
make dev-start
# 启动前端,修改代码自动热重载
make dev-frontend
```
### 场景 2:只修改后端
```bash
# 启动基础设施
make dev-start
# 启动后端(如果安装了 Air,支持热重载)
make dev-app
```
### 场景 3:同时开发前后端
```bash
# 终端 1:启动基础设施
make dev-start
# 终端 2:启动后端
make dev-app
# 终端 3:启动前端
make dev-frontend
```
## 🛠 VS Code 调试配置
创建 `.vscode/launch.json`
```json
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch WeKnora Server",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/server",
"env": {
"DB_HOST": "localhost",
"DB_PORT": "5432",
"DOCREADER_ADDR": "localhost:50051",
"MINIO_ENDPOINT": "localhost:9000",
"REDIS_ADDR": "localhost:6379",
"OTEL_EXPORTER_OTLP_ENDPOINT": "localhost:4317",
"NEO4J_URI": "bolt://localhost:7687"
}
}
]
}
```
然后按 F5 开始调试!
## 🐛 故障排除
### 问题:启动后端时报错连接不到数据库
**解决**:确保先运行了 `make dev-start` 并等待 30 秒让服务完全启动。
查看服务状态:
```bash
make dev-status
```
### 问题:前端访问 API 时报 CORS 错误
**解决**:前端已配置代理,确保:
1. 后端运行在 `localhost:8080`
2. 前端运行在 `localhost:5173`
3. 查看 `frontend/vite.config.ts` 的代理配置
### 问题:端口被占用
**解决**:修改 `.env` 文件中的端口配置:
```bash
# .env
DB_PORT=5433 # 默认 5432
REDIS_PORT=6380 # 默认 6379
MINIO_PORT=9001 # 默认 9000
```
然后重启服务:
```bash
make dev-restart
```
### 问题:DocReader 需要重新构建
**解决**DocReader 仍使用 Docker 镜像,需要重新构建:
```bash
sh scripts/build_images.sh -d
make dev-restart
```
## 🎯 生产环境部署
开发完成后需要部署时:
```bash
# 构建所有镜像
sh scripts/build_images.sh
# 或只构建特定镜像
sh scripts/build_images.sh -p # 后端
sh scripts/build_images.sh -f # 前端
sh scripts/build_images.sh -d # DocReader
# 启动生产环境
sh scripts/start_all.sh
```
## 📚 更多文档
- [完整开发指南](docs/开发指南.md)
- [API 文档](docs/API.md)
- [Agent 开发](docs/AGENT.md)
## 💪 最佳实践
1. **日常开发**:使用 `make dev-*` 命令,享受快速迭代
2. **提交前测试**:使用完整 Docker 环境测试集成功能
3. **生产部署**:使用构建镜像的方式部署
## 🎉 总结
使用开发模式,你可以:
**不重新构建镜像** - 直接在本地运行代码
**秒级热更新** - 前端自动热重载,后端快速重启
**完整调试支持** - IDE 断点调试,实时查看变量
**节省时间** - 每次修改从 2-5 分钟降至 5-10 秒
祝你开发愉快!🚀
-527
View File
@@ -1,527 +0,0 @@
# WeKnora Agent Mode 文档
## 概述
WeKnora Agent Mode 是基于 ReAct (Reasoning + Acting) 框架实现的智能代理系统,能够通过工具调用和迭代推理来回答复杂问题。
### 核心特性
- **ReAct 框架**: Thought → Action → Observation 循环
- **工具系统**: 7个知识库相关工具
- **可选规划**: 可在执行前生成任务计划
- **事件追踪**: 完整的执行过程可视化
- **灵活配置**: 支持多种参数调整
## 架构设计
### Agent 工作流程
```
用户查询
[可选] 生成执行计划
┌─────────────────┐
│ ReAct 循环开始 │
├─────────────────┤
│ 1. Think (思考) │ → LLM 分析当前状态
│ 2. Act (行动) │ → 调用工具获取信息
│ 3. Observe (观察)│ → 处理工具返回结果
│ [可选] Reflect │ → 反思当前步骤
│ 4. Decide (决策)│ → 继续或结束?
└─────────────────┘
生成最终答案
```
### 组件结构
```
internal/agent/
├── engine.go # Agent 执行引擎
├── prompts.go # System prompts
└── tools/ # 工具系统
├── tool.go
├── registry.go
├── knowledge_search.go
├── multi_kb_search.go
├── list_knowledge_bases.go
├── get_chunk_detail.go
├── get_related_chunks.go
├── query_knowledge_graph.go
└── get_document_info.go
```
## 配置说明
### Agent 配置结构
```go
type AgentConfig struct {
Enabled bool // 是否启用 Agent 模式
EnablePlanning bool // 是否先规划
MaxIterations int // 最大迭代次数 (1-20)
ReflectionEnabled bool // 是否启用反思
AllowedTools []string // 允许的工具列表
Temperature float64 // LLM 温度 (0-2)
ThinkingModelID string // 推理模型 ID
KnowledgeBases []string // 可访问的知识库 ID
}
```
### 全局配置 (config.yaml)
```yaml
agent:
enabled: true
default_max_iterations: 5
default_temperature: 0.7
reflection_enabled: false
default_tools:
- knowledge_search
- multi_kb_search
- list_knowledge_bases
- get_chunk_detail
- get_related_chunks
- query_knowledge_graph
- get_document_info
```
## 可用工具
### 1. knowledge_search
搜索指定知识库中的相关内容。
**参数:**
- `knowledge_base_id` (必需): 知识库ID
- `query` (必需): 搜索查询内容
- `top_k` (可选): 返回结果数量,默认5
**示例:**
```
knowledge_search(knowledge_base_id="kb123", query="什么是RAG", top_k=5)
```
### 2. multi_kb_search
在多个知识库中智能搜索,自动选择最相关的知识库。
**参数:**
- `query` (必需): 搜索查询内容
- `top_k` (可选): 每个知识库返回的结果数量,默认3
**适用场景:** 跨知识库查询,不确定信息在哪个知识库中
### 3. list_knowledge_bases
列出当前可访问的所有知识库。
**参数:**
**用途:** 了解有哪些知识库可以搜索
### 4. get_chunk_detail
获取指定chunk的详细信息,包括完整内容、来源文档。
**参数:**
- `chunk_id` (必需): Chunk ID
**用途:** 当搜索结果不够详细时获取完整内容
### 5. get_related_chunks
获取与指定chunk相关的其他chunks。
**参数:**
- `chunk_id` (必需): Chunk ID
- `relation_type` (可选): "sequential" (顺序) 或 "semantic" (语义)
- `limit` (可选): 返回数量,默认5
**用途:** 发现相关信息,扩展上下文
### 6. query_knowledge_graph
查询知识图谱中的实体和关系。
**参数:**
- `knowledge_base_id` (必需): 知识库ID
- `query` (必需): 查询内容(实体名称或查询文本)
**前提:** 知识库已配置知识图谱抽取
### 7. get_document_info
获取文档的元数据信息。
**参数:**
- `knowledge_id` (必需): 文档/知识ID
**用途:** 了解文档的整体情况
## 使用指南
### 创建 Agent Session
```bash
POST /api/v1/sessions
{
"knowledge_base_id": "kb123",
"session_strategy": {
"summary_model_id": "model123",
...
},
"agent_config": {
"enabled": true,
"enable_planning": false,
"max_iterations": 5,
"reflection_enabled": false,
"allowed_tools": [
"knowledge_search",
"multi_kb_search",
"list_knowledge_bases"
],
"temperature": 0.7,
"knowledge_bases": ["kb123", "kb456"]
}
}
```
### 发起 Agent 查询
```bash
POST /api/v1/sessions/{session_id}/agent-qa
{
"query": "请解释RAG技术的工作原理"
}
```
### 响应格式
Agent 会返回 SSE (Server-Sent Events) 流式响应:
1. **知识引用** (可选)
```json
{
"response_type": "references",
"knowledge_references": [...]
}
```
2. **Agent 思考过程** (可选,用于调试)
```json
{
"response_type": "agent_thought",
"content": "我需要先搜索相关知识..."
}
```
3. **工具调用** (可选)
```json
{
"response_type": "agent_action",
"content": "调用工具: knowledge_search"
}
```
4. **最终答案**
```json
{
"response_type": "answer",
"content": "RAG技术的工作原理是...",
"done": true
}
```
## 最佳实践
### 1. 工具选择策略
- **已知知识库**: 使用 `knowledge_search`
- **不确定位置**: 使用 `multi_kb_search`
- **需要上下文**: 使用 `get_related_chunks`
- **探索阶段**: 先用 `list_knowledge_bases`
### 2. 迭代次数设置
- **简单查询**: 3-5 次
- **复杂问题**: 5-10 次
- **探索性任务**: 10-15 次
- **最大限制**: 20 次 (防止无限循环)
### 3. 温度参数
- **精确查询**: 0.3-0.5 (更确定性)
- **创造性任务**: 0.7-1.0 (更多样性)
- **默认推荐**: 0.7
### 4. Planning 启用时机
- **复杂多步骤任务**: 启用
- **简单直接查询**: 禁用
- **探索性问题**: 启用
### 5. Reflection 启用时机
- **关键任务**: 启用 (提高准确性)
- **快速响应**: 禁用 (减少延迟)
- **默认**: 禁用
## 工作原理详解
### ReAct Prompt Template
```
你是一个智能知识库助手。你的任务是通过使用提供的工具来回答用户问题。
工作流程:
1. 分析用户问题,确定需要什么信息
2. 使用合适的工具获取信息(可以多次调用不同工具)
3. 基于获取的信息,提供准确、完整的答案
注意事项:
- 优先使用 multi_kb_search 进行跨知识库搜索
- 如果需要特定知识库,先用 list_knowledge_bases 查看可用知识库
- 如果搜索结果不够详细,使用 get_chunk_detail 获取完整内容
- 使用 get_related_chunks 发现相关信息
- 如果涉及实体关系,使用 query_knowledge_graph
- 引用信息时,说明来源(chunk_id 或 knowledge_base
- 如果找不到相关信息,诚实告知用户
当前可访问的知识库:
{knowledge_bases}
{plan_context}
```
### 工具调用解析
Agent 会尝试从 LLM 输出中解析工具调用,支持的格式:
```
tool_name(arg1="value1", arg2="value2")
```
例如:
```
knowledge_search(knowledge_base_id="kb123", query="RAG技术")
```
### 循环控制
Agent 会在以下情况停止:
1. LLM 输出包含 "最终答案" 或 "Final Answer"
2. 达到最大迭代次数
3. 工具调用失败且无法恢复
4. 检测到重复循环模式
## 事件追踪
Agent 执行过程中会发送以下事件 (通过 Event Bus):
### agent.plan
```go
type AgentPlanData struct {
Query string
Plan []string
Duration int64
}
```
### agent.step
```go
type AgentStepData struct {
Iteration int
Thought string
ToolCalls []ToolCall
Duration int64
}
```
### agent.tool
```go
type AgentActionData struct {
Iteration int
ToolName string
ToolInput map[string]interface{}
ToolOutput string
Success bool
Error string
Duration int64
}
```
## 示例场景
### 场景 1: 简单知识查询
**用户问题**: "什么是 RAG?"
**Agent 执行流程**:
1. **Think**: 需要搜索 RAG 相关知识
2. **Act**: `multi_kb_search(query="RAG")`
3. **Observe**: 获得3条相关结果
4. **Think**: 信息足够,可以回答
5. **Final Answer**: 基于搜索结果生成答案
**迭代次数**: 2
### 场景 2: 跨文档关联查询
**用户问题**: "比较 RAG 和微调的优缺点"
**Agent 执行流程**:
1. **Think**: 需要分别查找 RAG 和微调的信息
2. **Act**: `multi_kb_search(query="RAG优缺点")`
3. **Observe**: 获得 RAG 相关信息
4. **Think**: 还需要微调的信息
5. **Act**: `multi_kb_search(query="模型微调优缺点")`
6. **Observe**: 获得微调相关信息
7. **Think**: 信息完整,进行对比
8. **Final Answer**: 综合两者信息生成对比答案
**迭代次数**: 4
### 场景 3: 深入探索
**用户问题**: "详细解释向量数据库的工作原理"
**Agent 执行流程**:
1. **Think**: 先查看有哪些知识库
2. **Act**: `list_knowledge_bases()`
3. **Observe**: 发现有"数据库技术"知识库
4. **Think**: 在该知识库中搜索
5. **Act**: `knowledge_search(knowledge_base_id="db_kb", query="向量数据库")`
6. **Observe**: 找到相关 chunk
7. **Think**: 需要更多细节
8. **Act**: `get_chunk_detail(chunk_id="chunk123")`
9. **Observe**: 获得完整内容
10. **Think**: 查看相关内容
11. **Act**: `get_related_chunks(chunk_id="chunk123", relation_type="sequential")`
12. **Observe**: 获得上下文
13. **Final Answer**: 基于详细信息生成深入解释
**迭代次数**: 7
## 常见问题
### Q: Agent 和普通 RAG 有什么区别?
**A**:
- **普通 RAG**: 一次性检索 → 生成答案
- **Agent**: 可以多次迭代,根据中间结果调整策略,调用不同工具
### Q: 什么时候使用 Agent 模式?
**A**:
- 需要多步推理的复杂问题
- 需要在多个知识库中查找信息
- 需要深入探索和关联分析
- 问题模糊,需要澄清和逐步细化
### Q: Agent 模式的成本如何?
**A**:
- **Token 消耗**: 比普通 RAG 高 (多次 LLM 调用)
- **响应时间**: 较长 (迭代执行)
- **准确性**: 通常更高 (多步验证)
### Q: 如何优化 Agent 性能?
**A**:
1. 合理设置 `max_iterations` (避免过多)
2. 选择必要的工具 (`allowed_tools`)
3. 禁用不需要的功能 (planning, reflection)
4. 使用更快的模型作为 thinking_model
### Q: 工具调用失败怎么办?
**A**: Agent 会:
1. 在 Observation 中记录错误
2. 尝试使用其他工具
3. 如果多次失败,会在最终答案中说明
## 技术限制与注意事项
### 当前限制
1. **工具调用解析**: 使用简单的模式匹配,可能无法处理复杂格式
2. **Function Calling**: 当前使用 prompt-based 方式,未来可升级为原生 function calling
3. **并行工具调用**: 当前不支持,工具按顺序执行
4. **图谱查询**: 当前使用 hybrid search,完整图谱功能开发中
### 未来改进
- [ ] 支持 LLM 原生 function calling (GPT-4, Claude 等)
- [ ] 并行工具执行
- [ ] 更智能的循环检测
- [ ] 工具结果缓存
- [ ] 自定义工具注册
- [ ] Agent 执行可视化 UI
## 开发指南
### 创建自定义工具
```go
package tools
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
type CustomTool struct {
BaseTool
// 添加依赖
}
func NewCustomTool() *CustomTool {
return &CustomTool{
BaseTool: NewBaseTool(
"custom_tool",
"工具描述",
),
}
}
func (t *CustomTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"param1": map[string]interface{}{
"type": "string",
"description": "参数描述",
},
},
"required": []string{"param1"},
}
}
func (t *CustomTool) Execute(ctx context.Context, args map[string]interface{}) (*types.ToolResult, error) {
// 实现工具逻辑
param1 := args["param1"].(string)
// 执行操作
result := doSomething(param1)
return &types.ToolResult{
Success: true,
Output: result,
Data: map[string]interface{}{},
}, nil
}
```
### 注册自定义工具
`agent_service.go``registerTools` 方法中添加:
```go
case "custom_tool":
registry.RegisterTool(tools.NewCustomTool())
```
## 总结
WeKnora Agent Mode 提供了强大的迭代推理能力,适用于复杂的知识查询场景。通过合理配置和工具选择,可以在准确性和效率之间找到最佳平衡点。
如有问题或建议,请提交 Issue 或 Pull Request。
-401
View File
@@ -1,401 +0,0 @@
# LLM Context Manager - 大模型上下文管理器
## 概述
LLM Context Manager 是一个专门用于管理大模型对话上下文的组件,它**独立于消息存储系统**,专注于管理发送给大模型的上下文窗口。
### 关键特性
1. **独立管理**: 与消息的数据库存储分离,专门管理发送给 LLM 的上下文
2. **Token 限制管理**: 自动监控和管理上下文的 Token 数量
3. **智能压缩**: 当上下文超出限制时,自动应用压缩策略
4. **按 Session 维护**: 每个会话独立管理其上下文
5. **灵活配置**: 支持会话级别的自定义配置
## 为什么需要 Context Manager
### 问题背景
- **消息存储** vs **LLM 上下文**:
- 消息存储:完整保存所有对话历史,用于展示和审计
- LLM 上下文:有 Token 限制,需要精简管理
- **Token 限制**: 不同模型有不同的上下文窗口限制(如 4K, 8K, 16K tokens
- **性能优化**: 过长的上下文会增加推理时间和成本
### 解决方案
Context Manager 提供:
- 自动管理上下文窗口大小
- 智能压缩历史消息
- 保留最重要的上下文信息
- 与消息存储解耦
## 架构设计
```
┌─────────────────────────────────────────────────────────────┐
│ Session Service │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Message Repo │ │ Context Manager │ │
│ │ (Complete │ │ (LLM Context │ │
│ │ History) │ │ Window) │ │
│ └──────────────────┘ └──────────────────┘ │
│ │ │ │
│ │ Save all messages │ Manage LLM │
│ │ for display │ context │
│ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Database │ │ In-Memory │ │
│ │ (Persistent) │ │ (Session-based) │ │
│ └──────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## 压缩策略
### 1. 滑动窗口策略 (Sliding Window)
保留最近的 N 条消息,丢弃更早的消息。
**优点:**
- 简单高效
- 不需要额外的 LLM 调用
- 适合短期对话
**配置示例:**
```json
{
"enabled": true,
"max_tokens": 8192,
"compression_strategy": "sliding_window",
"recent_message_count": 20
}
```
**工作原理:**
```
原始消息: [system, msg1, msg2, msg3, ..., msg18, msg19, msg20, msg21, msg22]
保留最近20条消息
压缩结果: [system, msg3, msg4, ..., msg20, msg21, msg22]
└─────┘ └──────────────────────────────────┘
保留系统消息 保留最近的20条消息
```
### 2. 智能压缩策略 (Smart Compression)
使用 LLM 总结旧消息,保留最近消息的完整内容。
**优点:**
- 保留历史关键信息
- 更好的上下文连贯性
- 适合长期对话
**配置示例:**
```json
{
"enabled": true,
"max_tokens": 8192,
"compression_strategy": "smart",
"recent_message_count": 10
}
```
**工作原理:**
```
原始消息: [system, msg1, msg2, ..., msg15, msg16, msg17, msg18, msg19, msg20]
└───────────────┘ └─────────────────────────────┘
旧消息(总结) 最近消息(保留)
压缩结果: [system, summary, msg16, msg17, msg18, msg19, msg20]
└─────┘ └──────┘ └──────────────────────────────┘
系统消息 总结 保留最近的10条消息(完整)
```
## 使用方法
### 1. 默认配置
如果不设置 `context_config`,系统使用默认配置:
- 最大 Token: 8192
- 策略: 滑动窗口
- 保留消息数: 20
```go
// 不需要特别配置,自动使用默认设置
session := &types.Session{
TenantID: tenantID,
KnowledgeBaseID: kbID,
// context_config 为 nil,使用默认配置
}
```
### 2. 自定义配置 - 滑动窗口
```go
session := &types.Session{
TenantID: tenantID,
KnowledgeBaseID: kbID,
ContextConfig: &types.ContextConfig{
Enabled: true,
MaxTokens: 16384, // GPT-4 的上下文窗口
CompressionStrategy: types.ContextCompressionSlidingWindow,
RecentMessageCount: 30, // 保留最近30条消息
},
}
```
### 3. 自定义配置 - 智能压缩
```go
session := &types.Session{
TenantID: tenantID,
KnowledgeBaseID: kbID,
SummaryModelID: "gpt-4", // 用于总结的模型
ContextConfig: &types.ContextConfig{
Enabled: true,
MaxTokens: 8192,
CompressionStrategy: types.ContextCompressionSmart,
RecentMessageCount: 15, // 保留最近15条完整消息
},
}
```
### 4. 通过 API 创建/更新会话
```bash
# 创建带上下文配置的会话
curl -X POST http://localhost:8080/api/v1/sessions \
-H "Content-Type: application/json" \
-H "X-Tenant-ID: 1" \
-d '{
"knowledge_base_id": "kb-123",
"context_config": {
"enabled": true,
"max_tokens": 8192,
"compression_strategy": "sliding_window",
"recent_message_count": 20
}
}'
```
## 工作流程
### 对话流程
```
1. 用户发送消息
2. 保存消息到数据库 (Message Repo)
3. 添加消息到上下文管理器 (Context Manager)
4. Context Manager 检查 Token 限制
5. 如果超出限制,应用压缩策略
6. 获取压缩后的上下文
7. 发送给 LLM
8. 保存响应到数据库
9. 添加响应到上下文管理器
```
### 代码示例
```go
// 在 AgentQA 中的使用
func (s *sessionService) AgentQA(ctx context.Context, sessionID, query string, assistantMessageID string) (
[]*types.SearchResult, <-chan types.StreamResponse, error,
) {
// 1. 获取会话配置
session, err := s.sessionRepo.Get(ctx, tenantID, sessionID)
// 2. 从上下文管理器获取 LLM 上下文(自动应用压缩)
history, err := s.getContextForSession(ctx, session, sessionID)
// history 是压缩后的,适合发送给 LLM 的消息列表
// 3. 执行 Agent
eventChan, err := engine.ExecuteStreamWithHistory(ctx, query, history)
// 4. 保存新消息后,添加到上下文管理器
// s.AddMessageToContext(ctx, session, sessionID, newMessage)
return searchResults, responseChan, nil
}
```
## 配置参数说明
### ContextConfig 字段
| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| `enabled` | bool | 是否启用上下文管理 | true |
| `max_tokens` | int | 最大 Token 数 | 8192 |
| `compression_strategy` | string | 压缩策略: "sliding_window" 或 "smart" | "sliding_window" |
| `recent_message_count` | int | 保留的最近消息数 | 20 (sliding_window) <br> 10 (smart) |
### 推荐配置
#### 短期对话(客服、问答)
```json
{
"enabled": true,
"max_tokens": 4096,
"compression_strategy": "sliding_window",
"recent_message_count": 15
}
```
#### 长期对话(咨询、助手)
```json
{
"enabled": true,
"max_tokens": 8192,
"compression_strategy": "smart",
"recent_message_count": 10
}
```
#### 高端模型(GPT-4 Turbo, Claude
```json
{
"enabled": true,
"max_tokens": 16384,
"compression_strategy": "smart",
"recent_message_count": 20
}
```
## 监控和调试
### 查看上下文统计
```go
// 获取上下文统计信息
stats, err := contextManager.GetContextStats(ctx, sessionID)
if err == nil {
log.Printf("Session %s context stats:", sessionID)
log.Printf(" Messages: %d", stats.MessageCount)
log.Printf(" Tokens: ~%d", stats.TokenCount)
log.Printf(" Compressed: %v", stats.IsCompressed)
log.Printf(" Original messages: %d", stats.OriginalMessageCount)
}
```
### 日志示例
```
INFO Using custom context config for session abc123: strategy=smart, max_tokens=8192, recent_count=10
INFO Context exceeds max tokens (9500 > 8192), applying compression
INFO Summarizing 15 old messages
INFO Successfully summarized 15 messages
INFO Smart compression: 25 -> 11 messages (system: 1, compressed: 1, recent: 10)
INFO LLM context stats for session abc123: messages=11, tokens=~7800, compressed=true
```
## 与消息存储的区别
| 特性 | 消息存储 (Message Repo) | 上下文管理器 (Context Manager) |
|------|------------------------|------------------------------|
| 目的 | 完整保存对话历史 | 管理 LLM 输入上下文 |
| 存储 | 数据库(持久化) | 内存(会话级别) |
| 内容 | 所有消息(完整) | 压缩后的消息 |
| 大小限制 | 无限制 | 受 Token 限制 |
| 使用场景 | 展示历史、审计 | LLM 推理输入 |
| 生命周期 | 永久保存 | 会话期间 |
## 最佳实践
1. **选择合适的策略**:
- 短对话 → 滑动窗口(性能更好)
- 长对话 → 智能压缩(保留更多上下文)
2. **配置 Token 限制**:
- 设置为模型上下文窗口的 70-80%
- 为响应留出足够空间
3. **调整保留消息数**:
- 滑动窗口: 15-30 条消息
- 智能压缩: 8-15 条最近消息
4. **监控压缩效果**:
- 定期检查 Token 使用情况
- 观察压缩是否影响对话质量
5. **性能考虑**:
- 智能压缩会额外调用 LLM(有成本)
- 滑动窗口无额外开销
## 数据库迁移
运行以下迁移脚本添加 `context_config` 字段:
### MySQL
```bash
mysql -u root -p your_database < migrations/mysql/06-add-context-config-to-sessions.sql
```
### ParadeDB/PostgreSQL
```bash
psql -U postgres -d your_database -f migrations/paradedb/06-add-context-config-to-sessions.sql
```
## 常见问题
### Q1: 上下文管理器的数据会持久化吗?
**A**: 不会。上下文管理器是内存级别的,按 Session 维护。重启后会清空,需要从消息历史重新构建。
### Q2: 如何清空某个会话的上下文?
**A**:
```go
err := contextManager.ClearContext(ctx, sessionID)
```
### Q3: 压缩后的消息会影响数据库中的消息吗?
**A**: 不会。压缩只影响发送给 LLM 的上下文,数据库中的消息完整保留。
### Q4: 智能压缩的总结质量如何保证?
**A**:
- 使用会话配置的 `summary_model_id` 模型
- 可以使用更强的模型(如 GPT-4)进行总结
- 总结时使用低 temperature (0.3) 确保一致性
### Q5: 如何为现有会话启用上下文管理?
**A**:
```bash
# 更新会话配置
curl -X PUT http://localhost:8080/api/v1/sessions/{session_id} \
-H "Content-Type: application/json" \
-H "X-Tenant-ID: 1" \
-d '{
"context_config": {
"enabled": true,
"max_tokens": 8192,
"compression_strategy": "sliding_window",
"recent_message_count": 20
}
}'
```
## 未来改进
- [ ] 支持更多压缩策略(如 MapReduce 总结)
- [ ] 支持自定义 Token 计算器
- [ ] 持久化压缩后的上下文摘要
- [ ] 支持跨会话的上下文共享
- [ ] 添加更详细的压缩指标和监控
- [ ] 支持上下文恢复机制
## 参考
- [Agent 文档](./AGENT.md)
- [API 文档](./API.md)
- [消息管理](../internal/types/message.go)
+30
View File
@@ -0,0 +1,30 @@
## MCP 功能使用说明
### 功能概述
- MCPModel Context Protocol)让 WeKnora 可以安全地连接外部工具或数据源,扩展 Agent 在推理时可调用的能力。
- 在前端 `设置 > MCP 服务``frontend/src/views/settings/McpSettings.vue`)中集中管理所有服务,无需手动改配置文件。
- 每个服务都包含名称、传输方式(SSE / HTTP Streamable / Stdio)、连接地址或命令、认证信息以及高级超时与重试策略。
### 入口与界面
- 打开控制台左侧菜单 `设置 -> MCP 服务`,即可看到当前租户下的所有 MCP 服务列表。
- 列表中可快速启停服务、查看描述,并通过右侧菜单执行“测试 / 编辑 / 删除”。
- “添加服务”按钮会弹出 `McpServiceDialog`,用于创建或修改服务。
### 常用操作流程
1. **新建服务**
- 点击“添加服务”,填写名称与描述,选择传输方式。
- SSE / HTTP Streamable 需提供可访问的服务 URLStdio 需配置 `uvx`/`npx` 命令与参数,可附加环境变量。
- 根据需要填写 API Key、Bearer Token、超时与重试策略,保存后服务会出现在列表中。
2. **启停服务**
- 在列表开关中切换启用状态,系统会即时调用后端 `updateMCPService`,失败时会自动回滚状态并弹出提示。
3. **连接测试**
- 通过更多菜单选择“测试”,前端会调用 `/api/v1/mcp-services/{id}/test` 并弹出 `McpTestResult`
- 成功时会展示服务可用的工具清单(含输入 schema)和资源列表;失败时会显示错误信息,方便排查网络或鉴权问题。
4. **编辑 / 删除**
- “编辑”会带出原有配置,修改后保存即可。
- “删除”需要在弹窗中确认,完成后列表自动刷新。
### 使用建议
- **传输方式选择**:优先使用 SSE 获取流式体验;需要标准 HTTP Streamable 兼容时再切换;本地调试或离线环境适合使用 Stdio 并在同机启动 MCP Server。
- **鉴权管理**:将 API Key / Token 保存在“认证配置”中,生产环境建议单独创建最小权限 Key,并定期轮换。
- **重试策略**:对公网或第三方服务适当提高 `retry_count``retry_delay`,避免间歇性超时导致 Agent 中断
+2 -151
View File
@@ -1,74 +1,7 @@
# 快速开发模式说明
## 🎯 问题背景
解决开发流程中,每次修改 `app`(后端)或 `frontend`(前端)代码后,都需要打包Docker镜像的问题,实现这两个模块的热更新
之前的开发流程中,每次修改 `app`(后端)或 `frontend`(前端)代码后,都需要:
```bash
# 重新构建 Docker 镜像(耗时 2-5 分钟)
sh scripts/build_images.sh -p # 构建后端
sh scripts/build_images.sh -f # 构建前端
# 重启容器
sh scripts/start_all.sh --no-pull
```
这个流程非常耗时,严重影响开发效率。
## ✨ 解决方案
现在提供了**快速开发模式**,可以直接在本地运行应用和前端,只在 Docker 中启动基础设施服务(数据库、缓存等),实现:
-**前端热重载**:修改代码自动刷新,无需重启
-**后端快速重启**:修改代码后 5-10 秒即可重启
-**无需构建镜像**:跳过耗时的镜像构建过程
-**支持调试**:可以使用 IDE 断点调试
## 📦 新增文件
### 1. 核心配置文件
- **`docker-compose.dev.yml`** - 开发环境 Docker Compose 配置
- 只启动基础设施服务(postgres, redis, minio, neo4j, docreader, jaeger
- 不启动 app 和 frontend 容器
### 2. 开发脚本
- **`scripts/dev.sh`** - 开发环境管理脚本
- 支持启动/停止基础设施
- 支持本地运行 app 和 frontend
- 自动检测并使用 Air(Go 热重载工具)
- **`scripts/quick-dev.sh`** - 一键启动开发环境脚本
- 交互式启动所有服务
- 自动创建日志目录
### 3. 配置文件
- **`.air.toml`** - Air 热重载配置
- 监控 Go 文件变化
- 自动重新编译和重启
- **`frontend/vite.config.ts`** - 更新的 Vite 配置
- 添加 API 代理配置
- 避免 CORS 问题
### 4. 文档
- **`DEVELOPMENT.md`** - 开发环境快速入门指南
- **`docs/开发指南.md`** - 完整开发指南
- **`docs/快速开发模式说明.md`** - 本文档
### 5. Makefile 更新
新增的 Make 命令:
- `make dev-start` - 启动开发环境基础设施
- `make dev-stop` - 停止开发环境
- `make dev-restart` - 重启开发环境
- `make dev-logs` - 查看服务日志
- `make dev-status` - 查看服务状态
- `make dev-app` - 启动后端应用(本地)
- `make dev-frontend` - 启动前端(本地)
## 🚀 使用方法
@@ -104,28 +37,7 @@ make dev-frontend
./scripts/quick-dev.sh
```
## 📊 效率对比
### 旧方式(慢)
| 操作 | 耗时 |
|------|------|
| 修改代码 | - |
| 重新构建镜像 | 2-5 分钟 |
| 重启容器 | 30-60 秒 |
| **总计** | **2.5-6 分钟** |
### 新方式(快)
| 操作 | 耗时 |
|------|------|
| 首次启动基础设施 | 1-2 分钟(仅一次) |
| **后续修改后端** | **5-10 秒** |
| **后续修改前端** | **实时热重载** |
**效率提升:20-60 倍!**
## 🎓 高级功能
### 使用 Air 实现后端热重载
@@ -142,32 +54,6 @@ export PATH=$PATH:$(go env GOPATH)/bin
make dev-app
```
### VS Code 调试配置
创建 `.vscode/launch.json`
```json
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch WeKnora Server",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/server",
"env": {
"DB_HOST": "localhost",
"DOCREADER_ADDR": "localhost:50051",
"MINIO_ENDPOINT": "localhost:9000",
"REDIS_ADDR": "localhost:6379",
"OTEL_EXPORTER_OTLP_ENDPOINT": "localhost:4317",
"NEO4J_URI": "bolt://localhost:7687"
}
}
]
}
```
## 🔄 架构说明
@@ -217,39 +103,4 @@ make dev-app
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
```
## 🎯 使用场景
### 日常开发
使用**开发模式**`make dev-*`),享受快速迭代。
### 集成测试
使用**生产模式**`make start-all`),测试完整环境。
### 生产部署
```bash
# 构建镜像
make build-images
# 启动服务
make start-all
```
## 🔧 故障排除
详见 [DEVELOPMENT.md](../DEVELOPMENT.md) 的故障排除部分。
## 📚 相关文档
- [开发环境快速入门](../DEVELOPMENT.md)
- [完整开发指南](./开发指南.md)
- [API 文档](./API.md)
## 💬 反馈与建议
如有问题或建议,请提交 [Issue](https://github.com/Tencent/WeKnora/issues)。
```
+16
View File
@@ -43,6 +43,22 @@ export interface ConversationConfig {
context_template: string
temperature: number
max_tokens: number
use_custom_system_prompt?: boolean
use_custom_context_template?: boolean
max_rounds: number
embedding_top_k: number
keyword_threshold: number
vector_threshold: number
rerank_top_k: number
rerank_threshold: number
enable_rewrite: boolean
fallback_strategy: string
fallback_response: string
fallback_prompt?: string
summary_model_id?: string
rerank_model_id?: string
rewrite_prompt_system?: string
rewrite_prompt_user?: string
}
export function getSystemInfo(): Promise<{ data: SystemInfo }> {
+1 -1
View File
@@ -18,7 +18,7 @@
<div v-if="menuVisible" class="user-dropdown" @click.stop>
<div class="menu-item" @click="handleQuickNav('models')">
<t-icon name="control-platform" class="menu-icon" />
<span>{{ $t('settings.modelConfig') }}</span>
<span>{{ $t('settings.modelManagement') }}</span>
</div>
<div class="menu-item" @click="handleQuickNav('ollama')">
<t-icon name="server" class="menu-icon" />
+125 -6
View File
@@ -152,8 +152,10 @@ export default {
settings: {
title: 'Settings',
modelConfig: 'Model Settings',
modelManagement: 'Model Management',
agentConfig: 'Agent Settings',
conversationConfig: 'Conversation Settings',
conversationStrategy: 'Conversation Strategy',
webSearchConfig: 'Web Search',
mcpService: 'MCP Service',
systemSettings: 'System Settings',
@@ -833,8 +835,8 @@ export default {
title: 'Model Configuration',
description: 'Select appropriate AI models for the knowledge base',
llmLabel: 'LLM Model',
llmDesc: 'Large language model used for conversations and Q&A',
llmPlaceholder: 'Select an LLM model',
llmDesc: 'Large language model used for summarization and abstract generation (optional)',
llmPlaceholder: 'Select an LLM model (optional)',
embeddingLabel: 'Embedding Model',
embeddingDesc: 'Embedding model used for text vectorization',
embeddingPlaceholder: 'Select an embedding model',
@@ -1368,18 +1370,107 @@ export default {
}
},
conversationSettings: {
description: 'Configure default behavior and parameters for conversation modes, including Prompt settings for Agent mode and normal mode',
description: 'Configure default behavior and parameters for conversation modes, including prompts for Agent and normal modes',
agentMode: 'Agent Mode',
normalMode: 'Normal Mode',
menus: {
modes: 'Mode Settings',
models: 'Model Mapping',
thresholds: 'Retrieval Thresholds',
advanced: 'Advanced Settings'
},
models: {
description: 'Manage thinking/chat models and re-rank models for both Agent and normal modes',
chatGroupLabel: 'Thinking / Chat Models',
chatGroupDesc: 'Includes Agent reasoning/planning model and the default chat/summary model for normal mode',
chatModel: {
label: 'Default chat model (normal mode)',
desc: 'Used when a conversation does not specify its own model',
placeholder: 'Select default chat model'
},
rerankModel: {
label: 'Default ReRank model (normal mode)',
desc: 'Used for re-ranking when a session does not override it',
placeholder: 'Select default rerank model'
},
rerankGroupLabel: 'ReRank Models',
rerankGroupDesc: 'Includes Agent rerank model and the default rerank model for normal mode'
},
thresholds: {
description: 'Tune retrieval and re-ranking thresholds to balance accuracy and performance'
},
maxRounds: {
label: 'History Rounds',
desc: 'Number of rounds kept for context and query rewrite'
},
embeddingTopK: {
label: 'Embedding TopK',
desc: 'Number of documents kept after vector retrieval'
},
keywordThreshold: {
label: 'Keyword Threshold',
desc: 'Minimum score for keyword retrieval'
},
vectorThreshold: {
label: 'Vector Threshold',
desc: 'Minimum similarity for vector retrieval'
},
rerankTopK: {
label: 'ReRank TopK',
desc: 'Documents kept after re-ranking'
},
rerankThreshold: {
label: 'ReRank Threshold',
desc: 'Minimum score required after re-ranking'
},
enableRewrite: {
label: 'Enable Query Rewrite',
desc: 'Automatically rewrite multi-turn queries for better recall'
},
fallbackStrategy: {
label: 'Fallback Strategy',
desc: 'How to respond when no relevant documents are found',
fixed: 'Fixed response',
model: 'Let the model continue answering'
},
fallbackResponse: {
label: 'Fixed fallback response',
desc: 'Text returned when using the fixed fallback strategy'
},
fallbackPrompt: {
label: 'Fallback Prompt',
desc: 'Prompt used when fallback strategy is “model”'
},
advanced: {
description: 'Configure query rewrite, fallback strategy and other advanced settings'
},
rewritePrompt: {
system: 'Rewrite System Prompt',
user: 'Rewrite User Prompt',
desc: 'System prompt used during query rewrite',
userDesc: 'User prompt used during query rewrite'
},
chatModel: {
label: 'LLM Model',
desc: 'Large language model used for summarization and abstract generation'
},
rerankModel: {
label: 'ReRank Model',
desc: 'Model for re-ranking search results (optional)'
},
contextTemplate: {
label: 'Retrieval Result Summary Prompt',
desc: 'Prompt template for generating answers based on retrieval results in normal mode',
placeholder: 'Enter the prompt template for retrieval result summary...'
placeholder: 'Enter the prompt template for retrieval result summary...',
custom: 'Custom template',
disabledHint: 'Currently using the default summary prompt. Enable custom to edit below.'
},
systemPrompt: {
label: 'System Prompt',
desc: 'System-level prompt for normal mode conversations',
placeholder: 'Enter the system prompt...'
placeholder: 'Enter the system prompt...',
custom: 'Custom prompt',
disabledHint: 'Currently using the default prompt. Enable custom to edit below.'
},
temperature: {
label: 'Temperature',
@@ -1389,11 +1480,39 @@ export default {
label: 'Max Tokens',
desc: 'Maximum number of tokens to generate in the response'
},
resetSystemPrompt: {
header: 'Reset to Default System Prompt',
body: 'Are you sure you want to reset to the default system prompt?'
},
resetContextTemplate: {
header: 'Reset to Default Summary Prompt',
body: 'Are you sure you want to reset to the default summary prompt?'
},
toasts: {
chatModelSaved: 'LLM model saved',
rerankModelSaved: 'ReRank model saved',
contextTemplateSaved: 'Retrieval result summary prompt saved',
systemPromptSaved: 'System prompt saved',
temperatureSaved: 'Temperature saved',
maxTokensSaved: 'Max tokens saved'
maxTokensSaved: 'Max tokens saved',
maxRoundsSaved: 'History rounds saved',
embeddingSaved: 'Embedding TopK saved',
keywordThresholdSaved: 'Keyword threshold saved',
vectorThresholdSaved: 'Vector threshold saved',
rerankTopKSaved: 'ReRank TopK saved',
rerankThresholdSaved: 'ReRank threshold saved',
enableRewriteSaved: 'Query rewrite preference saved',
fallbackStrategySaved: 'Fallback strategy saved',
fallbackResponseSaved: 'Fallback response saved',
fallbackPromptSaved: 'Fallback prompt saved',
rewritePromptSystemSaved: 'Rewrite system prompt saved',
rewritePromptUserSaved: 'Rewrite user prompt saved',
customPromptEnabled: 'Custom prompt enabled',
defaultPromptEnabled: 'Using default prompt',
customContextTemplateEnabled: 'Custom summary prompt enabled',
defaultContextTemplateEnabled: 'Using default summary prompt',
resetSystemPromptSuccess: 'Reset to default system prompt',
resetContextTemplateSuccess: 'Reset to default summary prompt'
}
},
// New: MCP Settings
+3
View File
@@ -151,9 +151,12 @@ export default {
settings: {
title: 'Настройки',
modelConfig: 'Настройки модели',
modelManagement: 'Управление моделями',
agentConfig: 'Настройки агента',
webSearchConfig: 'Сетевой поиск',
mcpService: 'Сервис MCP',
conversationConfig: 'Настройки диалога',
conversationStrategy: 'Стратегия диалога',
systemSettings: 'Настройки системы',
tenantInfo: 'Информация о арендаторе',
apiInfo: 'Информация API',
+121 -2
View File
@@ -243,8 +243,10 @@ export default {
settings: {
title: "设置",
modelConfig: "模型配置",
modelManagement: "模型管理",
agentConfig: "Agent配置",
conversationConfig: "对话设置",
conversationStrategy: "对话策略",
webSearchConfig: "网络搜索",
mcpService: "MCP服务",
systemSettings: "系统设置",
@@ -1156,8 +1158,8 @@ export default {
title: "模型配置",
description: "为知识库选择合适的 AI 模型",
llmLabel: "LLM 大语言模型",
llmDesc: "用于对话和问答的大语言模型",
llmPlaceholder: "请选择 LLM 模型",
llmDesc: "用于总结和摘要的大语言模型(可选)",
llmPlaceholder: "请选择 LLM 模型(可选)",
embeddingLabel: "Embedding 嵌入模型",
embeddingDesc: "用于文本向量化的嵌入模型",
embeddingPlaceholder: "请选择 Embedding 模型",
@@ -1375,15 +1377,104 @@ export default {
description: "配置对话模式的默认行为和参数,包括Agent模式和普通模式的Prompt设置",
agentMode: "Agent模式",
normalMode: "普通模式",
menus: {
modes: "模式设置",
models: "模型配置",
thresholds: "检索阈值",
advanced: "高级设置",
},
models: {
description: "统一管理 Agent 和普通模式使用的对话/总结模型与 ReRank 模型",
chatGroupLabel: "思考 / 对话模型",
chatGroupDesc: "包含 Agent 推理与规划模型,以及普通模式默认的对话/总结模型",
chatModel: {
label: "普通模式默认对话模型",
desc: "普通模式默认使用的对话/总结模型,当会话未指定模型时生效",
placeholder: "请选择默认对话模型",
},
rerankModel: {
label: "普通模式默认 ReRank 模型",
desc: "普通模式默认使用的重排序模型",
placeholder: "请选择默认 ReRank 模型",
},
rerankGroupLabel: "ReRank 模型",
rerankGroupDesc: "包含 Agent 使用的重排序模型,以及普通模式默认 ReRank 模型",
},
thresholds: {
description: "调整召回与重排序的阈值与 TopK,平衡准确率与性能",
},
maxRounds: {
label: "历史保留轮数",
desc: "用于多轮上下文和问题改写的历史轮数",
},
embeddingTopK: {
label: "Embedding TopK",
desc: "向量召回阶段保留的文档数量",
},
keywordThreshold: {
label: "关键词阈值",
desc: "关键词检索的最低得分阈值",
},
vectorThreshold: {
label: "向量阈值",
desc: "向量召回的最低相似度阈值",
},
rerankTopK: {
label: "ReRank TopK",
desc: "重排序后进入答案生成的文档数量",
},
rerankThreshold: {
label: "ReRank 阈值",
desc: "重排序阶段的最低得分阈值",
},
enableRewrite: {
label: "开启问题改写",
desc: "多轮对话自动改写问题以获得更优召回",
},
fallbackStrategy: {
label: "兜底策略",
desc: "检索无结果时采用的处理方式",
fixed: "固定回复",
model: "交给模型继续生成",
},
fallbackResponse: {
label: "固定兜底回复",
desc: "当兜底策略为固定回复时返回的文本",
},
fallbackPrompt: {
label: "兜底 Prompt",
desc: "当选择模型兜底时使用的提示模板",
},
advanced: {
description: "配置问题改写、兜底策略等高级设置",
},
rewritePrompt: {
system: "Rewrite System Prompt",
user: "Rewrite User Prompt",
desc: "控制问题改写的系统提示词",
userDesc: "控制问题改写的用户提示词",
},
chatModel: {
label: "LLM 模型",
desc: "用于总结和摘要的大语言模型",
},
rerankModel: {
label: "ReRank 模型",
desc: "用于搜索结果重排序的模型(可选)",
},
contextTemplate: {
label: "总结Prompt",
desc: "用于普通模式下基于检索结果生成回答的Prompt模板",
placeholder: "请输入检索结果总结的Prompt模板...",
custom: "自定义模板",
disabledHint: "当前使用系统默认总结 Prompt,开启自定义后才会应用下方内容。",
},
systemPrompt: {
label: "系统Prompt",
desc: "用于普通模式对话的系统级Prompt",
placeholder: "请输入系统Prompt...",
custom: "自定义 Prompt",
disabledHint: "当前使用系统默认 Prompt,开启自定义后才会应用下方内容。",
},
temperature: {
label: "温度参数",
@@ -1393,11 +1484,39 @@ export default {
label: "最大Token数",
desc: "生成回答的最大Token数量",
},
resetSystemPrompt: {
header: "恢复默认系统 Prompt",
body: "确定要恢复为系统默认的系统 Prompt 吗?",
},
resetContextTemplate: {
header: "恢复默认总结 Prompt",
body: "确定要恢复为系统默认的总结 Prompt 吗?",
},
toasts: {
chatModelSaved: "LLM 模型已保存",
rerankModelSaved: "ReRank 模型已保存",
contextTemplateSaved: "总结Prompt已保存",
systemPromptSaved: "系统Prompt已保存",
temperatureSaved: "温度参数已保存",
maxTokensSaved: "最大Token数已保存",
maxRoundsSaved: "历史轮数已保存",
embeddingSaved: "Embedding TopK 已保存",
keywordThresholdSaved: "关键词阈值已保存",
vectorThresholdSaved: "向量阈值已保存",
rerankTopKSaved: "ReRank TopK 已保存",
rerankThresholdSaved: "ReRank 阈值已保存",
enableRewriteSaved: "问题改写开关已保存",
fallbackStrategySaved: "兜底策略已保存",
fallbackResponseSaved: "兜底回复已保存",
fallbackPromptSaved: "兜底 Prompt 已保存",
rewritePromptSystemSaved: "改写 System Prompt 已保存",
rewritePromptUserSaved: "改写 User Prompt 已保存",
customPromptEnabled: "已启用自定义 Prompt",
defaultPromptEnabled: "已使用系统默认 Prompt",
customContextTemplateEnabled: "已启用自定义总结 Prompt",
defaultContextTemplateEnabled: "已使用系统默认总结 Prompt",
resetSystemPromptSuccess: "已恢复为系统默认 Prompt",
resetContextTemplateSuccess: "已恢复为系统默认总结 Prompt",
},
},
// 新增:MCP 设置
@@ -226,7 +226,7 @@ onMounted(async () => {
color: #333333;
line-height: 1.6;
/* 确保换行符正确显示 */
white-space: pre-line; /* 保留换行符,但合并多个空格 */
// white-space: pre-line; /* 保留换行符,但合并多个空格 */
}
.markdown-content {
File diff suppressed because it is too large Load Diff
+32 -19
View File
@@ -91,7 +91,7 @@
<!-- Agent 配置 -->
<div v-if="currentSection === 'agent'" class="section">
<AgentSettings />
<AgentSettings :active-sub-section="currentSubSection || 'modes'" />
</div>
<!-- 网络搜索配置 -->
@@ -156,7 +156,7 @@ const navItems = computed(() => [
{
key: 'models',
icon: 'control-platform',
label: t('settings.modelConfig'),
label: t('settings.modelManagement'),
children: [
{ key: 'chat', label: t('model.llmModel') },
{ key: 'embedding', label: t('model.embeddingModel') },
@@ -165,9 +165,19 @@ const navItems = computed(() => [
]
},
{ key: 'ollama', icon: 'server', label: 'Ollama' },
{ key: 'agent', icon: 'chat', label: t('settings.conversationConfig') },
{
key: 'agent',
icon: 'chat',
label: t('settings.conversationStrategy'),
children: [
{ key: 'modes', label: t('conversationSettings.menus.modes') },
{ key: 'models', label: t('conversationSettings.menus.models') },
{ key: 'thresholds', label: t('conversationSettings.menus.thresholds') },
{ key: 'advanced', label: t('conversationSettings.menus.advanced') },
]
},
{ key: 'websearch', icon: 'search', label: t('settings.webSearchConfig') },
{ key: 'mcp', icon: 'tools', label: t('settings.mcpService') },
{ key: 'mcp', icon: 'tools', label: t('settings.mcpService') },
{ key: 'system', icon: 'info-circle', label: t('settings.systemSettings') },
{ key: 'tenant', icon: 'user-circle', label: t('settings.tenantInfo') },
{ key: 'api', icon: 'secured', label: t('settings.apiInfo') }
@@ -183,11 +193,13 @@ const handleNavClick = (item: any) => {
} else {
expandedMenus.value.push(item.key)
}
currentSubSection.value = item.children[0].key
} else {
currentSubSection.value = ''
}
// 切换到对应页面
currentSection.value = item.key
currentSubSection.value = ''
}
// 子菜单点击处理
@@ -222,19 +234,22 @@ const handleClose = () => {
watch(() => uiStore.settingsInitialSection, (section) => {
if (section && visible.value) {
currentSection.value = section
if (uiStore.settingsInitialSubSection) {
currentSubSection.value = uiStore.settingsInitialSubSection
// 展开对应的父菜单
const navItem = (navItems.value as any[]).find((item) => item.key === section)
if (navItem && navItem.children && navItem.children.length > 0) {
if (!expandedMenus.value.includes(section)) {
expandedMenus.value.push(section)
}
// 滚动到对应区域
setTimeout(() => {
const element = document.querySelector(`[data-model-type="${uiStore.settingsInitialSubSection}"]`)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, 300)
currentSubSection.value = uiStore.settingsInitialSubSection || navItem.children[0].key
if (uiStore.settingsInitialSubSection) {
setTimeout(() => {
const element = document.querySelector(`[data-model-type="${uiStore.settingsInitialSubSection}"]`)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, 300)
}
} else {
currentSubSection.value = ''
}
}
}, { immediate: true })
@@ -252,15 +267,13 @@ const handleSettingsNav = (e: CustomEvent) => {
if (section) {
currentSection.value = section
// 如果有子菜单,自动展开
const navItem = (navItems as any).find((item: any) => item.key === section)
const navItem = (navItems.value as any[]).find((item: any) => item.key === section)
if (navItem && navItem.children && navItem.children.length > 0) {
if (!expandedMenus.value.includes(section)) {
expandedMenus.value.push(section)
}
// 如果有 subsection,选中对应的子菜单项
if (subsection) {
currentSubSection.value = subsection
}
currentSubSection.value = subsection || navItem.children[0].key
}
}
}
@@ -3,7 +3,6 @@ package chatpipline
import (
"context"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
@@ -34,7 +33,12 @@ func (p *PluginChatCompletion) ActivationEvents() []types.EventType {
func (p *PluginChatCompletion) OnEvent(
ctx context.Context, eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
) *PluginError {
logger.Info(ctx, "Starting chat completion")
pipelineInfo(ctx, "Completion", "input", map[string]interface{}{
"session_id": chatManage.SessionID,
"user_question": chatManage.UserContent,
"history_rounds": len(chatManage.History),
"chat_model": chatManage.ChatModelID,
})
// Prepare chat model and options
chatModel, opt, err := prepareChatModel(ctx, p.modelService, chatManage)
@@ -43,18 +47,30 @@ func (p *PluginChatCompletion) OnEvent(
}
// Prepare messages including conversation history
logger.Info(ctx, "Preparing chat messages with history")
pipelineInfo(ctx, "Completion", "messages_ready", map[string]interface{}{
"message_count": len(chatManage.History) + 2,
})
chatMessages := prepareMessagesWithHistory(chatManage)
// Call the chat model to generate response
logger.Info(ctx, "Calling chat model")
pipelineInfo(ctx, "Completion", "model_call", map[string]interface{}{
"chat_model": chatManage.ChatModelID,
})
chatResponse, err := chatModel.Chat(ctx, chatMessages, opt)
if err != nil {
logger.Errorf(ctx, "Failed to call chat model: %v", err)
pipelineError(ctx, "Completion", "model_call", map[string]interface{}{
"chat_model": chatManage.ChatModelID,
"error": err.Error(),
})
return ErrModelCall.WithError(err)
}
logger.Info(ctx, "Chat completion successful")
pipelineInfo(ctx, "Completion", "output", map[string]interface{}{
"answer_preview": truncateForLog(chatResponse.Content),
"finish_reason": chatResponse.FinishReason,
"completion_tokens": chatResponse.Usage.CompletionTokens,
"prompt_tokens": chatResponse.Usage.PromptTokens,
})
chatManage.ChatResponse = chatResponse
return next()
}
@@ -40,7 +40,12 @@ func (p *PluginChatCompletionStream) ActivationEvents() []types.EventType {
func (p *PluginChatCompletionStream) OnEvent(ctx context.Context,
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
) *PluginError {
logger.Info(ctx, "Starting chat completion stream")
pipelineInfo(ctx, "Stream", "input", map[string]interface{}{
"session_id": chatManage.SessionID,
"user_question": chatManage.UserContent,
"history_rounds": len(chatManage.History),
"chat_model": chatManage.ChatModelID,
})
// Prepare chat model and options
chatModel, opt, err := prepareChatModel(ctx, p.modelService, chatManage)
@@ -49,31 +54,49 @@ func (p *PluginChatCompletionStream) OnEvent(ctx context.Context,
}
// Prepare base messages without history
logger.Info(ctx, "Preparing chat messages")
chatMessages := prepareMessagesWithHistory(chatManage)
chatMessages := prepareMessagesWithHistory(chatManage)
pipelineInfo(ctx, "Stream", "messages_ready", map[string]interface{}{
"message_count": len(chatMessages),
"system_prompt": chatMessages[0].Content,
"user_content": chatMessages[len(chatMessages)-1].Content,
})
// EventBus is required for event-driven streaming
if chatManage.EventBus == nil {
logger.Error(ctx, "EventBus is required but not available")
pipelineError(ctx, "Stream", "eventbus_missing", map[string]interface{}{
"session_id": chatManage.SessionID,
})
return ErrModelCall.WithError(errors.New("EventBus is required for streaming"))
}
eventBus := chatManage.EventBus
logger.Info(ctx, "EventBus detected, enabling event-driven streaming mode")
pipelineInfo(ctx, "Stream", "eventbus_ready", map[string]interface{}{
"session_id": chatManage.SessionID,
})
// Initiate streaming chat model call with independent context
logger.Info(ctx, "Calling chat stream model")
pipelineInfo(ctx, "Stream", "model_call", map[string]interface{}{
"chat_model": chatManage.ChatModelID,
})
responseChan, err := chatModel.ChatStream(ctx, chatMessages, opt)
if err != nil {
logger.Errorf(ctx, "Failed to call chat stream model: %v", err)
pipelineError(ctx, "Stream", "model_call", map[string]interface{}{
"chat_model": chatManage.ChatModelID,
"error": err.Error(),
})
return ErrModelCall.WithError(err)
}
if responseChan == nil {
logger.Error(ctx, "Chat stream returned nil channel")
pipelineError(ctx, "Stream", "model_call", map[string]interface{}{
"chat_model": chatManage.ChatModelID,
"error": "nil_channel",
})
return ErrModelCall.WithError(errors.New("chat stream returned nil channel"))
}
logger.Info(ctx, "Chat stream initiated successfully")
pipelineInfo(ctx, "Stream", "model_started", map[string]interface{}{
"session_id": chatManage.SessionID,
})
// Start goroutine to consume channel and emit events directly
go func() {
@@ -98,24 +121,9 @@ func (p *PluginChatCompletionStream) OnEvent(ctx context.Context,
}
}
logger.Info(ctx, "Chat stream completed, emitting completion event")
// Emit completion event when stream finishes
// This allows other components to detect stream completion
if err := eventBus.Emit(ctx, types.Event{
ID: fmt.Sprintf("%s-complete", uuid.New().String()[:8]),
Type: types.EventType(event.EventAgentComplete),
SessionID: chatManage.SessionID,
Data: event.AgentCompleteData{
SessionID: chatManage.SessionID,
MessageID: chatManage.MessageID,
FinalAnswer: finalContent,
},
}); err != nil {
logger.Errorf(ctx, "Failed to emit completion event: %v", err)
}
logger.Info(ctx, "Chat stream completed and completion event emitted")
pipelineInfo(ctx, "Stream", "channel_close", map[string]interface{}{
"session_id": chatManage.SessionID,
})
}()
return next()
@@ -2,6 +2,10 @@ package chatpipline
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/chat"
@@ -9,6 +13,78 @@ import (
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
const (
logValueMaxRune = 300
defaultStageName = "PIPELINE"
defaultActionName = "info"
pipelineLogPrefix = "[PIPELINE]"
pipelineTruncateEll = "..."
)
func pipelineLog(stage, action string, fields map[string]interface{}) string {
if stage == "" {
stage = defaultStageName
}
if action == "" {
action = defaultActionName
}
builder := strings.Builder{}
builder.Grow(128)
builder.WriteString(pipelineLogPrefix)
builder.WriteString(" stage=")
builder.WriteString(stage)
builder.WriteString(" action=")
builder.WriteString(action)
if len(fields) > 0 {
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
for _, key := range keys {
builder.WriteString(" ")
builder.WriteString(key)
builder.WriteString("=")
builder.WriteString(formatLogValue(fields[key]))
}
}
return builder.String()
}
func pipelineInfo(ctx context.Context, stage, action string, fields map[string]interface{}) {
logger.GetLogger(ctx).Info(pipelineLog(stage, action, fields))
}
func pipelineWarn(ctx context.Context, stage, action string, fields map[string]interface{}) {
logger.GetLogger(ctx).Warn(pipelineLog(stage, action, fields))
}
func pipelineError(ctx context.Context, stage, action string, fields map[string]interface{}) {
logger.GetLogger(ctx).Error(pipelineLog(stage, action, fields))
}
func formatLogValue(value interface{}) string {
switch v := value.(type) {
case string:
return strconv.Quote(truncateForLog(v))
case fmt.Stringer:
return strconv.Quote(truncateForLog(v.String()))
default:
return fmt.Sprintf("%v", v)
}
}
func truncateForLog(content string) string {
content = strings.ReplaceAll(content, "\n", "\\n")
runes := []rune(content)
if len(runes) <= logValueMaxRune {
return content
}
return string(runes[:logValueMaxRune]) + pipelineTruncateEll
}
// prepareChatModel shared logic to prepare chat model and options
func prepareChatModel(ctx context.Context, modelService interfaces.ModelService,
chatManage *types.ChatManage,
@@ -35,14 +111,6 @@ func prepareChatModel(ctx context.Context, modelService interfaces.ModelService,
return chatModel, opt, nil
}
// prepareBaseMessages prepare basic messages (system prompt and current user content)
func prepareBaseMessages(chatManage *types.ChatManage) []chat.Message {
var chatMessages []chat.Message
chatMessages = append(chatMessages, chat.Message{Role: "system", Content: chatManage.SummaryConfig.Prompt})
chatMessages = append(chatMessages, chat.Message{Role: "user", Content: chatManage.UserContent})
return chatMessages
}
// prepareMessagesWithHistory prepare complete messages including history
func prepareMessagesWithHistory(chatManage *types.ChatManage) []chat.Message {
chatMessages := []chat.Message{
@@ -3,7 +3,6 @@ package chatpipline
import (
"context"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
)
@@ -27,12 +26,20 @@ func (p *PluginFilterTopK) ActivationEvents() []types.EventType {
func (p *PluginFilterTopK) OnEvent(ctx context.Context,
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
) *PluginError {
logger.Info(ctx, "Starting filter top-K process")
logger.Infof(ctx, "Filter configuration: top-K = %d", chatManage.RerankTopK)
pipelineInfo(ctx, "FilterTopK", "input", map[string]interface{}{
"session_id": chatManage.SessionID,
"top_k": chatManage.RerankTopK,
"merge_cnt": len(chatManage.MergeResult),
"rerank_cnt": len(chatManage.RerankResult),
"search_cnt": len(chatManage.SearchResult),
})
filterTopK := func(searchResult []*types.SearchResult, topK int) []*types.SearchResult {
if topK > 0 && len(searchResult) > topK {
logger.Infof(ctx, "Filtering results: before=%d, after=%d", len(searchResult), topK)
pipelineInfo(ctx, "FilterTopK", "filter", map[string]interface{}{
"before": len(searchResult),
"after": topK,
})
searchResult = searchResult[:topK]
}
return searchResult
@@ -45,9 +52,15 @@ func (p *PluginFilterTopK) OnEvent(ctx context.Context,
} else if len(chatManage.SearchResult) > 0 {
chatManage.SearchResult = filterTopK(chatManage.SearchResult, chatManage.RerankTopK)
} else {
logger.Info(ctx, "No results to filter")
pipelineWarn(ctx, "FilterTopK", "skip", map[string]interface{}{
"reason": "no_results",
})
}
logger.Info(ctx, "Filter top-K process completed")
pipelineInfo(ctx, "FilterTopK", "output", map[string]interface{}{
"merge_cnt": len(chatManage.MergeResult),
"rerank_cnt": len(chatManage.RerankResult),
"search_cnt": len(chatManage.SearchResult),
})
return next()
}
@@ -10,7 +10,6 @@ import (
"strings"
"time"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
@@ -34,6 +33,12 @@ func (p *PluginIntoChatMessage) ActivationEvents() []types.EventType {
func (p *PluginIntoChatMessage) OnEvent(ctx context.Context,
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
) *PluginError {
pipelineInfo(ctx, "IntoChatMessage", "input", map[string]interface{}{
"session_id": chatManage.SessionID,
"merge_result_cnt": len(chatManage.MergeResult),
"template_len": len(chatManage.SummaryConfig.ContextTemplate),
})
// Extract content from merge results
passages := make([]string, len(chatManage.MergeResult))
for i, result := range chatManage.MergeResult {
@@ -44,6 +49,10 @@ func (p *PluginIntoChatMessage) OnEvent(ctx context.Context,
// Parse the context template
tmpl, err := template.New("searchContent").Parse(chatManage.SummaryConfig.ContextTemplate)
if err != nil {
pipelineError(ctx, "IntoChatMessage", "parse_template", map[string]interface{}{
"session_id": chatManage.SessionID,
"error": err.Error(),
})
return ErrTemplateParse.WithError(err)
}
@@ -54,7 +63,9 @@ func (p *PluginIntoChatMessage) OnEvent(ctx context.Context,
// 验证用户查询的安全性
safeQuery, isValid := secutils.ValidateInput(chatManage.Query)
if !isValid {
logger.Errorf(ctx, "Invalid user query: %s", chatManage.Query)
pipelineWarn(ctx, "IntoChatMessage", "invalid_query", map[string]interface{}{
"session_id": chatManage.SessionID,
})
return ErrTemplateExecute.WithError(fmt.Errorf("用户查询包含非法内容"))
}
@@ -66,11 +77,19 @@ func (p *PluginIntoChatMessage) OnEvent(ctx context.Context,
"CurrentWeek": weekdayName[time.Now().Weekday()], // Current weekday in Chinese
})
if err != nil {
pipelineError(ctx, "IntoChatMessage", "render_template", map[string]interface{}{
"session_id": chatManage.SessionID,
"error": err.Error(),
})
return ErrTemplateExecute.WithError(err)
}
// Set formatted content back to chat management
chatManage.UserContent = userContent.String()
pipelineInfo(ctx, "IntoChatMessage", "output", map[string]interface{}{
"session_id": chatManage.SessionID,
"user_content_len": len(chatManage.UserContent),
})
return next()
}
@@ -99,7 +118,9 @@ func enrichContentWithImageInfo(ctx context.Context, content string, imageInfoJS
var imageInfos []types.ImageInfo
err := json.Unmarshal([]byte(imageInfoJSON), &imageInfos)
if err != nil {
logger.Warnf(ctx, "Failed to parse ImageInfo: %v, using content only", err)
pipelineWarn(ctx, "IntoChatMessage", "image_parse_error", map[string]interface{}{
"error": err.Error(),
})
return content
}
@@ -125,7 +146,9 @@ func enrichContentWithImageInfo(ctx context.Context, content string, imageInfoJS
// 用于存储已处理的图片URL
processedURLs := make(map[string]bool)
logger.Infof(ctx, "Found %d Markdown image links in content", len(matches))
pipelineInfo(ctx, "IntoChatMessage", "image_markdown_links", map[string]interface{}{
"match_count": len(matches),
})
// 替换每个图片链接,添加描述和OCR文本
for _, match := range matches {
@@ -184,8 +207,10 @@ func enrichContentWithImageInfo(ctx context.Context, content string, imageInfoJS
content += "附加图片信息:\n" + strings.Join(additionalImageTexts, "\n")
}
logger.Debugf(ctx, "Enhanced content with image info: found %d Markdown images, added %d additional images",
len(matches), len(additionalImageTexts))
pipelineInfo(ctx, "IntoChatMessage", "image_enrich_summary", map[string]interface{}{
"markdown_images": len(matches),
"additional_imgs": len(additionalImageTexts),
})
return content
}
@@ -5,16 +5,20 @@ import (
"encoding/json"
"sort"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// PluginMerge handles merging of search result chunks
type PluginMerge struct{}
type PluginMerge struct {
chunkRepo interfaces.ChunkRepository
}
// NewPluginMerge creates and registers a new PluginMerge instance
func NewPluginMerge(eventManager *EventManager) *PluginMerge {
res := &PluginMerge{}
func NewPluginMerge(eventManager *EventManager, chunkRepo interfaces.ChunkRepository) *PluginMerge {
res := &PluginMerge{
chunkRepo: chunkRepo,
}
eventManager.Register(res)
return res
}
@@ -28,19 +32,29 @@ func (p *PluginMerge) ActivationEvents() []types.EventType {
func (p *PluginMerge) OnEvent(ctx context.Context,
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
) *PluginError {
logger.Info(ctx, "Starting chunk merge process")
pipelineInfo(ctx, "Merge", "input", map[string]interface{}{
"session_id": chatManage.SessionID,
"candidate_cnt": len(chatManage.RerankResult),
})
// Use rerank results if available, fallback to search results
searchResult := chatManage.RerankResult
if len(searchResult) == 0 {
logger.Info(ctx, "No rerank results available, using search results")
pipelineWarn(ctx, "Merge", "fallback", map[string]interface{}{
"reason": "empty_rerank_result",
})
searchResult = chatManage.SearchResult
}
logger.Infof(ctx, "Processing %d chunks for merging", len(searchResult))
pipelineInfo(ctx, "Merge", "candidate_ready", map[string]interface{}{
"chunk_cnt": len(searchResult),
})
if len(searchResult) == 0 {
logger.Info(ctx, "No chunks available for merging")
pipelineWarn(ctx, "Merge", "output", map[string]interface{}{
"chunk_cnt": 0,
"reason": "no_candidates",
})
return next()
}
@@ -50,12 +64,17 @@ func (p *PluginMerge) OnEvent(ctx context.Context,
knowledgeGroup[chunk.KnowledgeID] = append(knowledgeGroup[chunk.KnowledgeID], chunk)
}
logger.Infof(ctx, "Grouped chunks by knowledge ID, %d knowledge sources", len(knowledgeGroup))
pipelineInfo(ctx, "Merge", "group_summary", map[string]interface{}{
"knowledge_cnt": len(knowledgeGroup),
})
mergedChunks := []*types.SearchResult{}
// Process each knowledge source separately
for knowledgeID, chunks := range knowledgeGroup {
logger.Infof(ctx, "Processing knowledge ID: %s with %d chunks", knowledgeID, len(chunks))
pipelineInfo(ctx, "Merge", "group_process", map[string]interface{}{
"knowledge_id": knowledgeID,
"chunk_cnt": len(chunks),
})
// Sort chunks by their start position in the original document
sort.Slice(chunks, func(i, j int) bool {
@@ -92,7 +111,10 @@ func (p *PluginMerge) OnEvent(ctx context.Context,
// 合并 ImageInfo
if err := mergeImageInfo(ctx, lastChunk, chunks[i]); err != nil {
logger.Warnf(ctx, "Failed to merge ImageInfo: %v", err)
pipelineWarn(ctx, "Merge", "image_merge", map[string]interface{}{
"knowledge_id": knowledgeID,
"error": err.Error(),
})
}
}
if chunks[i].Score > lastChunk.Score {
@@ -100,8 +122,10 @@ func (p *PluginMerge) OnEvent(ctx context.Context,
}
}
logger.Infof(ctx, "Merged %d chunks into %d chunks for knowledge ID: %s",
len(chunks), len(knowledgeMergedChunks), knowledgeID)
pipelineInfo(ctx, "Merge", "group_output", map[string]interface{}{
"knowledge_id": knowledgeID,
"merged_chunks": len(knowledgeMergedChunks),
})
mergedChunks = append(mergedChunks, knowledgeMergedChunks...)
}
@@ -111,7 +135,11 @@ func (p *PluginMerge) OnEvent(ctx context.Context,
return mergedChunks[i].Score > mergedChunks[j].Score
})
logger.Infof(ctx, "Final merged result: %d chunks, sorted by score", len(mergedChunks))
pipelineInfo(ctx, "Merge", "output", map[string]interface{}{
"merged_total": len(mergedChunks),
})
mergedChunks = p.expandShortContextWithNeighbors(ctx, chatManage, mergedChunks)
chatManage.MergeResult = mergedChunks
return next()
@@ -126,7 +154,9 @@ func mergeImageInfo(ctx context.Context, target *types.SearchResult, source *typ
var sourceImageInfos []types.ImageInfo
if err := json.Unmarshal([]byte(source.ImageInfo), &sourceImageInfos); err != nil {
logger.Warnf(ctx, "Failed to unmarshal source ImageInfo: %v", err)
pipelineWarn(ctx, "Merge", "image_unmarshal_source", map[string]interface{}{
"error": err.Error(),
})
return err
}
@@ -139,7 +169,9 @@ func mergeImageInfo(ctx context.Context, target *types.SearchResult, source *typ
var targetImageInfos []types.ImageInfo
if target.ImageInfo != "" {
if err := json.Unmarshal([]byte(target.ImageInfo), &targetImageInfos); err != nil {
logger.Warnf(ctx, "Failed to unmarshal target ImageInfo: %v", err)
pipelineWarn(ctx, "Merge", "image_unmarshal_target", map[string]interface{}{
"error": err.Error(),
})
// 如果目标解析失败,直接使用源数据
target.ImageInfo = source.ImageInfo
return nil
@@ -164,12 +196,345 @@ func mergeImageInfo(ctx context.Context, target *types.SearchResult, source *typ
// 序列化合并后的ImageInfo
mergedImageInfoJSON, err := json.Marshal(uniqueImageInfos)
if err != nil {
logger.Warnf(ctx, "Failed to marshal merged ImageInfo: %v", err)
pipelineWarn(ctx, "Merge", "image_marshal", map[string]interface{}{
"error": err.Error(),
})
return err
}
// 更新目标chunk的ImageInfo
target.ImageInfo = string(mergedImageInfoJSON)
logger.Infof(ctx, "Successfully merged ImageInfo, total count: %d", len(uniqueImageInfos))
pipelineInfo(ctx, "Merge", "image_merged", map[string]interface{}{
"image_refs": len(uniqueImageInfos),
})
return nil
}
func (p *PluginMerge) expandShortContextWithNeighbors(ctx context.Context, chatManage *types.ChatManage, results []*types.SearchResult) []*types.SearchResult {
const (
minLen = 350
maxLen = 850
)
if len(results) == 0 || p.chunkRepo == nil {
return results
}
tenantID, _ := ctx.Value(types.TenantIDContextKey).(uint)
if tenantID == 0 && chatManage != nil {
tenantID = chatManage.TenantID
}
if tenantID == 0 {
pipelineWarn(ctx, "Merge", "expand_skip", map[string]interface{}{
"reason": "missing_tenant",
})
return results
}
type targetInfo struct {
result *types.SearchResult
}
targets := make([]targetInfo, 0)
baseIDsSet := make(map[string]struct{})
for _, r := range results {
if r == nil || r.ID == "" || r.Content == "" {
continue
}
if r.ChunkType != string(types.ChunkTypeText) {
continue
}
if runeLen(r.Content) >= minLen {
continue
}
targets = append(targets, targetInfo{result: r})
baseIDsSet[r.ID] = struct{}{}
pipelineInfo(ctx, "Merge", "need_expand", map[string]interface{}{
"chunk_id": r.ID,
"content": r.Content,
"chunk_type": r.ChunkType,
"len": runeLen(r.Content),
})
}
if len(targets) == 0 {
return results
}
baseIDs := make([]string, 0, len(baseIDsSet))
for id := range baseIDsSet {
baseIDs = append(baseIDs, id)
}
chunkMap := make(map[string]*types.Chunk, len(baseIDs))
chunks, err := p.chunkRepo.ListChunksByID(ctx, tenantID, baseIDs)
if err != nil {
pipelineWarn(ctx, "Merge", "expand_list_base_failed", map[string]interface{}{
"error": err.Error(),
})
return results
}
for _, chunk := range chunks {
chunkMap[chunk.ID] = chunk
}
neighborIDsSet := make(map[string]struct{})
for _, chunk := range chunkMap {
if chunk == nil {
continue
}
if chunk.PreChunkID != "" {
if _, exists := chunkMap[chunk.PreChunkID]; !exists {
neighborIDsSet[chunk.PreChunkID] = struct{}{}
}
}
if chunk.NextChunkID != "" {
if _, exists := chunkMap[chunk.NextChunkID]; !exists {
neighborIDsSet[chunk.NextChunkID] = struct{}{}
}
}
}
if len(neighborIDsSet) > 0 {
neighborIDs := make([]string, 0, len(neighborIDsSet))
for id := range neighborIDsSet {
neighborIDs = append(neighborIDs, id)
}
neighbors, err := p.chunkRepo.ListChunksByID(ctx, tenantID, neighborIDs)
if err != nil {
pipelineWarn(ctx, "Merge", "expand_list_neighbor_failed", map[string]interface{}{
"error": err.Error(),
})
} else {
for _, chunk := range neighbors {
chunkMap[chunk.ID] = chunk
pipelineInfo(ctx, "Merge", "expand_list_neighbor_success", map[string]interface{}{
"neighbor_chunk_id": chunk.ID,
"neighbor_content": chunk.Content,
"neighbor_chunk_type": chunk.ChunkType,
"neighbor_len": runeLen(chunk.Content),
})
}
}
}
for _, target := range targets {
res := target.result
p.fetchChunksIfMissing(ctx, tenantID, chunkMap, res.ID)
baseChunk := chunkMap[res.ID]
if baseChunk == nil || baseChunk.Content == "" || baseChunk.ChunkType != types.ChunkTypeText {
continue
}
prevContent := ""
nextContent := ""
prevIDs := []string{}
nextIDs := []string{}
prevCursor := baseChunk.PreChunkID
nextCursor := baseChunk.NextChunkID
p.fetchChunksIfMissing(ctx, tenantID, chunkMap, prevCursor, nextCursor)
if prevCursor != "" {
if prevChunk := chunkMap[prevCursor]; prevChunk != nil && prevChunk.KnowledgeID == baseChunk.KnowledgeID {
prevContent = prevChunk.Content
prevIDs = append(prevIDs, prevChunk.ID)
prevCursor = prevChunk.PreChunkID
} else {
prevCursor = ""
}
}
if nextCursor != "" {
if nextChunk := chunkMap[nextCursor]; nextChunk != nil && nextChunk.KnowledgeID == baseChunk.KnowledgeID {
nextContent = nextChunk.Content
nextIDs = append(nextIDs, nextChunk.ID)
nextCursor = nextChunk.NextChunkID
} else {
nextCursor = ""
}
}
var merged string
for {
merged = mergeOrderedContent(prevContent, baseChunk.Content, nextContent, maxLen)
if merged == "" {
break
}
if runeLen(merged) >= minLen {
break
}
if prevCursor == "" && nextCursor == "" {
break
}
expanded := false
if prevCursor != "" {
p.fetchChunksIfMissing(ctx, tenantID, chunkMap, prevCursor)
if prevChunk := chunkMap[prevCursor]; prevChunk != nil && prevChunk.KnowledgeID == baseChunk.KnowledgeID {
prevContent = concatNoOverlap(prevChunk.Content, prevContent)
prevIDs = append([]string{prevChunk.ID}, prevIDs...)
prevCursor = prevChunk.PreChunkID
expanded = true
} else {
prevCursor = ""
}
}
merged = mergeOrderedContent(prevContent, baseChunk.Content, nextContent, maxLen)
if runeLen(merged) >= minLen {
break
}
if nextCursor != "" {
p.fetchChunksIfMissing(ctx, tenantID, chunkMap, nextCursor)
if nextChunk := chunkMap[nextCursor]; nextChunk != nil && nextChunk.KnowledgeID == baseChunk.KnowledgeID {
nextContent = concatNoOverlap(nextContent, nextChunk.Content)
nextIDs = append(nextIDs, nextChunk.ID)
nextCursor = nextChunk.NextChunkID
expanded = true
} else {
nextCursor = ""
}
}
if !expanded {
break
}
}
if merged == "" {
continue
}
beforeLen := runeLen(res.Content)
res.Content = merged
for _, id := range prevIDs {
if id != "" && !containsID(res.SubChunkID, id) {
res.SubChunkID = append(res.SubChunkID, id)
}
}
for _, id := range nextIDs {
if id != "" && !containsID(res.SubChunkID, id) {
res.SubChunkID = append(res.SubChunkID, id)
}
}
if prevContent != "" {
res.StartAt = baseChunk.StartAt - runeLen(prevContent)
if res.StartAt < 0 {
res.StartAt = 0
}
}
res.EndAt = res.StartAt + runeLen(res.Content)
pipelineInfo(ctx, "Merge", "expand_short_chunk", map[string]interface{}{
"chunk_id": res.ID,
"prev_ids": prevIDs,
"next_ids": nextIDs,
"before_len": beforeLen,
"after_len": runeLen(res.Content),
"base_content": baseChunk.Content,
"after_content": res.Content,
"chunk_type": res.ChunkType,
"remaining_prev": prevCursor,
"remaining_next": nextCursor,
})
}
return results
}
func runeLen(s string) int {
return len([]rune(s))
}
func mergeOrderedContent(prev, base, next string, maxLen int) string {
content := base
if prev != "" {
content = concatNoOverlap(prev, content)
}
if next != "" {
content = concatNoOverlap(content, next)
}
runes := []rune(content)
if len(runes) > maxLen {
return string(runes[:maxLen])
}
return content
}
// concatNoOverlap concatenates a and b, removing potential overlapping prefix/suffix
func concatNoOverlap(a, b string) string {
if a == "" {
return b
}
if b == "" {
return a
}
ar := []rune(a)
br := []rune(b)
maxOverlap := minInt(len(ar), len(br))
for k := maxOverlap; k > 0; k-- {
if string(ar[len(ar)-k:]) == string(br[:k]) {
return string(ar) + string(br[k:])
}
}
return string(ar) + string(br)
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func containsID(ids []string, target string) bool {
for _, id := range ids {
if id == target {
return true
}
}
return false
}
func (p *PluginMerge) fetchChunksIfMissing(ctx context.Context, tenantID uint, chunkMap map[string]*types.Chunk, chunkIDs ...string) {
missing := make([]string, 0, len(chunkIDs))
for _, id := range chunkIDs {
if id == "" {
continue
}
if _, exists := chunkMap[id]; !exists {
missing = append(missing, id)
}
}
if len(missing) == 0 {
return
}
chunks, err := p.chunkRepo.ListChunksByID(ctx, tenantID, missing)
if err != nil {
pipelineWarn(ctx, "Merge", "expand_fetch_missing_failed", map[string]interface{}{
"missing_cnt": len(missing),
"error": err.Error(),
})
}
found := make(map[string]struct{}, len(chunks))
for _, chunk := range chunks {
chunkMap[chunk.ID] = chunk
found[chunk.ID] = struct{}{}
}
for _, id := range missing {
if _, ok := found[id]; !ok {
chunkMap[id] = nil
}
}
}
@@ -1,23 +1,26 @@
package chatpipline
import (
"context"
"regexp"
"strings"
"unicode"
"context"
"encoding/json"
"regexp"
"strings"
"unicode"
"unicode/utf8"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/yanyiwu/gojieba"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/yanyiwu/gojieba"
)
// PluginPreprocess Query preprocessing plugin
type PluginPreprocess struct {
config *config.Config
jieba *gojieba.Jieba
stopwords map[string]struct{}
config *config.Config
jieba *gojieba.Jieba
stopwords map[string]struct{}
modelService interfaces.ModelService
}
// Regular expressions for text cleaning
@@ -28,11 +31,14 @@ var (
punctRegex = regexp.MustCompile(`[^\p{L}\p{N}\s]`) // Punctuation marks
)
const maxProcessedTokens = 12
// NewPluginPreprocess Creates a new query preprocessing plugin
func NewPluginPreprocess(
eventManager *EventManager,
config *config.Config,
cleaner interfaces.ResourceCleaner,
eventManager *EventManager,
config *config.Config,
cleaner interfaces.ResourceCleaner,
modelService interfaces.ModelService,
) *PluginPreprocess {
// Use default dictionary for Jieba tokenizer
jieba := gojieba.NewJieba()
@@ -40,11 +46,12 @@ func NewPluginPreprocess(
// Load stopwords from built-in stopword library
stopwords := loadStopwords()
res := &PluginPreprocess{
config: config,
jieba: jieba,
stopwords: stopwords,
}
res := &PluginPreprocess{
config: config,
jieba: jieba,
stopwords: stopwords,
modelService: modelService,
}
// Register resource cleanup function
if cleaner != nil {
@@ -85,25 +92,66 @@ func (p *PluginPreprocess) ActivationEvents() []types.EventType {
// OnEvent Process events
func (p *PluginPreprocess) OnEvent(ctx context.Context, eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError) *PluginError {
if chatManage.RewriteQuery == "" {
return next()
rawQuery := strings.TrimSpace(chatManage.RewriteQuery)
if rawQuery == "" {
return next()
}
pipelineInfo(ctx, "Preprocess", "input", map[string]interface{}{
"session_id": chatManage.SessionID,
"rewrite_query": rawQuery,
})
normalized := normalizeWhitespace(rawQuery)
sanitized := strings.TrimSpace(p.cleanText(normalized))
if sanitized == "" {
sanitized = normalized
}
logger.GetLogger(ctx).Infof("Starting query preprocessing, original query: %s", chatManage.RewriteQuery)
var (
processed = sanitized
strategy = "original"
tokenPreview string
tokenCount int
)
// 1. Basic text cleaning
processed := p.cleanText(chatManage.RewriteQuery)
switch {
case containsChineseCharacters(sanitized):
segments := p.segmentText(sanitized)
tokens := p.selectMeaningfulTokens(segments)
tokenCount = len(tokens)
if len(tokens) >= 2 {
processed = strings.Join(tokens, " ")
strategy = "zh_tokens"
tokenPreview = strings.Join(tokens, ",")
} else {
strategy = "fallback_original"
}
case containsLatinLetters(sanitized):
processed = normalizeLatinQuery(sanitized)
if processed != sanitized {
strategy = "latin_normalize"
}
default:
strategy = "original"
}
// 2. Tokenization
segments := p.segmentText(processed)
if strings.TrimSpace(processed) == "" {
processed = rawQuery
strategy = "fallback_original"
}
// 3. Stopword filtering and reconstruction
filteredSegments := p.filterStopwords(segments)
chatManage.ProcessedQuery = processed
chatManage.QueryIntent = p.detectIntentLLM(ctx, chatManage, sanitized)
// Update preprocessed query
chatManage.ProcessedQuery = strings.Join(filteredSegments, " ")
logger.GetLogger(ctx).Infof("Query preprocessing complete, processed query: %s", chatManage.ProcessedQuery)
pipelineInfo(ctx, "Preprocess", "output", map[string]interface{}{
"session_id": chatManage.SessionID,
"processed_query": processed,
"strategy": strategy,
"token_count": tokenCount,
"token_preview": truncateForLog(tokenPreview),
"query_intent": chatManage.QueryIntent,
})
return next()
}
@@ -136,32 +184,137 @@ func (p *PluginPreprocess) segmentText(text string) []string {
}
// filterStopwords Filter stopwords
func (p *PluginPreprocess) filterStopwords(segments []string) []string {
var filtered []string
func (p *PluginPreprocess) selectMeaningfulTokens(segments []string) []string {
var tokens []string
seen := make(map[string]struct{})
for _, word := range segments {
// If not a stopword and not blank, keep it
if _, isStopword := p.stopwords[word]; !isStopword && !isBlank(word) {
filtered = append(filtered, word)
word = strings.TrimSpace(word)
if word == "" {
continue
}
if _, stop := p.stopwords[word]; stop {
continue
}
if _, exists := seen[word]; exists {
continue
}
if !isInformativeToken(word) {
continue
}
seen[word] = struct{}{}
tokens = append(tokens, word)
if len(tokens) >= maxProcessedTokens {
break
}
}
// If filtering results in empty list, return original tokenization results
if len(filtered) == 0 {
return segments
}
return filtered
return tokens
}
// isBlank Check if a string is blank
func isBlank(str string) bool {
for _, r := range str {
if !unicode.IsSpace(r) {
return false
func isInformativeToken(token string) bool {
if token == "" {
return false
}
runeCount := utf8.RuneCountInString(token)
if runeCount == 1 {
r, _ := utf8.DecodeRuneInString(token)
if unicode.IsDigit(r) {
return true
}
if r <= unicode.MaxASCII && unicode.IsLetter(r) {
return true
}
return false
}
return true
}
func containsChineseCharacters(text string) bool {
for _, r := range text {
if unicode.Is(unicode.Han, r) {
return true
}
}
return true
return false
}
func containsLatinLetters(text string) bool {
for _, r := range text {
if r <= unicode.MaxASCII && unicode.IsLetter(r) {
return true
}
}
return false
}
func normalizeWhitespace(text string) string {
text = strings.TrimSpace(text)
return multiSpaceRegex.ReplaceAllString(text, " ")
}
func normalizeLatinQuery(text string) string {
text = strings.ToLower(text)
text = multiSpaceRegex.ReplaceAllString(text, " ")
return strings.TrimSpace(text)
}
type intentResp struct {
Intent string `json:"intent"`
Confidence float64 `json:"confidence"`
}
func (p *PluginPreprocess) detectIntentLLM(ctx context.Context, chatManage *types.ChatManage, text string) string {
if p.modelService == nil || chatManage.ChatModelID == "" {
pipelineWarn(ctx, "IntentDetect", "skip", map[string]interface{}{ "reason": "no_model", "session_id": chatManage.SessionID })
return "general"
}
chatModel, err := p.modelService.GetChatModel(ctx, chatManage.ChatModelID)
if err != nil {
pipelineWarn(ctx, "IntentDetect", "get_model_failed", map[string]interface{}{ "error": err.Error(), "model_id": chatManage.ChatModelID })
return "general"
}
pipelineInfo(ctx, "IntentDetect", "start", map[string]interface{}{ "session_id": chatManage.SessionID, "model_id": chatManage.ChatModelID })
sys := "You are a query intent classifier. Classify the user's query into one of: definition, howto, compare, qa, general. Respond ONLY with a JSON object {\"intent\": \"...\", \"confidence\": 0.0 } inside a markdown fenced block."
usr := text
think := false
resp, err := chatModel.Chat(ctx, []chat.Message{
{Role: "system", Content: sys},
{Role: "user", Content: usr},
}, &chat.ChatOptions{Temperature: 0.0, MaxCompletionTokens: 64, Thinking: &think})
if err != nil || resp.Content == "" {
pipelineWarn(ctx, "IntentDetect", "model_call_failed", map[string]interface{}{ "error": err })
return "general"
}
body := extractJSONBody(resp.Content)
var ir intentResp
if err := json.Unmarshal([]byte(body), &ir); err != nil {
pipelineWarn(ctx, "IntentDetect", "parse_failed", map[string]interface{}{ "body": truncateForLog(body), "error": err.Error() })
return "general"
}
pipelineInfo(ctx, "IntentDetect", "result", map[string]interface{}{ "intent": ir.Intent, "confidence": ir.Confidence })
switch strings.ToLower(strings.TrimSpace(ir.Intent)) {
case "definition", "howto", "compare", "qa", "general":
return strings.ToLower(ir.Intent)
default:
return "general"
}
}
func extractJSONBody(text string) string {
t := strings.TrimSpace(text)
// Try fenced block first
if i := strings.Index(t, "{"); i >= 0 {
j := strings.LastIndex(t, "}")
if j > i {
return t[i : j+1]
}
}
return "{}"
}
// Ensure resources are properly released
@@ -1,16 +1,15 @@
package chatpipline
import (
"context"
"encoding/json"
"fmt"
"math"
"strings"
"context"
"encoding/json"
"fmt"
"math"
"strings"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/rerank"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/Tencent/WeKnora/internal/models/rerank"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// PluginRerank implements reranking functionality for chat pipeline
@@ -34,28 +33,43 @@ func (p *PluginRerank) ActivationEvents() []types.EventType {
// OnEvent handles reranking events in the chat pipeline
func (p *PluginRerank) OnEvent(ctx context.Context,
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
) *PluginError {
logger.Info(ctx, "Starting reranking process")
logger.Infof(ctx, "Getting rerank model, model ID: %s", chatManage.RerankModelID)
pipelineInfo(ctx, "Rerank", "input", map[string]interface{}{
"session_id": chatManage.SessionID,
"candidate_cnt": len(chatManage.SearchResult),
"rerank_model": chatManage.RerankModelID,
"rerank_thresh": chatManage.RerankThreshold,
"rewrite_query": chatManage.RewriteQuery,
"processed_query": chatManage.ProcessedQuery,
})
if len(chatManage.SearchResult) == 0 {
logger.Infof(ctx, "No search result, skip reranking")
pipelineInfo(ctx, "Rerank", "skip", map[string]interface{}{
"reason": "empty_search_result",
})
return next()
}
if chatManage.RerankModelID == "" {
logger.Warn(ctx, "Rerank model ID is empty, skipping reranking")
pipelineWarn(ctx, "Rerank", "skip", map[string]interface{}{
"reason": "empty_model_id",
})
return next()
}
// Get rerank model from service
rerankModel, err := p.modelService.GetRerankModel(ctx, chatManage.RerankModelID)
if err != nil {
logger.Errorf(ctx, "Failed to get rerank model: %v, rerank model ID: %s", err, chatManage.RerankModelID)
pipelineError(ctx, "Rerank", "get_model", map[string]interface{}{
"model_id": chatManage.RerankModelID,
"error": err.Error(),
})
return ErrGetRerankModel.WithError(err)
}
// Prepare passages for reranking
logger.Infof(ctx, "Preparing passages for reranking, search result count: %d", len(chatManage.SearchResult))
pipelineInfo(ctx, "Rerank", "build_passages", map[string]interface{}{
"candidate_cnt": len(chatManage.SearchResult),
})
var passages []string
for _, result := range chatManage.SearchResult {
// 合并Content和ImageInfo的文本内容
@@ -72,41 +86,79 @@ func (p *PluginRerank) OnEvent(ctx context.Context,
}
}
// Update search results with reranked scores
logger.Infof(ctx, "Filtered rerank results, original: %d, filtered: %d", len(rerankResp), len(rerankResp))
result := []*types.SearchResult{}
for _, rr := range rerankResp {
chatManage.SearchResult[rr.Index].Score = rr.RelevanceScore
result = append(result, chatManage.SearchResult[rr.Index])
}
chatManage.RerankResult = result
pipelineInfo(ctx, "Rerank", "model_response", map[string]interface{}{
"result_cnt": len(rerankResp),
})
for i := range chatManage.SearchResult {
chatManage.SearchResult[i].Metadata = ensureMetadata(chatManage.SearchResult[i].Metadata)
}
reranked := make([]*types.SearchResult, 0, len(rerankResp))
for _, rr := range rerankResp {
sr := chatManage.SearchResult[rr.Index]
base := sr.Score
sr.Metadata["base_score"] = fmt.Sprintf("%.4f", base)
sr.Score = rr.RelevanceScore
sr.Score = compositeScore(sr, rr.RelevanceScore, base, chatManage)
reranked = append(reranked, sr)
}
final := applyMMR(ctx, reranked, chatManage, min(len(reranked), max(1, chatManage.RerankTopK)), 0.7)
chatManage.RerankResult = final
if len(chatManage.RerankResult) == 0 {
logger.Warn(ctx, "Reranking produced no results above threshold")
return ErrSearchNothing
}
// Log composite top scores and MMR selection summary
topN := min(3, len(reranked))
for i := 0; i < topN; i++ {
pipelineInfo(ctx, "Rerank", "composite_top", map[string]interface{}{
"rank": i + 1,
"chunk_id": reranked[i].ID,
"base_score": reranked[i].Metadata["base_score"],
"final_score": fmt.Sprintf("%.4f", reranked[i].Score),
"intent": chatManage.QueryIntent,
})
}
logger.Infof(ctx, "Reranking process completed successfully, result count: %d", len(chatManage.RerankResult))
return next()
if len(chatManage.RerankResult) == 0 {
pipelineWarn(ctx, "Rerank", "output", map[string]interface{}{
"filtered_cnt": 0,
})
return ErrSearchNothing
}
pipelineInfo(ctx, "Rerank", "output", map[string]interface{}{
"filtered_cnt": len(chatManage.RerankResult),
})
return next()
}
// rerank performs the actual reranking operation with given query and passages
func (p *PluginRerank) rerank(ctx context.Context,
chatManage *types.ChatManage, rerankModel rerank.Reranker, query string, passages []string,
chatManage *types.ChatManage, rerankModel rerank.Reranker, query string, passages []string,
) []rerank.RankResult {
logger.Infof(ctx, "Executing reranking with query: %s, passage count: %d", query, len(passages))
pipelineInfo(ctx, "Rerank", "model_call", map[string]interface{}{
"query_variant": query,
"passages": len(passages),
})
rerankResp, err := rerankModel.Rerank(ctx, query, passages)
if err != nil {
logger.Errorf(ctx, "Reranking failed: %v", err)
pipelineError(ctx, "Rerank", "model_call", map[string]interface{}{
"query_variant": query,
"error": err.Error(),
})
return nil
}
// Log top scores for debugging
logger.Infof(ctx, "Reranking completed, filtering results with threshold: %f", chatManage.RerankThreshold)
for i := range min(3, len(rerankResp)) {
logger.Infof(ctx, "Top %d score of rerankResp: %f, passages: %s, index: %d",
i+1, rerankResp[i].RelevanceScore, rerankResp[i].Document.Text, rerankResp[i].Index,
)
pipelineInfo(ctx, "Rerank", "threshold", map[string]interface{}{
"threshold": chatManage.RerankThreshold,
})
for i := range min(5, len(rerankResp)) {
pipelineInfo(ctx, "Rerank", "top_score", map[string]interface{}{
"rank": i + 1,
"score": rerankResp[i].RelevanceScore,
"chunk_id": chatManage.SearchResult[rerankResp[i].Index].ID,
"match_type": chatManage.SearchResult[rerankResp[i].Index].MatchType,
"chunk_type": chatManage.SearchResult[rerankResp[i].Index].ChunkType,
"content": chatManage.SearchResult[rerankResp[i].Index].Content,
})
}
// Filter results based on threshold with special handling for history matches
@@ -121,7 +173,149 @@ func (p *PluginRerank) rerank(ctx context.Context,
rankFilter = append(rankFilter, result)
}
}
return rankFilter
return rankFilter
}
func ensureMetadata(m map[string]string) map[string]string {
if m == nil {
return make(map[string]string)
}
return m
}
func compositeScore(sr *types.SearchResult, modelScore, baseScore float64, chatManage *types.ChatManage) float64 {
sourceWeight := 1.0
switch strings.ToLower(sr.KnowledgeSource) {
case "web_search":
sourceWeight = 0.95
default:
sourceWeight = 1.0
}
intentBoost := 1.0
if chatManage.QueryIntent != "" {
switch chatManage.QueryIntent {
case "definition":
if sr.ChunkType == string(types.ChunkTypeSummary) {
intentBoost = 1.05
}
case "howto":
if sr.EndAt-sr.StartAt > 300 {
intentBoost = 1.03
}
case "compare":
intentBoost = 1.0
}
}
positionPrior := 1.0
if sr.StartAt >= 0 {
positionPrior += clampFloat(1.0-float64(sr.StartAt)/float64(sr.EndAt+1), -0.05, 0.05)
}
composite := 0.6*modelScore + 0.3*baseScore + 0.1*sourceWeight
composite *= intentBoost
composite *= positionPrior
if composite < 0 {
composite = 0
}
if composite > 1 {
composite = 1
}
return composite
}
func applyMMR(ctx context.Context, results []*types.SearchResult, chatManage *types.ChatManage, k int, lambda float64) []*types.SearchResult {
if k <= 0 || len(results) == 0 {
return nil
}
pipelineInfo(ctx, "Rerank", "mmr_start", map[string]interface{}{
"lambda": lambda,
"k": k,
"candidates": len(results),
})
selected := make([]*types.SearchResult, 0, k)
candidates := make([]*types.SearchResult, len(results))
copy(candidates, results)
tokenSets := make([]map[string]struct{}, len(candidates))
for i, r := range candidates {
tokenSets[i] = tokenizeSimple(getEnrichedPassage(ctx, r))
}
for len(selected) < k && len(candidates) > 0 {
bestIdx := 0
bestScore := -1.0
for i, r := range candidates {
relevance := r.Score
redundancy := 0.0
for _, s := range selected {
redundancy = math.Max(redundancy, jaccard(tokenSets[i], tokenizeSimple(getEnrichedPassage(ctx, s))))
}
mmr := lambda*relevance - (1.0-lambda)*redundancy
if mmr > bestScore {
bestScore = mmr
bestIdx = i
}
}
selected = append(selected, candidates[bestIdx])
candidates = append(candidates[:bestIdx], candidates[bestIdx+1:]...)
}
// Compute average redundancy among selected
avgRed := 0.0
if len(selected) > 1 {
pairs := 0
for i := 0; i < len(selected); i++ {
for j := i + 1; j < len(selected); j++ {
si := tokenizeSimple(getEnrichedPassage(ctx, selected[i]))
sj := tokenizeSimple(getEnrichedPassage(ctx, selected[j]))
avgRed += jaccard(si, sj)
pairs++
}
}
if pairs > 0 {
avgRed /= float64(pairs)
}
}
pipelineInfo(ctx, "Rerank", "mmr_done", map[string]interface{}{
"selected": len(selected),
"avg_redundancy": fmt.Sprintf("%.4f", avgRed),
})
return selected
}
func tokenizeSimple(text string) map[string]struct{} {
text = strings.ToLower(text)
fields := strings.Fields(text)
set := make(map[string]struct{}, len(fields))
for _, f := range fields {
if len(f) > 1 {
set[f] = struct{}{}
}
}
return set
}
func jaccard(a, b map[string]struct{}) float64 {
if len(a) == 0 && len(b) == 0 {
return 0
}
inter := 0
for k := range a {
if _, ok := b[k]; ok {
inter++
}
}
union := len(a) + len(b) - inter
if union == 0 {
return 0
}
return float64(inter) / float64(union)
}
func clampFloat(v, minV, maxV float64) float64 {
if v < minV {
return minV
}
if v > maxV {
return maxV
}
return v
}
// getEnrichedPassage 合并Content和ImageInfo的文本内容
@@ -134,7 +328,9 @@ func getEnrichedPassage(ctx context.Context, result *types.SearchResult) string
var imageInfos []types.ImageInfo
err := json.Unmarshal([]byte(result.ImageInfo), &imageInfos)
if err != nil {
logger.Warnf(ctx, "Failed to parse ImageInfo: %v, using content only", err)
pipelineWarn(ctx, "Rerank", "image_info_parse", map[string]interface{}{
"error": err.Error(),
})
return result.Content
}
@@ -164,8 +360,10 @@ func getEnrichedPassage(ctx context.Context, result *types.SearchResult) string
}
combinedText += strings.Join(imageTexts, "\n")
logger.Debugf(ctx, "Enhanced passage with image info: content length %d, image texts length %d",
len(result.Content), len(strings.Join(imageTexts, "\n")))
pipelineInfo(ctx, "Rerank", "image_info_merge", map[string]interface{}{
"content_len": len(result.Content),
"image_len": len(strings.Join(imageTexts, "\n")),
})
return combinedText
}
@@ -12,7 +12,6 @@ import (
"time"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
@@ -58,10 +57,28 @@ func (p *PluginRewrite) OnEvent(ctx context.Context,
// Initialize rewritten query as original query
chatManage.RewriteQuery = chatManage.Query
if !chatManage.EnableRewrite {
pipelineInfo(ctx, "Rewrite", "skip", map[string]interface{}{
"session_id": chatManage.SessionID,
"reason": "rewrite_disabled",
})
return next()
}
pipelineInfo(ctx, "Rewrite", "input", map[string]interface{}{
"session_id": chatManage.SessionID,
"tenant_id": chatManage.TenantID,
"user_query": chatManage.Query,
"enable_rewrite": chatManage.EnableRewrite,
})
// Get conversation history
history, err := p.messageService.GetRecentMessagesBySession(ctx, chatManage.SessionID, 20)
if err != nil {
logger.Errorf(ctx, "Failed to get conversation history, session_id: %s, error: %v", chatManage.SessionID, err)
pipelineWarn(ctx, "Rewrite", "history_fetch", map[string]interface{}{
"session_id": chatManage.SessionID,
"error": err.Error(),
})
}
// Convert historical messages to conversation history structure
@@ -99,22 +116,52 @@ func (p *PluginRewrite) OnEvent(ctx context.Context,
})
// Limit the number of historical records
if len(historyList) > p.config.Conversation.MaxRounds {
historyList = historyList[:p.config.Conversation.MaxRounds]
maxRounds := p.config.Conversation.MaxRounds
if chatManage.MaxRounds > 0 {
maxRounds = chatManage.MaxRounds
}
if len(historyList) > maxRounds {
historyList = historyList[:maxRounds]
}
// Reverse to chronological order
slices.Reverse(historyList)
chatManage.History = historyList
userTmpl, err := template.New("rewriteContent").Parse(p.config.Conversation.RewritePromptUser)
if err != nil {
logger.Errorf(ctx, "Failed to execute template, session_id: %s, error: %v", chatManage.SessionID, err)
if len(historyList) == 0 {
pipelineInfo(ctx, "Rewrite", "skip", map[string]interface{}{
"session_id": chatManage.SessionID,
"reason": "empty_history",
})
return next()
}
systemTmpl, err := template.New("rewriteContent").Parse(p.config.Conversation.RewritePromptSystem)
pipelineInfo(ctx, "Rewrite", "history_ready", map[string]interface{}{
"session_id": chatManage.SessionID,
"history_rounds": len(historyList),
"max_rounds": maxRounds,
})
userPrompt := p.config.Conversation.RewritePromptUser
if chatManage.RewritePromptUser != "" {
userPrompt = chatManage.RewritePromptUser
}
userTmpl, err := template.New("rewriteContent").Parse(userPrompt)
if err != nil {
logger.GetLogger(ctx).Errorf("Failed to execute template, session_id: %s, error: %v", chatManage.SessionID, err)
pipelineError(ctx, "Rewrite", "parse_user_template", map[string]interface{}{
"session_id": chatManage.SessionID,
"error": err.Error(),
})
return next()
}
systemPrompt := p.config.Conversation.RewritePromptSystem
if chatManage.RewritePromptSystem != "" {
systemPrompt = chatManage.RewritePromptSystem
}
systemTmpl, err := template.New("rewriteContent").Parse(systemPrompt)
if err != nil {
pipelineError(ctx, "Rewrite", "parse_system_template", map[string]interface{}{
"session_id": chatManage.SessionID,
"error": err.Error(),
})
return next()
}
currentTime := time.Now().Format("2006-01-02 15:04:05")
@@ -126,7 +173,10 @@ func (p *PluginRewrite) OnEvent(ctx context.Context,
"Conversation": historyList,
})
if err != nil {
logger.GetLogger(ctx).Errorf("Failed to execute template, session_id: %s, error: %v", chatManage.SessionID, err)
pipelineError(ctx, "Rewrite", "render_user_template", map[string]interface{}{
"session_id": chatManage.SessionID,
"error": err.Error(),
})
return next()
}
err = systemTmpl.Execute(&systemContent, map[string]interface{}{
@@ -136,12 +186,19 @@ func (p *PluginRewrite) OnEvent(ctx context.Context,
"Conversation": historyList,
})
if err != nil {
logger.Errorf(ctx, "Failed to execute template, session_id: %s, error: %v", chatManage.SessionID, err)
pipelineError(ctx, "Rewrite", "render_system_template", map[string]interface{}{
"session_id": chatManage.SessionID,
"error": err.Error(),
})
return next()
}
rewriteModel, err := p.modelService.GetChatModel(ctx, chatManage.ChatModelID)
if err != nil {
logger.Errorf(ctx, "Failed to get model, session_id: %s, error: %v", chatManage.SessionID, err)
pipelineError(ctx, "Rewrite", "get_model", map[string]interface{}{
"session_id": chatManage.SessionID,
"chat_model_id": chatManage.ChatModelID,
"error": err.Error(),
})
return next()
}
@@ -162,7 +219,10 @@ func (p *PluginRewrite) OnEvent(ctx context.Context,
Thinking: &thinking,
})
if err != nil {
logger.Errorf(ctx, "Failed to execute model, session_id: %s, error: %v", chatManage.SessionID, err)
pipelineError(ctx, "Rewrite", "model_call", map[string]interface{}{
"session_id": chatManage.SessionID,
"error": err.Error(),
})
return next()
}
@@ -170,7 +230,9 @@ func (p *PluginRewrite) OnEvent(ctx context.Context,
// Update rewritten query
chatManage.RewriteQuery = response.Content
}
logger.GetLogger(ctx).Infof("Rewritten query, session_id: %s, rewrite_query: %s",
chatManage.SessionID, chatManage.RewriteQuery)
pipelineInfo(ctx, "Rewrite", "output", map[string]interface{}{
"session_id": chatManage.SessionID,
"rewrite_query": chatManage.RewriteQuery,
})
return next()
}
@@ -1,16 +1,17 @@
package chatpipline
import (
"context"
"fmt"
"strings"
"sync"
"time"
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// PluginSearch implements search functionality for chat pipeline
@@ -53,23 +54,42 @@ func (p *PluginSearch) ActivationEvents() []types.EventType {
// OnEvent handles search events in the chat pipeline
func (p *PluginSearch) OnEvent(ctx context.Context,
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
) *PluginError {
// Get knowledge base IDs list
knowledgeBaseIDs := chatManage.KnowledgeBaseIDs
if len(knowledgeBaseIDs) == 0 && chatManage.KnowledgeBaseID != "" {
// Fall back to single knowledge base
knowledgeBaseIDs = []string{chatManage.KnowledgeBaseID}
logger.Infof(ctx, "No KnowledgeBaseIDs provided, falling back to single KB: %s", chatManage.KnowledgeBaseID)
pipelineInfo(ctx, "Search", "fallback_kb", map[string]interface{}{
"session_id": chatManage.SessionID,
"kb_id": chatManage.KnowledgeBaseID,
})
}
if len(knowledgeBaseIDs) == 0 {
logger.Errorf(ctx, "No knowledge base IDs available for search")
pipelineError(ctx, "Search", "kb_not_found", map[string]interface{}{
"session_id": chatManage.SessionID,
})
return ErrSearch.WithError(nil)
}
pipelineInfo(ctx, "Search", "input", map[string]interface{}{
"session_id": chatManage.SessionID,
"rewrite_query": chatManage.RewriteQuery,
"processed_query": chatManage.ProcessedQuery,
"kb_ids": strings.Join(knowledgeBaseIDs, ","),
"tenant_id": chatManage.TenantID,
"web_enabled": chatManage.WebSearchEnabled,
})
// Run KB search and web search concurrently
logger.Infof(ctx, "Searching across %d knowledge base(s): %v", len(knowledgeBaseIDs), knowledgeBaseIDs)
pipelineInfo(ctx, "Search", "plan", map[string]interface{}{
"kb_count": len(knowledgeBaseIDs),
"embedding_top_k": chatManage.EmbeddingTopK,
"vector_threshold": chatManage.VectorThreshold,
"keyword_threshold": chatManage.KeywordThreshold,
})
var wg sync.WaitGroup
var mu sync.Mutex
allResults := make([]*types.SearchResult, 0)
@@ -97,30 +117,116 @@ func (p *PluginSearch) OnEvent(ctx context.Context,
}
}()
wg.Wait()
wg.Wait()
chatManage.SearchResult = allResults
chatManage.SearchResult = allResults
// If recall is low, attempt query expansion with keyword-focused search
if len(chatManage.SearchResult) < max(1, chatManage.EmbeddingTopK/2) {
pipelineInfo(ctx, "Search", "recall_low", map[string]interface{}{
"current": len(chatManage.SearchResult),
"threshold": chatManage.EmbeddingTopK / 2,
})
expansions := p.expandQueries(ctx, chatManage)
if len(expansions) > 0 {
pipelineInfo(ctx, "Search", "expansion_start", map[string]interface{}{
"variants": len(expansions),
})
expTopK := max(chatManage.EmbeddingTopK*2, chatManage.RerankTopK*2)
expKwTh := chatManage.KeywordThreshold * 0.8
// Concurrent expansion retrieval across queries and KBs
expResults := make([]*types.SearchResult, 0, expTopK*len(expansions))
var muExp sync.Mutex
var wgExp sync.WaitGroup
jobs := len(expansions) * len(knowledgeBaseIDs)
capSem := 16
if jobs < capSem {
capSem = jobs
}
if capSem <= 0 {
capSem = 1
}
sem := make(chan struct{}, capSem)
pipelineInfo(ctx, "Search", "expansion_concurrency", map[string]interface{}{
"jobs": jobs,
"cap": capSem,
})
for _, q := range expansions {
for _, kbID := range knowledgeBaseIDs {
wgExp.Add(1)
go func(q string, kbID string) {
defer wgExp.Done()
sem <- struct{}{}
defer func() { <-sem }()
paramsExp := types.SearchParams{
QueryText: q,
VectorThreshold: chatManage.VectorThreshold,
KeywordThreshold: expKwTh,
MatchCount: expTopK,
DisableVectorMatch: true,
DisableKeywordsMatch: false,
}
res, err := p.knowledgeBaseService.HybridSearch(ctx, kbID, paramsExp)
if err != nil {
pipelineWarn(ctx, "Search", "expansion_error", map[string]interface{}{
"kb_id": kbID,
"error": err.Error(),
})
return
}
if len(res) > 0 {
pipelineInfo(ctx, "Search", "expansion_hits", map[string]interface{}{
"kb_id": kbID,
"query": truncateForLog(q),
"hits": len(res),
})
muExp.Lock()
expResults = append(expResults, res...)
muExp.Unlock()
}
}(q, kbID)
}
}
wgExp.Wait()
if len(expResults) > 0 {
pipelineInfo(ctx, "Search", "expansion_done", map[string]interface{}{
"added": len(expResults),
})
chatManage.SearchResult = append(chatManage.SearchResult, expResults...)
}
}
}
// Add relevant results from chat history
historyResult := p.getSearchResultFromHistory(chatManage)
if historyResult != nil {
logger.Infof(ctx, "Add history result, result count: %d", len(historyResult))
pipelineInfo(ctx, "Search", "history_hits", map[string]interface{}{
"session_id": chatManage.SessionID,
"history_hits": len(historyResult),
})
chatManage.SearchResult = append(chatManage.SearchResult, historyResult...)
}
// Remove duplicate results
chatManage.SearchResult = removeDuplicateResults(chatManage.SearchResult)
// Remove duplicate results
before := len(chatManage.SearchResult)
chatManage.SearchResult = removeDuplicateResults(chatManage.SearchResult)
pipelineInfo(ctx, "Search", "dedup_summary", map[string]interface{}{
"before": before,
"after": len(chatManage.SearchResult),
})
// Return if we have results
if len(chatManage.SearchResult) != 0 {
logger.Infof(
ctx,
"Get search results, count: %d, session_id: %s",
len(chatManage.SearchResult), chatManage.SessionID,
)
pipelineInfo(ctx, "Search", "output", map[string]interface{}{
"session_id": chatManage.SessionID,
"result_count": len(chatManage.SearchResult),
})
return next()
}
logger.Infof(ctx, "No search result, session_id: %s", chatManage.SessionID)
pipelineWarn(ctx, "Search", "output", map[string]interface{}{
"session_id": chatManage.SessionID,
"result_count": 0,
})
return ErrSearchNothing
}
@@ -143,15 +249,52 @@ func (p *PluginSearch) getSearchResultFromHistory(chatManage *types.ChatManage)
}
func removeDuplicateResults(results []*types.SearchResult) []*types.SearchResult {
seen := make(map[string]bool)
var uniqueResults []*types.SearchResult
for _, result := range results {
if !seen[result.ID] {
seen[result.ID] = true
uniqueResults = append(uniqueResults, result)
}
}
return uniqueResults
seen := make(map[string]bool)
contentSig := make(map[string]bool)
var uniqueResults []*types.SearchResult
for _, r := range results {
keys := []string{r.ID}
if r.ParentChunkID != "" {
keys = append(keys, "parent:"+r.ParentChunkID)
}
if r.KnowledgeID != "" {
keys = append(keys, fmt.Sprintf("kb:%s#%d", r.KnowledgeID, r.ChunkIndex))
}
dup := false
for _, k := range keys {
if seen[k] {
dup = true
break
}
}
if dup {
continue
}
sig := buildContentSignature(r.Content)
if sig != "" {
if contentSig[sig] {
continue
}
contentSig[sig] = true
}
for _, k := range keys {
seen[k] = true
}
uniqueResults = append(uniqueResults, r)
}
return uniqueResults
}
func buildContentSignature(content string) string {
c := strings.ToLower(strings.TrimSpace(content))
if c == "" {
return ""
}
c = strings.Join(strings.Fields(c), " ")
if len(c) > 128 {
c = c[:128]
}
return c
}
// searchKnowledgeBases performs KB searches for rewrite and processed queries across KB IDs
@@ -175,10 +318,19 @@ func (p *PluginSearch) searchKnowledgeBases(ctx context.Context, knowledgeBaseID
defer wg.Done()
res, err := p.knowledgeBaseService.HybridSearch(ctx, knowledgeBaseID, baseParams)
if err != nil {
logger.Errorf(ctx, "Failed to search KB %s: %v", knowledgeBaseID, err)
pipelineWarn(ctx, "Search", "kb_search_error", map[string]interface{}{
"kb_id": knowledgeBaseID,
"query": baseParams.QueryText,
"error": err.Error(),
"query_ty": "rewrite",
})
return
}
logger.Infof(ctx, "KB %s search results count: %d", knowledgeBaseID, len(res))
pipelineInfo(ctx, "Search", "kb_result", map[string]interface{}{
"kb_id": knowledgeBaseID,
"query_ty": "rewrite",
"hit_count": len(res),
})
mu.Lock()
results = append(results, res...)
mu.Unlock()
@@ -191,7 +343,9 @@ func (p *PluginSearch) searchKnowledgeBases(ctx context.Context, knowledgeBaseID
if chatManage.RewriteQuery != chatManage.ProcessedQuery {
paramsProcessed := baseParams
paramsProcessed.QueryText = strings.TrimSpace(chatManage.ProcessedQuery)
logger.Infof(ctx, "Searching with processed query: %s", paramsProcessed.QueryText)
pipelineInfo(ctx, "Search", "processed_query_search", map[string]interface{}{
"query": paramsProcessed.QueryText,
})
wg = sync.WaitGroup{}
for _, kbID := range knowledgeBaseIDs {
@@ -200,10 +354,19 @@ func (p *PluginSearch) searchKnowledgeBases(ctx context.Context, knowledgeBaseID
defer wg.Done()
res, err := p.knowledgeBaseService.HybridSearch(ctx, knowledgeBaseID, paramsProcessed)
if err != nil {
logger.Errorf(ctx, "Failed to search KB %s with processed query: %v", knowledgeBaseID, err)
pipelineWarn(ctx, "Search", "kb_search_error", map[string]interface{}{
"kb_id": knowledgeBaseID,
"query": paramsProcessed.QueryText,
"error": err.Error(),
"query_ty": "processed",
})
return
}
logger.Infof(ctx, "KB %s processed query results count: %d", knowledgeBaseID, len(res))
pipelineInfo(ctx, "Search", "kb_result", map[string]interface{}{
"kb_id": knowledgeBaseID,
"query_ty": "processed",
"hit_count": len(res),
})
mu.Lock()
results = append(results, res...)
mu.Unlock()
@@ -212,7 +375,9 @@ func (p *PluginSearch) searchKnowledgeBases(ctx context.Context, knowledgeBaseID
wg.Wait()
}
logger.Infof(ctx, "Total KB results (rewrite + processed): %d", len(results))
pipelineInfo(ctx, "Search", "kb_result_summary", map[string]interface{}{
"total_hits": len(results),
})
return results
}
@@ -223,14 +388,22 @@ func (p *PluginSearch) searchWebIfEnabled(ctx context.Context, chatManage *types
}
tenant := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if tenant == nil || tenant.WebSearchConfig == nil || tenant.WebSearchConfig.Provider == "" {
logger.Warnf(ctx, "Web search enabled but no valid configuration found for tenant %d", chatManage.TenantID)
pipelineWarn(ctx, "Search", "web_config_missing", map[string]interface{}{
"tenant_id": chatManage.TenantID,
})
return nil
}
logger.Infof(ctx, "Performing web search with provider: %s", tenant.WebSearchConfig.Provider)
pipelineInfo(ctx, "Search", "web_request", map[string]interface{}{
"tenant_id": chatManage.TenantID,
"provider": tenant.WebSearchConfig.Provider,
})
webResults, err := p.webSearchService.Search(ctx, tenant.WebSearchConfig, chatManage.RewriteQuery)
if err != nil {
logger.Warnf(ctx, "Web search failed: %v", err)
pipelineWarn(ctx, "Search", "web_search_error", map[string]interface{}{
"tenant_id": chatManage.TenantID,
"error": err.Error(),
})
return nil
}
// Build questions (rewrite + processed if different)
@@ -245,14 +418,18 @@ func (p *PluginSearch) searchWebIfEnabled(ctx context.Context, chatManage *types
p.knowledgeBaseService, p.knowledgeService, seen, ids,
)
if err != nil {
logger.Warnf(ctx, "RAG compression failed, falling back to raw: %v", err)
pipelineWarn(ctx, "Search", "web_compress_error", map[string]interface{}{
"error": err.Error(),
})
} else {
webResults = compressed
// Persist temp KB state back into Redis using SessionService
p.sessionService.SaveWebSearchTempKBState(ctx, chatManage.SessionID, kbID, newSeen, newIDs)
}
res := convertWebSearchResults(webResults)
logger.Infof(ctx, "Web search returned %d results", len(res))
pipelineInfo(ctx, "Search", "web_hits", map[string]interface{}{
"hit_count": len(res),
})
return res
}
@@ -323,3 +500,79 @@ func convertWebSearchResults(webResults []*types.WebSearchResult) []*types.Searc
return results
}
// expandQueries generates paraphrases and synonyms using chat model to improve keyword recall
func (p *PluginSearch) expandQueries(ctx context.Context, chatManage *types.ChatManage) []string {
if p.modelService == nil || chatManage.ChatModelID == "" {
pipelineWarn(ctx, "Search", "expansion_skip", map[string]interface{}{
"reason": "no_model",
})
return nil
}
model, err := p.modelService.GetChatModel(ctx, chatManage.ChatModelID)
if err != nil {
pipelineWarn(ctx, "Search", "expansion_get_model_failed", map[string]interface{}{
"error": err.Error(),
})
return nil
}
sys := "Generate up to 5 diverse paraphrases or keyword variants for the user query to improve keyword-based search recall. Respond ONLY with a JSON array of strings inside a fenced code block."
usr := chatManage.RewriteQuery
think := false
resp, err := model.Chat(ctx, []chat.Message{{Role: "system", Content: sys}, {Role: "user", Content: usr}}, &chat.ChatOptions{Temperature: 0.2, MaxCompletionTokens: 80, Thinking: &think})
if err != nil || resp.Content == "" {
pipelineWarn(ctx, "Search", "expansion_model_call_failed", map[string]interface{}{
"error": err,
})
return nil
}
body := extractJSONBlock(resp.Content)
var arr []string
if err := json.Unmarshal([]byte(body), &arr); err != nil || len(arr) == 0 {
// Fallback: split lines
lines := strings.Split(resp.Content, "\n")
for _, l := range lines {
l = strings.TrimSpace(l)
if l != "" {
arr = append(arr, l)
}
}
}
uniq := make(map[string]struct{})
base := []string{chatManage.Query, chatManage.RewriteQuery, chatManage.ProcessedQuery}
for _, b := range base {
if s := strings.TrimSpace(b); s != "" {
uniq[strings.ToLower(s)] = struct{}{}
}
}
expansions := make([]string, 0, len(arr))
for _, a := range arr {
s := strings.TrimSpace(a)
if s == "" {
continue
}
key := strings.ToLower(s)
if _, ok := uniq[key]; ok {
continue
}
uniq[key] = struct{}{}
expansions = append(expansions, s)
if len(expansions) >= 5 {
break
}
}
pipelineInfo(ctx, "Search", "expansion_result", map[string]interface{}{
"variants": len(expansions),
})
return expansions
}
func extractJSONBlock(text string) string {
t := strings.TrimSpace(text)
if i := strings.Index(t, "["); i >= 0 {
j := strings.LastIndex(t, "]")
if j > i {
return t[i : j+1]
}
}
return "[]"
}
@@ -7,7 +7,6 @@ import (
"strings"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/google/uuid"
)
@@ -31,11 +30,17 @@ func (p *PluginStreamFilter) ActivationEvents() []types.EventType {
func (p *PluginStreamFilter) OnEvent(ctx context.Context,
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
) *PluginError {
logger.Info(ctx, "Starting stream filter")
pipelineInfo(ctx, "StreamFilter", "input", map[string]interface{}{
"session_id": chatManage.SessionID,
"has_event_bus": chatManage.EventBus != nil,
"no_match_prefix": chatManage.SummaryConfig.NoMatchPrefix,
})
// EventBus is required
if chatManage.EventBus == nil {
logger.Error(ctx, "EventBus is required but not available")
pipelineError(ctx, "StreamFilter", "eventbus_missing", map[string]interface{}{
"session_id": chatManage.SessionID,
})
return ErrModelCall.WithError(errors.New("EventBus is required for stream filtering"))
}
eventBus := chatManage.EventBus
@@ -44,13 +49,17 @@ func (p *PluginStreamFilter) OnEvent(ctx context.Context,
matchNoMatchBuilderPrefix := chatManage.SummaryConfig.NoMatchPrefix != ""
if matchNoMatchBuilderPrefix {
logger.Infof(ctx, "Using no match prefix filter: %s", chatManage.SummaryConfig.NoMatchPrefix)
pipelineInfo(ctx, "StreamFilter", "enable_prefix_filter", map[string]interface{}{
"prefix": chatManage.SummaryConfig.NoMatchPrefix,
})
// Create an event interceptor for prefix filtering
return p.filterEventsWithPrefix(ctx, chatManage, eventBus, next)
}
// No filtering needed, just pass through
logger.Info(ctx, "No prefix filtering required, passing through")
pipelineInfo(ctx, "StreamFilter", "passthrough", map[string]interface{}{
"session_id": chatManage.SessionID,
})
return next()
}
@@ -61,7 +70,9 @@ func (p *PluginStreamFilter) filterEventsWithPrefix(
originalEventBus types.EventBusInterface,
next func() *PluginError,
) *PluginError {
logger.Info(ctx, "Setting up event-based stream filtering with NoMatchPrefix")
pipelineInfo(ctx, "StreamFilter", "setup_temp_bus", map[string]interface{}{
"session_id": chatManage.SessionID,
})
// Create a temporary EventBus to intercept events
tempEventBus := event.NewEventBus()
@@ -81,7 +92,9 @@ func (p *PluginStreamFilter) filterEventsWithPrefix(
// Check if content does NOT match the no-match prefix (meaning it's valid content)
if !strings.HasPrefix(chatManage.SummaryConfig.NoMatchPrefix, responseBuilder.String()) {
logger.Infof(ctx, "Content does not match no-match prefix, emitting valid content: %s", responseBuilder.String())
pipelineInfo(ctx, "StreamFilter", "emit_valid_chunk", map[string]interface{}{
"chunk_len": len(responseBuilder.String()),
})
// Emit the accumulated content as valid answer
originalEventBus.Emit(ctx, types.Event{
@@ -104,7 +117,9 @@ func (p *PluginStreamFilter) filterEventsWithPrefix(
// After pipeline completes, check if we need fallback
if !matchFound && responseBuilder.Len() > 0 {
logger.Info(ctx, "Content matches no-match prefix, emitting fallback response")
pipelineInfo(ctx, "StreamFilter", "emit_fallback", map[string]interface{}{
"session_id": chatManage.SessionID,
})
fallbackID := fmt.Sprintf("%s-fallback", uuid.New().String()[:8])
originalEventBus.Emit(ctx, types.Event{
ID: fallbackID,
@@ -77,7 +77,7 @@ func (p *PluginTracing) OnEvent(ctx context.Context,
// Search traces search operations in the chat pipeline
func (p *PluginTracing) Search(ctx context.Context,
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
) *PluginError {
_, span := tracing.ContextWithSpan(ctx, "PluginTracing.Search")
defer span.End()
@@ -87,18 +87,23 @@ func (p *PluginTracing) Search(ctx context.Context,
attribute.Float64("keyword_threshold", chatManage.KeywordThreshold),
attribute.Int("match_count", chatManage.EmbeddingTopK),
)
err := next()
searchResultJson, _ := json.Marshal(chatManage.SearchResult)
span.SetAttributes(
attribute.String("hybrid_search", string(searchResultJson)),
attribute.String("processed_query", chatManage.ProcessedQuery),
)
return err
err := next()
searchResultJson, _ := json.Marshal(chatManage.SearchResult)
unique := make(map[string]struct{})
for _, r := range chatManage.SearchResult {
unique[r.ID] = struct{}{}
}
span.SetAttributes(
attribute.String("hybrid_search", string(searchResultJson)),
attribute.String("processed_query", chatManage.ProcessedQuery),
attribute.Int("search_unique_count", len(unique)),
)
return err
}
// Rerank traces rerank operations in the chat pipeline
func (p *PluginTracing) Rerank(ctx context.Context,
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
) *PluginError {
_, span := tracing.ContextWithSpan(ctx, "PluginTracing.Rerank")
defer span.End()
@@ -109,13 +114,14 @@ func (p *PluginTracing) Rerank(ctx context.Context,
attribute.Float64("rerank_filter_threshold", chatManage.RerankThreshold),
attribute.Int("rerank_filter_topk", chatManage.RerankTopK),
)
err := next()
resultJson, _ := json.Marshal(chatManage.RerankResult)
span.SetAttributes(
attribute.Int("rerank_resp_count", len(chatManage.RerankResult)),
attribute.String("rerank_resp_results", string(resultJson)),
)
return err
err := next()
resultJson, _ := json.Marshal(chatManage.RerankResult)
span.SetAttributes(
attribute.Int("rerank_resp_count", len(chatManage.RerankResult)),
attribute.String("rerank_resp_results", string(resultJson)),
attribute.String("query_intent", chatManage.QueryIntent),
)
return err
}
// Merge traces merge operations in the chat pipeline
+4 -1
View File
@@ -261,6 +261,7 @@ func (e *EvaluationService) Evaluation(ctx context.Context,
VectorThreshold: e.config.Conversation.VectorThreshold,
KeywordThreshold: e.config.Conversation.KeywordThreshold,
EmbeddingTopK: e.config.Conversation.EmbeddingTopK,
MaxRounds: e.config.Conversation.MaxRounds,
RerankModelID: rerankModelID,
RerankTopK: e.config.Conversation.RerankTopK,
RerankThreshold: e.config.Conversation.RerankThreshold,
@@ -279,7 +280,9 @@ func (e *EvaluationService) Evaluation(ctx context.Context,
Seed: e.config.Conversation.Summary.Seed,
MaxCompletionTokens: e.config.Conversation.Summary.MaxCompletionTokens,
},
FallbackResponse: e.config.Conversation.FallbackResponse,
FallbackResponse: e.config.Conversation.FallbackResponse,
RewritePromptSystem: e.config.Conversation.RewritePromptSystem,
RewritePromptUser: e.config.Conversation.RewritePromptUser,
},
}
@@ -372,6 +372,41 @@ func (s *knowledgeBaseService) HybridSearch(ctx context.Context,
return nil, err
}
// Normalize keyword retriever scores into [0,1] per-engine batch
for i := range retrieveResults {
rr := retrieveResults[i]
if rr.Error != nil || rr.RetrieverType != types.KeywordsRetrieverType || len(rr.Results) == 0 {
continue
}
minS := rr.Results[0].Score
maxS := rr.Results[0].Score
for _, r := range rr.Results {
if r.Score < minS {
minS = r.Score
}
if r.Score > maxS {
maxS = r.Score
}
}
if maxS > minS {
for _, r := range rr.Results {
ns := (r.Score - minS) / (maxS - minS)
if ns < 0 {
ns = 0
} else if ns > 1 {
ns = 1
}
r.Score = ns
}
logger.Infof(ctx, "Normalized keyword scores for engine %s: min=%f, max=%f", rr.RetrieverEngineType, minS, maxS)
} else {
for _, r := range rr.Results {
r.Score = 1.0
}
logger.Infof(ctx, "Keyword scores have no variance for engine %s, set to 1.0", rr.RetrieverEngineType)
}
}
// Collect all results from different retrievers and deduplicate by chunk ID
logger.Infof(ctx, "Processing retrieval results")
matchResults := []*types.IndexWithScore{}
+113 -39
View File
@@ -436,40 +436,100 @@ func (s *sessionService) KnowledgeQA(ctx context.Context, session *types.Session
return err
}
rewritePromptSystem := s.cfg.Conversation.RewritePromptSystem
rewritePromptUser := s.cfg.Conversation.RewritePromptUser
var tenantConv *types.ConversationConfig
if tc, err := getTenantConversationConfig(ctx); err == nil {
tenantConv = tc
} else {
logger.Warnf(ctx, "Failed to load tenant conversation config, tenant ID: %d, error: %v", session.TenantID, err)
}
vectorThreshold := session.VectorThreshold
keywordThreshold := session.KeywordThreshold
embeddingTopK := session.EmbeddingTopK
rerankModelID := session.RerankModelID
rerankTopK := session.RerankTopK
rerankThreshold := session.RerankThreshold
maxRounds := session.MaxRounds
fallbackResponse := session.FallbackResponse
enableRewrite := session.EnableRewrite
summaryParams := session.SummaryParameters
if summaryParams == nil {
summaryParams = &types.SummaryConfig{}
}
summaryConfig := types.SummaryConfig{
MaxTokens: summaryParams.MaxTokens,
RepeatPenalty: summaryParams.RepeatPenalty,
TopK: summaryParams.TopK,
TopP: summaryParams.TopP,
FrequencyPenalty: summaryParams.FrequencyPenalty,
PresencePenalty: summaryParams.PresencePenalty,
Prompt: summaryParams.Prompt,
ContextTemplate: summaryParams.ContextTemplate,
Temperature: summaryParams.Temperature,
Seed: summaryParams.Seed,
NoMatchPrefix: summaryParams.NoMatchPrefix,
MaxCompletionTokens: summaryParams.MaxCompletionTokens,
}
if tenantConv != nil {
vectorThreshold = tenantConv.VectorThreshold
keywordThreshold = tenantConv.KeywordThreshold
embeddingTopK = tenantConv.EmbeddingTopK
rerankModelID = tenantConv.RerankModelID
rerankTopK = tenantConv.RerankTopK
rerankThreshold = tenantConv.RerankThreshold
maxRounds = tenantConv.MaxRounds
fallbackResponse = tenantConv.FallbackResponse
enableRewrite = tenantConv.EnableRewrite
if tenantConv.MaxTokens != 0 {
summaryConfig.MaxTokens = tenantConv.MaxTokens
}
if tenantConv.Prompt != "" {
summaryConfig.Prompt = tenantConv.Prompt
}
if tenantConv.ContextTemplate != "" {
summaryConfig.ContextTemplate = tenantConv.ContextTemplate
}
if tenantConv.Temperature != 0 {
summaryConfig.Temperature = tenantConv.Temperature
}
if tenantConv.RewritePromptSystem != "" {
rewritePromptSystem = tenantConv.RewritePromptSystem
}
if tenantConv.RewritePromptUser != "" {
rewritePromptUser = tenantConv.RewritePromptUser
}
}
// Create chat management object with session settings
logger.Infof(ctx, "Creating chat manage object, knowledge base IDs: %v, chat model ID: %s", knowledgeBaseIDs, chatModelID)
chatManage := &types.ChatManage{
Query: query,
RewriteQuery: query,
SessionID: session.ID,
MessageID: assistantMessageID, // NEW: For event emission in pipeline
KnowledgeBaseID: knowledgeBaseIDs[0], // For backward compatibility, use first KB ID
KnowledgeBaseIDs: knowledgeBaseIDs, // Multi-KB support
VectorThreshold: session.VectorThreshold,
KeywordThreshold: session.KeywordThreshold,
EmbeddingTopK: session.EmbeddingTopK,
RerankModelID: session.RerankModelID,
RerankTopK: session.RerankTopK,
RerankThreshold: session.RerankThreshold,
ChatModelID: chatModelID,
SummaryConfig: types.SummaryConfig{
MaxTokens: session.SummaryParameters.MaxTokens,
RepeatPenalty: session.SummaryParameters.RepeatPenalty,
TopK: session.SummaryParameters.TopK,
TopP: session.SummaryParameters.TopP,
FrequencyPenalty: session.SummaryParameters.FrequencyPenalty,
PresencePenalty: session.SummaryParameters.PresencePenalty,
Prompt: session.SummaryParameters.Prompt,
ContextTemplate: session.SummaryParameters.ContextTemplate,
Temperature: session.SummaryParameters.Temperature,
Seed: session.SummaryParameters.Seed,
NoMatchPrefix: session.SummaryParameters.NoMatchPrefix,
MaxCompletionTokens: session.SummaryParameters.MaxCompletionTokens,
},
FallbackResponse: session.FallbackResponse,
EventBus: eventBus.AsEventBusInterface(), // NEW: For pipeline to emit events directly
WebSearchEnabled: webSearchEnabled,
TenantID: session.TenantID,
Query: query,
RewriteQuery: query,
SessionID: session.ID,
MessageID: assistantMessageID, // NEW: For event emission in pipeline
KnowledgeBaseID: knowledgeBaseIDs[0], // For backward compatibility, use first KB ID
KnowledgeBaseIDs: knowledgeBaseIDs, // Multi-KB support
VectorThreshold: vectorThreshold,
KeywordThreshold: keywordThreshold,
EmbeddingTopK: embeddingTopK,
RerankModelID: rerankModelID,
RerankTopK: rerankTopK,
RerankThreshold: rerankThreshold,
MaxRounds: maxRounds,
ChatModelID: chatModelID,
SummaryConfig: summaryConfig,
FallbackResponse: fallbackResponse,
EventBus: eventBus.AsEventBusInterface(), // NEW: For pipeline to emit events directly
WebSearchEnabled: webSearchEnabled,
TenantID: session.TenantID,
RewritePromptSystem: rewritePromptSystem,
RewritePromptUser: rewritePromptUser,
EnableRewrite: enableRewrite,
}
// Start knowledge QA event processing
@@ -676,6 +736,17 @@ func (s *sessionService) KnowledgeQAByEvent(ctx context.Context,
return nil
}
func getTenantConversationConfig(ctx context.Context) (*types.ConversationConfig, error) {
tenant := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if tenant == nil {
return nil, errors.New("tenant is empty")
}
if tenant.ConversationConfig == nil {
return nil, errors.New("tenant has no conversation config")
}
return tenant.ConversationConfig, nil
}
// SearchKnowledge performs knowledge base search without LLM summarization
func (s *sessionService) SearchKnowledge(ctx context.Context,
knowledgeBaseID, query string,
@@ -685,14 +756,17 @@ func (s *sessionService) SearchKnowledge(ctx context.Context,
// Create default retrieval parameters
chatManage := &types.ChatManage{
Query: query,
RewriteQuery: query,
KnowledgeBaseID: knowledgeBaseID,
VectorThreshold: s.cfg.Conversation.VectorThreshold, // Use default configuration
KeywordThreshold: s.cfg.Conversation.KeywordThreshold, // Use default configuration
EmbeddingTopK: s.cfg.Conversation.EmbeddingTopK, // Use default configuration
RerankTopK: s.cfg.Conversation.RerankTopK, // Use default configuration
RerankThreshold: s.cfg.Conversation.RerankThreshold, // Use default configuration
Query: query,
RewriteQuery: query,
KnowledgeBaseID: knowledgeBaseID,
VectorThreshold: s.cfg.Conversation.VectorThreshold, // Use default configuration
KeywordThreshold: s.cfg.Conversation.KeywordThreshold, // Use default configuration
EmbeddingTopK: s.cfg.Conversation.EmbeddingTopK, // Use default configuration
RerankTopK: s.cfg.Conversation.RerankTopK, // Use default configuration
RerankThreshold: s.cfg.Conversation.RerankThreshold, // Use default configuration
MaxRounds: s.cfg.Conversation.MaxRounds,
RewritePromptSystem: s.cfg.Conversation.RewritePromptSystem,
RewritePromptUser: s.cfg.Conversation.RewritePromptUser,
}
// Get default models
+1 -1
View File
@@ -124,7 +124,7 @@ func BuildContainer(container *dig.Container) *dig.Container {
// Session service (depends on agent service)
// SessionService is created after AgentService and passes itself to AgentService.CreateAgentEngine when needed
must(container.Provide(service.NewSessionService))
must(container.Provide(service.NewSessionService))
must(container.Provide(router.NewAsyncqClient))
must(container.Provide(router.NewAsynqServer))
@@ -444,7 +444,7 @@ func (h *AgentStreamHandler) handleComplete(ctx context.Context, evt event.Event
"total_duration_ms": data.TotalDurationMs,
},
}); err != nil {
logger.GetLogger(h.ctx).Error("Append complete event to stream failed", "error", err)
logger.GetLogger(h.ctx).Errorf("Append complete event to stream failed: %v", err)
}
return nil
+42 -12
View File
@@ -1,6 +1,7 @@
package session
import (
"context"
"net/http"
"github.com/Tencent/WeKnora/internal/config"
@@ -103,18 +104,8 @@ func (h *Handler) CreateSession(c *gin.Context) {
logger.Debug(ctx, "Custom session strategy set")
} else {
// Use default configuration from global config
createdSession.MaxRounds = h.config.Conversation.MaxRounds
createdSession.EnableRewrite = h.config.Conversation.EnableRewrite
createdSession.FallbackStrategy = types.FallbackStrategy(h.config.Conversation.FallbackStrategy)
createdSession.FallbackResponse = h.config.Conversation.FallbackResponse
createdSession.EmbeddingTopK = h.config.Conversation.EmbeddingTopK
createdSession.KeywordThreshold = h.config.Conversation.KeywordThreshold
createdSession.VectorThreshold = h.config.Conversation.VectorThreshold
createdSession.RerankThreshold = h.config.Conversation.RerankThreshold
createdSession.RerankTopK = h.config.Conversation.RerankTopK
createdSession.SummaryParameters = h.createDefaultSummaryConfig(ctx)
tenantInfo, _ := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
h.applyConversationDefaults(ctx, createdSession, tenantInfo)
logger.Debug(ctx, "Using default session strategy")
}
@@ -159,6 +150,45 @@ func (h *Handler) CreateSession(c *gin.Context) {
})
}
func (h *Handler) applyConversationDefaults(ctx context.Context, session *types.Session, tenant *types.Tenant) {
session.MaxRounds = h.config.Conversation.MaxRounds
session.EnableRewrite = h.config.Conversation.EnableRewrite
session.FallbackStrategy = types.FallbackStrategy(h.config.Conversation.FallbackStrategy)
session.FallbackResponse = h.config.Conversation.FallbackResponse
session.EmbeddingTopK = h.config.Conversation.EmbeddingTopK
session.KeywordThreshold = h.config.Conversation.KeywordThreshold
session.VectorThreshold = h.config.Conversation.VectorThreshold
session.RerankThreshold = h.config.Conversation.RerankThreshold
session.RerankTopK = h.config.Conversation.RerankTopK
session.RerankModelID = ""
session.SummaryModelID = ""
if tenant != nil && tenant.ConversationConfig != nil {
tc := tenant.ConversationConfig
session.MaxRounds = tc.MaxRounds
session.EnableRewrite = tc.EnableRewrite
if tc.FallbackStrategy != "" {
session.FallbackStrategy = types.FallbackStrategy(tc.FallbackStrategy)
}
if tc.FallbackResponse != "" {
session.FallbackResponse = tc.FallbackResponse
}
session.EmbeddingTopK = tc.EmbeddingTopK
session.KeywordThreshold = tc.KeywordThreshold
session.VectorThreshold = tc.VectorThreshold
session.RerankThreshold = tc.RerankThreshold
session.RerankTopK = tc.RerankTopK
if tc.RerankModelID != "" {
session.RerankModelID = tc.RerankModelID
}
if tc.SummaryModelID != "" {
session.SummaryModelID = tc.SummaryModelID
}
}
session.SummaryParameters = h.createDefaultSummaryConfig(ctx)
}
// GetSession retrieves a session by its ID
func (h *Handler) GetSession(c *gin.Context) {
ctx := c.Request.Context()
+21 -5
View File
@@ -185,10 +185,20 @@ func (h *Handler) createDefaultSummaryConfig(ctx context.Context) *types.Summary
// Override with tenant-level conversation config if available
if tenant != nil && tenant.ConversationConfig != nil {
if tenant.ConversationConfig.Prompt != "" {
useSystemPrompt := tenant.ConversationConfig.UseCustomSystemPrompt
if !useSystemPrompt && tenant.ConversationConfig.Prompt != "" {
// Backward compatibility: treat legacy configs without flag as custom
useSystemPrompt = true
}
if useSystemPrompt && tenant.ConversationConfig.Prompt != "" {
cfg.Prompt = tenant.ConversationConfig.Prompt
}
if tenant.ConversationConfig.ContextTemplate != "" {
useContextTemplate := tenant.ConversationConfig.UseCustomContextTemplate
if !useContextTemplate && tenant.ConversationConfig.ContextTemplate != "" {
useContextTemplate = true
}
if useContextTemplate && tenant.ConversationConfig.ContextTemplate != "" {
cfg.ContextTemplate = tenant.ConversationConfig.ContextTemplate
}
if tenant.ConversationConfig.Temperature > 0 {
@@ -214,9 +224,15 @@ func (h *Handler) fillSummaryConfigDefaults(ctx context.Context, config *types.S
var defaultMaxTokens int
if tenant != nil && tenant.ConversationConfig != nil {
// Use tenant-level config as defaults
defaultPrompt = tenant.ConversationConfig.Prompt
defaultContextTemplate = tenant.ConversationConfig.ContextTemplate
useSystemPrompt := tenant.ConversationConfig.UseCustomSystemPrompt
if useSystemPrompt && tenant.ConversationConfig.Prompt != "" {
defaultPrompt = tenant.ConversationConfig.Prompt
}
useContextTemplate := tenant.ConversationConfig.UseCustomContextTemplate
if useContextTemplate && tenant.ConversationConfig.ContextTemplate != "" {
defaultContextTemplate = tenant.ConversationConfig.ContextTemplate
}
defaultTemperature = tenant.ConversationConfig.Temperature
defaultMaxTokens = tenant.ConversationConfig.MaxTokens
}
+10
View File
@@ -443,6 +443,16 @@ func (h *Handler) handleKnowledgeQARequest(
if data.Done {
logger.Infof(asyncCtx, "Knowledge QA service completed for session: %s", sessionID)
h.completeAssistantMessage(asyncCtx, assistantMessage)
// Emit completion event when stream finishes
if err := eventBus.Emit(asyncCtx, event.Event{
Type: event.EventAgentComplete,
SessionID: sessionID,
Data: event.AgentCompleteData{
FinalAnswer: assistantMessage.Content,
},
}); err != nil {
logger.Errorf(asyncCtx, "Failed to emit completion event: %v", err)
}
cancel() // Clean up context
return nil
}
+141 -29
View File
@@ -510,6 +510,62 @@ func (h *TenantHandler) GetTenantWebSearchConfig(c *gin.Context) {
})
}
func (h *TenantHandler) buildDefaultConversationConfig() *types.ConversationConfig {
return &types.ConversationConfig{
Prompt: h.config.Conversation.Summary.Prompt,
ContextTemplate: h.config.Conversation.Summary.ContextTemplate,
UseCustomContextTemplate: true,
UseCustomSystemPrompt: true,
Temperature: h.config.Conversation.Summary.Temperature,
MaxTokens: h.config.Conversation.Summary.MaxTokens,
MaxRounds: h.config.Conversation.MaxRounds,
EmbeddingTopK: h.config.Conversation.EmbeddingTopK,
KeywordThreshold: h.config.Conversation.KeywordThreshold,
VectorThreshold: h.config.Conversation.VectorThreshold,
RerankTopK: h.config.Conversation.RerankTopK,
RerankThreshold: h.config.Conversation.RerankThreshold,
EnableRewrite: h.config.Conversation.EnableRewrite,
FallbackStrategy: h.config.Conversation.FallbackStrategy,
FallbackResponse: h.config.Conversation.FallbackResponse,
FallbackPrompt: h.config.Conversation.FallbackPrompt,
RewritePromptUser: h.config.Conversation.RewritePromptUser,
RewritePromptSystem: h.config.Conversation.RewritePromptSystem,
}
}
func validateConversationConfig(req *types.ConversationConfig) error {
if req.MaxRounds <= 0 {
return errors.NewBadRequestError("max_rounds must be greater than 0")
}
if req.EmbeddingTopK <= 0 {
return errors.NewBadRequestError("embedding_top_k must be greater than 0")
}
if req.KeywordThreshold < 0 || req.KeywordThreshold > 1 {
return errors.NewBadRequestError("keyword_threshold must be between 0 and 1")
}
if req.VectorThreshold < 0 || req.VectorThreshold > 1 {
return errors.NewBadRequestError("vector_threshold must be between 0 and 1")
}
if req.RerankTopK <= 0 {
return errors.NewBadRequestError("rerank_top_k must be greater than 0")
}
if req.RerankThreshold < 0 || req.RerankThreshold > 1 {
return errors.NewBadRequestError("rerank_threshold must be between 0 and 1")
}
if req.Temperature < 0 || req.Temperature > 2 {
return errors.NewBadRequestError("temperature must be between 0 and 2")
}
if req.MaxTokens <= 0 || req.MaxTokens > 100000 {
return errors.NewBadRequestError("max_tokens must be between 1 and 100000")
}
if req.FallbackStrategy != "" &&
req.FallbackStrategy != string(types.FallbackStrategyFixed) &&
req.FallbackStrategy != string(types.FallbackStrategyModel) {
return errors.NewBadRequestError("fallback_strategy is invalid")
}
return nil
}
// GetTenantConversationConfig retrieves the conversation configuration for a tenant
// This is the global conversation configuration that applies to normal mode sessions by default
func (h *TenantHandler) GetTenantConversationConfig(c *gin.Context) {
@@ -524,29 +580,94 @@ func (h *TenantHandler) GetTenantConversationConfig(c *gin.Context) {
}
// If tenant has no conversation config, return defaults from config.yaml
var response *types.ConversationConfig
if tenant.ConversationConfig == nil {
logger.Info(ctx, "Tenant has no conversation config, returning defaults")
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"prompt": h.config.Conversation.Summary.Prompt,
"context_template": h.config.Conversation.Summary.ContextTemplate,
"temperature": h.config.Conversation.Summary.Temperature,
"max_tokens": h.config.Conversation.Summary.MaxTokens,
},
})
return
response = h.buildDefaultConversationConfig()
} else {
logger.Infof(ctx, "Tenant has conversation config, merging with defaults, Tenant ID: %d", tenant.ID)
// Merge tenant config with defaults, so that newly added fields always have valid values
defaultCfg := h.buildDefaultConversationConfig()
tc := tenant.ConversationConfig
// Prompt related
defaultCfg.UseCustomSystemPrompt = tc.UseCustomSystemPrompt
if !defaultCfg.UseCustomSystemPrompt && tc.Prompt != "" {
// Legacy configs without explicit flag
defaultCfg.UseCustomSystemPrompt = true
}
defaultCfg.UseCustomContextTemplate = tc.UseCustomContextTemplate
if !defaultCfg.UseCustomContextTemplate && tc.ContextTemplate != "" {
defaultCfg.UseCustomContextTemplate = true
}
if tc.Prompt != "" {
defaultCfg.Prompt = tc.Prompt
}
if tc.ContextTemplate != "" {
defaultCfg.ContextTemplate = tc.ContextTemplate
}
if tc.Temperature > 0 {
defaultCfg.Temperature = tc.Temperature
}
if tc.MaxTokens > 0 {
defaultCfg.MaxTokens = tc.MaxTokens
}
// Retrieval parameters
if tc.MaxRounds > 0 {
defaultCfg.MaxRounds = tc.MaxRounds
}
if tc.EmbeddingTopK > 0 {
defaultCfg.EmbeddingTopK = tc.EmbeddingTopK
}
if tc.KeywordThreshold > 0 {
defaultCfg.KeywordThreshold = tc.KeywordThreshold
}
if tc.VectorThreshold > 0 {
defaultCfg.VectorThreshold = tc.VectorThreshold
}
if tc.RerankTopK > 0 {
defaultCfg.RerankTopK = tc.RerankTopK
}
if tc.RerankThreshold > 0 {
defaultCfg.RerankThreshold = tc.RerankThreshold
}
// EnableRewrite 需要允许显式关闭,因此直接覆盖
defaultCfg.EnableRewrite = tc.EnableRewrite
// Model IDs
if tc.SummaryModelID != "" {
defaultCfg.SummaryModelID = tc.SummaryModelID
}
if tc.RerankModelID != "" {
defaultCfg.RerankModelID = tc.RerankModelID
}
// Fallback settings
if tc.FallbackStrategy != "" {
defaultCfg.FallbackStrategy = tc.FallbackStrategy
}
if tc.FallbackResponse != "" {
defaultCfg.FallbackResponse = tc.FallbackResponse
}
if tc.FallbackPrompt != "" {
defaultCfg.FallbackPrompt = tc.FallbackPrompt
}
// Rewrite prompts
if tc.RewritePromptSystem != "" {
defaultCfg.RewritePromptSystem = tc.RewritePromptSystem
}
if tc.RewritePromptUser != "" {
defaultCfg.RewritePromptUser = tc.RewritePromptUser
}
response = defaultCfg
}
logger.Infof(ctx, "Retrieved tenant conversation config successfully, Tenant ID: %d", tenant.ID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"prompt": tenant.ConversationConfig.Prompt,
"context_template": tenant.ConversationConfig.ContextTemplate,
"temperature": tenant.ConversationConfig.Temperature,
"max_tokens": tenant.ConversationConfig.MaxTokens,
},
"data": response,
})
}
@@ -564,12 +685,8 @@ func (h *TenantHandler) updateTenantConversationConfigInternal(c *gin.Context) {
}
// Validate configuration
if req.Temperature < 0 || req.Temperature > 2 {
c.Error(errors.NewBadRequestError("temperature must be between 0 and 2"))
return
}
if req.MaxTokens <= 0 || req.MaxTokens > 100000 {
c.Error(errors.NewBadRequestError("max_tokens must be between 1 and 100000"))
if err := validateConversationConfig(&req); err != nil {
c.Error(err)
return
}
@@ -599,12 +716,7 @@ func (h *TenantHandler) updateTenantConversationConfigInternal(c *gin.Context) {
logger.Infof(ctx, "Tenant conversation config updated successfully, Tenant ID: %d", tenant.ID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"prompt": updatedTenant.ConversationConfig.Prompt,
"context_template": updatedTenant.ConversationConfig.ContextTemplate,
"temperature": updatedTenant.ConversationConfig.Temperature,
"max_tokens": updatedTenant.ConversationConfig.MaxTokens,
},
"data": updatedTenant.ConversationConfig,
"message": "Conversation configuration updated successfully",
})
}
+16 -4
View File
@@ -6,7 +6,8 @@ type ChatManage struct {
SessionID string `json:"session_id"` // Unique identifier for the chat session
Query string `json:"query,omitempty"` // Original user query
ProcessedQuery string `json:"processed_query,omitempty"` // Query after preprocessing
RewriteQuery string `json:"rewrite_query,omitempty"` // Query after rewriting for better retrieval
RewriteQuery string `json:"rewrite_query,omitempty"` // Query after rewriting for better retrieval
QueryIntent string `json:"query_intent,omitempty"` // Parsed intent: definition/howto/compare/qa/general
History []*History `json:"history,omitempty"` // Chat history for context
KnowledgeBaseID string `json:"knowledge_base_id"` // ID of the knowledge base to search against (deprecated, use KnowledgeBaseIDs)
@@ -20,11 +21,17 @@ type ChatManage struct {
RerankTopK int `json:"rerank_top_k"` // Number of top results after reranking
RerankThreshold float64 `json:"rerank_threshold"` // Minimum score threshold for reranked results
MaxRounds int `json:"max_rounds"` // Maximum history rounds used for rewrite/context
ChatModelID string `json:"chat_model_id"` // ID of the chat model to use
SummaryConfig SummaryConfig `json:"summary_config"` // Configuration for summary generation
FallbackStrategy FallbackStrategy `json:"fallback_strategy"` // Strategy when no relevant results are found
FallbackResponse string `json:"fallback_response"` // Default response when fallback occurs
EnableRewrite bool `json:"enable_rewrite"` // Whether to enable rewrite
RewritePromptSystem string `json:"rewrite_prompt_system"` // Custom system prompt for rewrite stage
RewritePromptUser string `json:"rewrite_prompt_user"` // Custom user prompt for rewrite stage
// Internal fields for pipeline data processing
SearchResult []*SearchResult `json:"-"` // Results from search phase
RerankResult []*SearchResult `json:"-"` // Results after reranking
@@ -52,13 +59,15 @@ func (c *ChatManage) Clone() *ChatManage {
return &ChatManage{
Query: c.Query,
ProcessedQuery: c.ProcessedQuery,
RewriteQuery: c.RewriteQuery,
RewriteQuery: c.RewriteQuery,
QueryIntent: c.QueryIntent,
SessionID: c.SessionID,
KnowledgeBaseID: c.KnowledgeBaseID,
KnowledgeBaseIDs: knowledgeBaseIDs,
VectorThreshold: c.VectorThreshold,
KeywordThreshold: c.KeywordThreshold,
EmbeddingTopK: c.EmbeddingTopK,
MaxRounds: c.MaxRounds,
VectorDatabase: c.VectorDatabase,
RerankModelID: c.RerankModelID,
RerankTopK: c.RerankTopK,
@@ -78,8 +87,11 @@ func (c *ChatManage) Clone() *ChatManage {
Seed: c.SummaryConfig.Seed,
MaxCompletionTokens: c.SummaryConfig.MaxCompletionTokens,
},
FallbackStrategy: c.FallbackStrategy,
FallbackResponse: c.FallbackResponse,
FallbackStrategy: c.FallbackStrategy,
FallbackResponse: c.FallbackResponse,
RewritePromptSystem: c.RewritePromptSystem,
RewritePromptUser: c.RewritePromptUser,
EnableRewrite: c.EnableRewrite,
}
}
+26 -2
View File
@@ -74,14 +74,38 @@ func (c *RetrieverEngines) Scan(value interface{}) error {
// ConversationConfig represents the conversation configuration for normal mode
type ConversationConfig struct {
// Prompt is the system prompt
Prompt string `json:"prompt"`
// Prompt is the system prompt for normal mode
UseCustomSystemPrompt bool `json:"use_custom_system_prompt"`
Prompt string `json:"prompt"`
UseCustomContextTemplate bool `json:"use_custom_context_template"`
// ContextTemplate is the prompt template for summarizing retrieval results
ContextTemplate string `json:"context_template"`
// Temperature controls the randomness of the model output
Temperature float64 `json:"temperature"`
// MaxTokens is the maximum number of tokens to generate
MaxTokens int `json:"max_tokens"`
// Retrieval & strategy parameters
MaxRounds int `json:"max_rounds"`
EmbeddingTopK int `json:"embedding_top_k"`
KeywordThreshold float64 `json:"keyword_threshold"`
VectorThreshold float64 `json:"vector_threshold"`
RerankTopK int `json:"rerank_top_k"`
RerankThreshold float64 `json:"rerank_threshold"`
EnableRewrite bool `json:"enable_rewrite"`
// Model configuration
SummaryModelID string `json:"summary_model_id"`
RerankModelID string `json:"rerank_model_id"`
// Fallback strategy
FallbackStrategy string `json:"fallback_strategy"`
FallbackResponse string `json:"fallback_response"`
FallbackPrompt string `json:"fallback_prompt"`
// Rewrite prompts
RewritePromptSystem string `json:"rewrite_prompt_system"`
RewritePromptUser string `json:"rewrite_prompt_user"`
}
// Value implements the driver.Valuer interface, used to convert ConversationConfig to database value