WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
# 总体架构
本章从部署视角与代码视角两个维度介绍 WeKnora 的整体架构:系统由哪些进程/容器组成、各组件之间如何通信、使用了哪些技术栈,以及代码仓库的顶层目录布局。
## 1. 系统组成
WeKnora 采用"主服务 + 前端 + 文档解析微服务"的三进程核心架构,外加 PostgreSQL 与 Redis 两个基础设施依赖;其余组件(向量库、知识图谱、联网搜索等)均为可选,通过 Docker Compose profile 按需启用。
### 1.1 核心服务(默认启动)
| 服务 | 镜像 / 构建 | 端口 | 职责 |
| --- | --- | --- | --- |
| `app` | `wechatopenai/weknora-app`(`docker/Dockerfile.app`,Go) | `8080` | 主后端:REST API、RAG 检索、Agent 引擎、异步任务 worker、IM/Embed 渠道接入。健康检查 `GET /health` |
| `frontend` | `wechatopenai/weknora-ui`(`frontend/`,NGINX + Vue3 静态产物) | `80` | Web UI;NGINX 同时充当反向代理,将 `/api` 转发到 `app`(`APP_HOST`/`APP_BACKEND_PORT`/`APP_SCHEME` 可指向远端后端) |
| `docreader` | `wechatopenai/weknora-docreader`(`docker/Dockerfile.docreader`,Python) | `50051`(仅 compose 网络内 expose,不映射宿主机) | 文档解析微服务:gRPC 服务端,PDF/DOCX/Excel/EPUB/网页等 25+ 格式解析与页面渲染。健康检查 `grpc_health_probe` |
| `postgres` | `paradedb/paradedb:v0.22.2-pg17` | `5432`(网络内) | 主数据库。ParadeDB 发行版自带 BM25 全文检索与 pgvector 向量能力,因此**默认部署无需独立向量库**(`RETRIEVE_DRIVER=postgres`) |
| `redis` | `redis:7.0-alpine`(`appendonly` + `requirepass`) | `6379`(网络内) | Asynq 任务队列、SSE 流管理(跨实例)、system_settings 发布订阅、限流与分布式模型并发闸门 |
| `sandbox` | `wechatopenai/weknora-sandbox`(`docker/Dockerfile.sandbox`) | — | WeKnora 标准运行镜像;可直接用于空间 Docker 后端,接入 CubeSandbox/E2B 时则通过模板 API 自动注册并用于 Agent Skills |
`app` 与 `docreader` 之间还通过共享卷 `docreader-tmp`(挂载于 `/tmp/docreader`)传递解析产物图片;`app` 的本地文件存储卷为 `data-files`(`/data/files`)。
### 1.2 可选组件(Compose profile)
| 服务 | profile | 用途 |
| --- | --- | --- |
| `searxng`(+ 一次性 `searxng-init`) | `searxng` / `full` | 自托管元搜索引擎,为 Agent 提供 Web Search(默认绑定 `127.0.0.1:8888`) |
| `neo4j` | `neo4j` / `full` | 知识图谱存储(GraphRAG),开关为 `NEO4J_ENABLE`,Bolt 协议 `7687` |
| `minio` | `minio` / `full` | 对象存储(`STORAGE_TYPE=minio`) |
| `qdrant` / `milvus` / `weaviate` | 各自同名 profile | 独立向量库(`RETRIEVE_DRIVER` 切换) |
| `doris-fe` + `doris-be` | `doris` | Apache Doris 4.1 检索引擎(FE MySQL 9030 / FE HTTP 8030 Stream Load / BE 8040) |
| `odl-hybrid` | `odl-hybrid` | OpenDataLoader PDF 混合解析后端(docreader 通过 HTTP `:5002` 调用) |
| `dex` | `dex` / `full` | OIDC 测试用 IdP(配合 `OIDC_AUTH_ENABLE`) |
| `langfuse-*`(web/worker/clickhouse/minio/db-init) | `langfuse` | 自建 LLM 可观测栈,复用 WeKnora 的 postgres(新建 `langfuse` 库)与 redis(DB 1) |
此外,Go 后端还可直连未在 compose 内的外部引擎:Elasticsearch v7/v8、OpenSearch、腾讯云 VectorDB、火山 VikingDB,以及 8 种对象存储(local/MinIO/COS/TOS/S3/OSS/KS3/OBS)。
### 1.3 部署形态
除标准 Docker Compose 部署外,仓库还支持:
- **Lite 模式**:`DB_DRIVER=sqlite`(内置 sqlite-vec 向量扩展)+ 不配置 `REDIS_ADDR`(Asynq 退化为进程内 `SyncTaskExecutor`),单二进制运行,前端静态资源内嵌(`handler.Edition == "lite"` 时由 Go 进程直接托管);
- **桌面版**:`cmd/desktop` 基于 Wails v2 打包为桌面应用;
- **Kubernetes**:`helm/` Chart;**裸机**:`deploy/` systemd 单元;**macOS**:`Formula/` Homebrew 配方。
## 2. 技术栈清单
| 层 | 技术 | 版本/说明 |
| --- | --- | --- |
| 后端语言 | Go | `go.mod` 声明 `go 1.26.0` |
| Web 框架 | `github.com/gin-gonic/gin` | v1.12.0 |
| ORM | `gorm.io/gorm` + postgres/sqlite driver | v1.31.1;SQLite 附带 `sqlite-vec` 向量扩展 |
| 依赖注入 | `go.uber.org/dig` | v1.19.0(构造函数注入,见后端设计篇) |
| 异步任务 | `github.com/hibiken/asynq` | v0.26.0(基于 Redis,6 个 worker 池) |
| 缓存/队列 | `github.com/redis/go-redis/v9` | v9.14.1 |
| 认证 | `github.com/golang-jwt/jwt/v5` + OIDC | JWT Bearer / X-API-Key / OIDC 三态 |
| 数据库迁移 | `github.com/golang-migrate/migrate/v4` | `migrations/versioned/*.up.sql`,启动时 `AUTO_MIGRATE` 自动执行 |
| 日志 | `github.com/sirupsen/logrus` + lumberjack 轮转 | 自研 formatter,request_id 贯穿 |
| 配置 | `github.com/spf13/viper` + `config/config.yaml` + 环境变量 | — |
| 可观测 | OpenTelemetry + Langfuse(`internal/tracing/langfuse`) | LLM 调用级 trace |
| gRPC | `google.golang.org/grpc` v1.81.0 | 调用 docreader |
| LLM 接入 | `sashabaranov/go-openai`、Ollama、腾讯云 LKE 等 | 18+ 模型提供商(OpenAI 兼容 / Ollama / 云厂商 SDK) |
| 向量/检索 | pgvector、ES v7/v8、OpenSearch、Qdrant、Milvus、Weaviate、Doris、腾讯 VectorDB、sqlite-vec | 由 `RETRIEVE_DRIVER` 与 `vector_stores` 表动态装配 |
| 知识图谱 | `neo4j-go-driver/v6` | 可选 |
| 数据分析 | DuckDB(`duckdb-go/v2`)、`pg_query_go` SQL 校验 | Agent 数据分析工具 |
| 协程池 | `panjf2000/ants/v2` | 文档处理并发池(`CONCURRENCY_POOL_SIZE`) |
| MCP | `mark3labs/mcp-go` v0.52.0 | Agent 外接 MCP 工具(含 OAuth) |
| API 文档 | swaggo/gin-swagger | 非 release 模式暴露 `/swagger` |
| 前端框架 | Vue 3(^3.5)+ TypeScript + Vite 7 | `frontend/package.json` |
| 前端 UI/状态 | TDesign Vue Next、Pinia、Vue Router 4、vue-i18n | Marked/KaTeX/Mermaid/highlight.js 渲染富文本 |
| 文档解析服务 | Python + grpcio | `docreader/main.py`;解析器位于 `docreader/parser/`(pdf/docx/excel/epub/web/image/markitdown/opendataloader 等) |
| 桌面端 | Wails v2 | `cmd/desktop` |
## 3. 进程间通信方式
| 链路 | 协议 | 说明 |
| --- | --- | --- |
| 浏览器 → `frontend`(NGINX) → `app` | HTTP/HTTPS(REST + SSE) | NGINX 反代 `/api`;聊天走 SSE 流式响应 |
| `app` → `docreader` | **gRPC**(默认 `docreader:50051`,`DOCREADER_TRANSPORT=grpc`,支持 TLS/mTLS 与 `GRPC_AUTH_TOKEN`) | proto 定义在 `docreader/proto/`;大文件走流式 `ReadStream` |
| `app` → `postgres` | PostgreSQL wire(GORM/pgx) | 业务数据 + BM25 + pgvector |
| `app` ↔ `redis` | RESP(支持 TLS) | ① Asynq 任务队列(文档解析/富化/Wiki 等 19 类任务);② SSE 流断线续传的 Stream Manager(`STREAM_MANAGER_TYPE`);③ `system_settings` 变更 Pub/Sub;④ Embed 渠道限流;⑤ 分布式 per-model 并发信号量 |
| `app` → `neo4j` | Bolt(`bolt://neo4j:7687`) | GraphRAG 实体/关系存取 |
| `app` → `searxng` / Web 搜索 provider | HTTP | SSRF 白名单校验(`SSRF_WHITELIST_EXTRA` 默认放行 compose 内 `searxng,qdrant,milvus,weaviate,doris-fe,doris-be`) |
| `app` → 向量库/对象存储/LLM 提供商 | 各自 SDK(HTTP/gRPC/MySQL 协议) | Doris 走 MySQL 协议 + Stream Load HTTP |
| `app` → `sandbox` | 本地 `docker run` | Skills 代码执行隔离 |
| `app` ↔ IM 平台 | HTTP webhook / 长连接 SDK | 微信、企业微信、飞书、钉钉、Slack、Telegram、QQ、Mattermost、云之家(`internal/im/`) |
## 4. 总体架构图
```mermaid
graph LR
subgraph Clients["客户端"]
Browser["浏览器 (Vue3 SPA)"]
Mini["微信小程序 (miniprogram/)"]
CLI["CLI / Go SDK (cli/, client/)"]
MCPC["MCP 客户端 (mcp-server/)"]
IM["IM 平台 (微信/飞书/钉钉/Slack...)"]
end
subgraph Compose["Docker Compose: WeKnora-network"]
FE["frontend: NGINX + 静态资源 (:80)"]
APP["app: Go 主服务 (:8080)<br/>Gin REST + SSE / Agent 引擎 / Asynq worker"]
DR["docreader: Python gRPC (:50051)<br/>PDF / DOCX / Excel / Web 解析"]
PG[("postgres: ParadeDB pg17<br/>业务数据 + BM25 + pgvector")]
RD[("redis 7<br/>Asynq 队列 / 流管理 / PubSub / 限流")]
SBX["sandbox 容器 (按需 docker run)"]
subgraph Optional["可选 profile"]
SX["searxng (联网搜索)"]
NEO[("neo4j (知识图谱)")]
VDB[("qdrant / milvus / weaviate / doris")]
MINIO[("minio (对象存储)")]
LF["langfuse 可观测栈"]
end
end
EXT["外部服务: LLM API / Elasticsearch / OpenSearch / COS / S3 / OSS ..."]
Browser -->|"HTTP / SSE"| FE
Mini -->|"HTTP"| APP
CLI -->|"HTTP"| APP
MCPC -->|"HTTP (X-API-Key)"| APP
IM -->|"webhook / SDK 长连接"| APP
FE -->|"反向代理 /api"| APP
APP -->|"gRPC ReadStream"| DR
APP -->|"GORM (SQL)"| PG
APP -->|"RESP"| RD
APP -->|"docker run"| SBX
APP -->|"HTTP"| SX
APP -->|"Bolt"| NEO
APP -->|"SDK"| VDB
APP -->|"S3 API"| MINIO
APP -->|"HTTPS"| EXT
APP -.->|"trace 上报"| LF
DR -.->|"共享卷 docreader-tmp"| APP
```
## 5. 典型请求链路:文档上传与解析入库
下图展示一篇文档从上传到可被检索的完整链路,覆盖了绝大多数组件间交互(同步 API、Asynq 异步任务、gRPC 解析、Embedding 与向量写入、富化子任务):
```mermaid
sequenceDiagram
autonumber
participant U as 浏览器
participant N as "frontend (NGINX)"
participant A as "app (Gin Handler 层)"
participant S as "KnowledgeService (Service 层)"
participant R as "Redis (Asynq)"
participant W as "Asynq Worker (app 进程内)"
participant D as "docreader (gRPC)"
participant E as "Embedding 模型 (LLM Provider)"
participant V as "向量库 (pgvector / qdrant ...)"
participant P as "PostgreSQL"
U->>N: POST /api/v1/knowledge-bases/:id/knowledge/file
N->>A: 反向代理
A->>A: "中间件链: RequestID → Auth(JWT/APIKey) → APIKeyGate → RBAC(OwnedKBOrAdmin)"
A->>S: KnowledgeHandler → CreateKnowledgeFromFile
S->>P: "写入 knowledge 行 (parse_status=pending), 文件落盘/对象存储"
S->>R: "Enqueue TypeDocumentProcess (queue=default)"
A-->>U: "202 返回 knowledge_id (前端轮询/订阅进度)"
R->>W: 派发任务 (Core worker pool)
W->>D: "gRPC ReadStream(文件字节/URL)"
D-->>W: "Markdown 文本 + 图片 (含 OCR / 页面渲染)"
W->>W: "分块 Chunking (parent-child / heading 策略)"
W->>E: "批量 Embedding (BatchEmbedder, 受 per-model 并发闸门约束)"
E-->>W: 向量
W->>V: 写入向量索引 + BM25 关键词索引
W->>P: "写入 chunks, parse_status=finalizing"
W->>R: "Enqueue 富化子任务: summary / question / graph (enrichment 队列)"
R->>W: Enrichment worker 消费
W->>P: "回写摘要/问题/实体, PendingSubtasksCount 归零 → parse_status=completed"
```
对话链路(`POST /api/v1/knowledge-chat/:session_id` 或 agent-chat)则为同步 SSE:Handler → `SessionService` → `chat_pipeline` 插件流水线(query 理解 → 并行检索 → rerank → 合并 → Prompt 组装 → LLM 流式补全)→ 通过 Stream Manager(Redis/内存)将 token 流推回客户端,详见后端设计篇。
## 6. 代码仓库顶层目录导览
| 目录 | 职责 |
| --- | --- |
| `cmd/` | 可执行入口。`cmd/server`:主服务(main/bootstrap/listen + 平台信号处理);`cmd/desktop`:Wails 桌面版;`cmd/download`:模型/资源下载辅助工具 |
| `internal/` | Go 后端全部业务代码(分层结构见后端设计篇):`handler`、`application/service`、`application/repository`、`container`(DI)、`router`、`middleware`、`types`、`agent`、`im`、`mcp`、`stream`、`sandbox` 等 |
| `frontend/` | Vue3 + Vite + TDesign 的 Web 前端,构建产物由 NGINX 或 Lite 模式内嵌托管 |
| `docreader/` | Python gRPC 文档解析微服务:`main.py` 服务端入口、`parser/` 25+ 解析器、`splitter/` 分割器、`proto/` 协议定义、独立 `Dockerfile.docreader` 构建 |
| `cli/` | `weknora` 命令行工具(约 30 个子命令:部署、日志、备份、诊断等) |
| `client/` | Go SDK:以 HTTP 客户端形式封装 WeKnora API,供二次开发集成 |
| `mcp-server/` | Python 实现的 MCP Server(`weknora_mcp_server.py`),把 WeKnora API 暴露为 MCP 工具给 Claude 等 MCP 客户端 |
| `miniprogram/` | 微信小程序客户端(WXML/WXSS/JS) |
| `migrations/` | golang-migrate 数据库迁移:`versioned/`(Postgres 主线 `NNNNNN_*.up/down.sql`)、`sqlite/`(Lite 模式)、`paradedb/`、`mysql/` |
| `config/` | 运行配置:`config.yaml` 主配置、`builtin_agents.yaml` 内置 Agent、`agent_type_presets.yaml` Agent 预设、`builtin_models.yaml.example` 声明式内置模型、`prompt_templates/` 提示词模板 |
| `docker/` | 各镜像 Dockerfile(app/docreader/sandbox/odl-hybrid)与 searxng 配置 |
| `deploy/` | 裸机部署资源(systemd 服务单元等) |
| `helm/` | Kubernetes Helm Chart(Chart.yaml / values.yaml / templates/) |
| `skills/` | Agent Skills 技能包目录,`skills/preloaded/` 随镜像预装,可通过挂载 + `WEKNORA_SKILLS_DIR` 免重建扩展 |
| `dataset/` | 评估用 QA 数据集及生成脚本 |
| `examples/` | API 使用示例代码 |
| `scripts/` | 构建/启动/迁移辅助脚本(如 `start_all.sh`、`build_frontend_dist.sh`) |
| `tests/`、`testdata/` | 集成测试与测试数据 |
| `Formula/` | Homebrew 安装配方(macOS) |
| `misc/` | 杂项(如 `dex-config.yaml` OIDC 测试配置) |
| `packages/` | 预留的本地包目录 |
| `docs/` | 早期文档,部分内容已过时 |
> 说明:Go 模块路径为 `github.com/Tencent/WeKnora`;根目录还包含 `docker-compose.yml`(生产编排)与 `docker-compose.dev.yml`(开发编排)、`Makefile`、`VERSION` 等。
下一篇《Go 后端设计》将深入 `internal/` 内部:分层架构、dig 依赖注入、启动流程、路由与 RBAC、中间件、领域模型与错误/日志规范。
@@ -0,0 +1,358 @@
# Go 后端设计
本章深入 WeKnora Go 后端(`internal/` 与 `cmd/server`)的内部设计:分层架构、基于 uber/dig 的依赖注入、启动与优雅退出流程、路由组织与 RBAC 装配、全部 HTTP 中间件、领域模型、错误处理与日志规范,以及公共工具库。
## 1. 分层架构
后端遵循经典的 **Handler → Service → Repository → 数据库** 四层结构,层间依赖全部通过接口(`internal/types/interfaces/`)解耦,由 DI 容器在启动时装配:
| 层 | 位置 | 职责 |
| --- | --- | --- |
| Router / Middleware | `internal/router/`、`internal/middleware/` | 路由注册、认证、RBAC、限流、日志、错误信封 |
| Handler | `internal/handler/`(会话相关在 `internal/handler/session/`) | 解析请求参数(DTO 在 `internal/handler/dto/`)、调用 Service、写响应;不含业务逻辑 |
| Service | `internal/application/service/`(约 160+ 文件) | 业务编排:知识库/知识/分块、会话与 `chat_pipeline/` 流水线、Agent、租户与成员、模型、数据源同步、Wiki、审计等 |
| Repository | `internal/application/repository/`(约 60 文件) | 数据访问,统一使用 **GORM**(`type knowledgeRepository struct { db *gorm.DB }`,操作走 `r.db.WithContext(ctx)`);检索引擎的仓储实现按引擎分包于 `repository/retriever/{postgres,elasticsearch,qdrant,milvus,weaviate,doris,opensearch,tencentvectordb,sqlite,neo4j}` |
| 领域模型 | `internal/types/` | GORM 实体、枚举、context key、接口定义(`types/interfaces`) |
| 基础设施 | `internal/infrastructure/`(docparser gRPC 客户端、web_search)、`internal/models/`(chat/embedding/rerank 模型适配)、`internal/stream/`、`internal/sandbox/`、`internal/mcp/`、`internal/im/` | 外部系统适配 |
```mermaid
graph TD
C["客户端请求"] --> MW["Gin 中间件链<br/>CORS → RequestID → Logger → Recovery → ErrorHandler → Auth → APIKeyGate → RBAC"]
MW --> H["Handler 层 (internal/handler)<br/>参数校验 / DTO 转换"]
H --> S["Service 层 (internal/application/service)<br/>业务编排 / chat_pipeline / 事务"]
S --> R["Repository 层 (internal/application/repository)<br/>GORM 数据访问"]
S --> AG["Agent 引擎 (internal/agent)<br/>think → act → observe"]
S --> Q["TaskEnqueuer (Asynq / SyncTaskExecutor)"]
R --> DB[("PostgreSQL / SQLite (GORM)")]
R --> VS[("检索引擎仓储 repository/retriever/*<br/>pgvector / ES / Qdrant / Milvus / Doris ...")]
S --> INF["基础设施适配<br/>docparser(gRPC) / models(LLM) / stream / mcp / im / sandbox"]
Q --> W["Asynq Worker (同进程, 6 个池)"]
W --> S
```
关键约定:
- Handler 只依赖 Service 接口(如 `interfaces.KnowledgeService`),Service 只依赖 Repository 接口与其他 Service 接口;
- 所有接口集中声明在 `internal/types/interfaces/`,实现方通过 dig 绑定;
- Asynq worker 与 HTTP server 运行在**同一个进程**内,任务处理函数复用同一套 Service。
## 2. 依赖注入:internal/container(uber/dig)
WeKnora 使用 **`go.uber.org/dig` v1.19.0**(构造函数注入容器,非代码生成的 wire)。入口是 `internal/container/container.go` 的 `BuildContainer`:
```go
// cmd/server/main.go
c := container.BuildContainer(runtime.GetContainer())
// internal/container/container.go
func BuildContainer(container *dig.Container) *dig.Container {
must(container.Provide(NewResourceCleaner, dig.As(new(interfaces.ResourceCleaner))))
must(container.Provide(config.LoadConfig))
must(container.Provide(initDatabase)) // *gorm.DB
must(container.Provide(initRedisClient)) // *redis.Client(可为 nil:Lite 模式)
...
must(container.Provide(repository.NewTenantRepository))
must(container.Provide(service.NewTenantService))
...
must(container.Provide(router.NewRouter)) // 最终产出 *gin.Engine
return container
}
```
`runtime.GetContainer()`(`internal/runtime/container.go`)持有全局单例 `dig.Container`;`must(err)` 对注册失败直接 panic——DI 装配错误属于启动期致命错误。
### 2.1 用到的 dig 特性
| 特性 | 用法示例 |
| --- | --- |
| `dig.As` | 把具体类型绑定为接口:`container.Provide(NewResourceCleaner, dig.As(new(interfaces.ResourceCleaner)))`;`router.NewAsyncqClient` 绑定为 `interfaces.TaskEnqueuer` |
| `dig.Name` 命名依赖 | 同一接口多实例:4 个抽取服务(`chunkExtractor`/`dataTableSummary`/`imageMultimodal`/`knowledgePostProcess`)、6 个 Asynq server(`coreAsynqServer`/`postProcessAsynqServer`/`enrichmentAsynqServer`/`maintenanceAsynqServer`/`sharedAsynqServer`/`wikiAsynqServer`)、`wikiIngest` |
| `dig.In` 参数结构体 | `router.RouterParams` 内嵌 `dig.In`,一次性注入约 60 个 Handler/Service 依赖,避免超长构造函数签名 |
| `container.Invoke` 执行副作用 | 注册即启动的后台组件:`registerPoolCleanup`、`registerWebSearchProviders`、`startDataSourceScheduler`、`startHousekeepingService`、`startAuditLogRetention`、`startTemporaryDocumentCleanup`、15 个 `chatpipeline.NewPluginXxx`(Search/Rerank/WebFetch/Merge/DataAnalysis/QueryUnderstand/LoadHistory/ChatCompletionStream 等插件自注册到 EventManager)、`router.RunAsynqServer`、`recoverPendingWikiTasks` 等 |
| 适配器 Provide | 用闭包做接口转换:`func(s *service.StorageBackendService) interfaces.StorageBackendService { return s }`;`RetrieveEngineRegistry` 同实例同时暴露为 `StoreRegistry` |
### 2.2 注册顺序与条件装配
`BuildContainer` 的注册分为九个阶段(源码中有对应日志):① 核心基础设施(config/langfuse/db/file/redis/ants 池)→ ② 检索引擎注册表 → ③ 外部客户端(docreader gRPC、Ollama、Neo4j、StreamManager、DuckDB)→ ④ Repository 层(30+ 个)→ ⑤ Service 层(50+ 个,含 MCP Manager、事件总线、Agent 审批闸门 `approval.Gate`)→ ⑥ **任务执行器条件装配** → ⑦ chat_pipeline 插件 → ⑧ Handler 层(40+ 个)与 IM 适配器 → ⑨ Router 与 Asynq server 启动。
第 ⑥ 步是全仓库最重要的条件分支——**Redis 有无决定运行形态**:
```go
redisAvailable := os.Getenv("REDIS_ADDR") != ""
if redisAvailable {
must(container.Provide(router.NewAsyncqClient, dig.As(new(interfaces.TaskEnqueuer))))
must(container.Provide(router.NewCoreAsynqServer, dig.Name("coreAsynqServer")))
... // 共 6 个 worker 池 + AsynqInspector
must(container.Invoke(registerModelConcurrencyLimiter)) // Redis 分布式 per-model 并发闸门
} else {
syncExec := router.NewSyncTaskExecutor() // Lite 模式:进程内同步执行器
must(container.Provide(func() interfaces.TaskEnqueuer { return syncExec }))
must(container.Provide(router.NewNoopTaskInspector))
must(container.Invoke(registerLiteModelConcurrencyLimiter)) // 进程内信号量
}
```
6 个 Asynq worker 池的并发度可经 system settings / 环境变量调整(默认 Core=8、PostProcess=2、Enrichment=12、Maintenance=4、Shared=6、Wiki=8,`WEKNORA_ASYNQ_*_CONCURRENCY`);队列拓扑定义在 `internal/types/task.go`(default、chat_attachment、postprocess、summary、multimodal、graph、question、sync、low/maintenance、wiki 等,共 19 类任务)。
### 2.3 资源清理与工厂
- `ResourceCleaner`(`internal/container/cleanup.go`):各组件通过 `RegisterWithName(name, cleanupFunc)` 注册析构(ants 池、Langfuse flush、数据源调度器、Housekeeping 等),退出时统一 `Cleanup(ctx)`;
- `EngineFactory`(`internal/container/engine_factory.go`):根据 `vector_stores` 表行运行时创建检索引擎实例(`createQdrantEngine` / `createMilvusEngine` / `createDorisEngine` / `createOpenSearchEngine` ...),而非启动期静态绑定单一引擎;
- `initDatabase` 除建连外还负责:golang-migrate 自动迁移(`AUTO_MIGRATE`,失败仅告警不阻断)、`__pending_env__` 存储 provider 回填、遗留 StorageBackend 迁移、序列同步、Lite 模式 pending 任务复位、`config/builtin_models.yaml` 声明式内置模型 UPSERT;SQLite 时强制 `SetMaxOpenConns(1)` 串行化写入。
## 3. cmd/server 启动流程
`cmd/server` 仅三个逻辑文件:`main.go`(入口与 HTTP 生命周期)、`bootstrap.go`(一次性引导钩子)、`listen.go`(端口重试),另有 `signals_unix.go`/`signals_windows.go` 提供平台化 `shutdownSignals`。
```mermaid
flowchart TD
A["main() 启动"] --> B["设置 GIN_MODE (release/debug)"]
B --> C["runtime.SilenceGinRouteSpam()<br/>抑制 150+ 条路由注册日志"]
C --> D["runtime.LogStartupEnv()<br/>打印环境变量横幅 (先于容器构建, 便于排障)"]
D --> E["container.BuildContainer(runtime.GetContainer())<br/>DI 装配: DB 迁移 / Redis / Asynq / Router..."]
E --> F["runStartupBootstrap(c) — best-effort, 失败仅告警"]
F --> F1["TenantAPIKeyService.BackfillMissingKeyHashes<br/>(迁移 000065 遗留 API Key 哈希回填)"]
F --> F2["bootstrapSystemAdmin<br/>WEKNORA_BOOTSTRAP_SYSTEM_ADMIN_EMAIL 指定的用户<br/>在无系统管理员时晋升为超管 (幂等)"]
F --> G["c.Invoke(cfg, router, resourceCleaner, systemSettingSvc)"]
G --> H["listenWithRetry(addr, 10 次, 300ms 指数退避, 上限 3s)"]
H --> I["systemSettingSvc.SubscribeRedis(ctx)<br/>订阅 system_settings 变更 (Lite 模式 no-op)"]
I --> J["signal.Notify(shutdownSignals) + server.Serve(listener)"]
J --> K{"收到第一个信号?"}
K -->|是| L["listener.Close() 立即释放端口<br/>server.Shutdown(ctx, ShutdownTimeout 默认 30s) 优雅排空"]
L --> M{"排空期间收到第二个信号?"}
M -->|是| N["server.Close() 强制断开所有连接"]
M -->|否| O["resourceCleaner.Cleanup(ctx)<br/>ants 池 / Langfuse / 调度器逐个析构"]
N --> O
O --> P["进程退出"]
```
要点:
- **引导钩子刻意 best-effort**:`bootstrap.go` 注释明确"配置错误不应 brick 部署",所有失败路径只 `logger.Warnf`;系统管理员晋升仅当部署中尚无任何超管时生效,UI 撤销不会被重启还原;
- **两段式优雅退出**:第一个 SIGTERM/SIGINT 先关 listener(新进程可立即绑定端口)再 `Shutdown` 排空存量连接;第二个信号强制 `Close`;
- **端口占用重试**:`listenWithRetry` 以 300ms 起步指数退避重试 10 次(滚动重启场景旧进程尚未释放端口时避免直接失败)。
## 4. 路由组织与 RBAC 装配(internal/router)
### 4.1 NewRouter 的装配顺序
`internal/router/router.go` 的 `NewRouter(params RouterParams)`(`RouterParams` 为 `dig.In` 结构体)按以下顺序装配,**顺序即安全语义**:
1. `gin.New()` + `SetTrustedProxies`(`WEKNORA_TRUSTED_PROXIES`,默认仅信任回环与私网段,防止伪造 `X-Forwarded-For` 绕过按 IP 限流);
2. 全局中间件:`cors` → `RequestID` → `Language` → `Logger` → `Recovery` → `ErrorHandler`;
3. 免认证端点:`GET /health`;非 release 模式挂载 `/swagger/*any`;
4. Embed 页面 `frame-ancestors` CSP 中间件;Lite 版内嵌前端静态资源(`handler.Edition == "lite"`);
5. **认证之前**注册的公开路由:IM 平台回调(`/api/v1/im`,各平台自带签名验证)、Web Embed 公开路由(`/api/v1/embed/:channel_id`,`middleware.EmbedAuth` publish-token 鉴权 + Redis 限流)、短时效能力 URL(resource grants);
6. `middleware.Auth(...)` 全局认证;随后是需认证的文件代理路由、免认证但签名校验的 presigned 文件路由、Langfuse trace 中间件、`AuditServiceProvider`;
7. `v1 := r.Group("/api/v1")`:先 `v1.Use(rbacGuards.apiKeyAuthorizer.Middleware())`(API Key 网关,JWT 会话直接放行),再依次调用 30 个 `RegisterXxxRoutes(v1, handler, rbacGuards)`;
8. 收尾自检:`rbacGuards.assertAPIKeyPoliciesMatchRoutes(r)` —— 若声明的 API Key 策略指向不存在的路由模板(路径漂移/拼写错误),**启动即 panic**,避免上线一条永远 403 的死策略。
### 4.2 路由分组一览
| 分组前缀 | Register 函数 | API Key 策略示例 |
| --- | --- | --- |
| `/auth`、`/me` | RegisterAuthRoutes / RegisterMyInvitationRoutes | 多数免 Key |
| `/tenants`、`/tenants/:id/*`(成员/邀请/审计) | RegisterTenantRoutes | `manage_members` / `manage_spaces`;`/:id` 组挂 `PathTenantMatch()` |
| `/knowledge-bases`、`/knowledge-bases/:id/knowledge|faq|tags|shares` | RegisterKnowledgeBaseRoutes 等 | `retrieve` / `ingest`(fallback `full_access`) |
| `/knowledge`、`/chunks` | RegisterKnowledgeRoutes / RegisterChunkRoutes | `ingest` |
| `/sessions`、`/knowledge-chat`、`/agent-chat`、`/knowledge-search`、`/messages` | RegisterSessionRoutes / RegisterChatRoutes 等 | `chat` / `retrieve` |
| `/models`、`/evaluation` | RegisterModelRoutes / RegisterEvaluationRoutes | `manage_models` / `run_evaluations` |
| `/system`、`/system/admin` | RegisterSystemRoutes / RegisterSystemAdminRoutes | admin 组强制 `g.SystemAdmin()` |
| `/mcp-services`、`/agent`、`/web-search`、`/web-search-providers` | 对应 Register 函数 | `manage_mcp_services` / `manage_web_search` |
| `/vector-stores`、`/storage-backends` | RegisterVectorStoreRoutes / RegisterStorageBackendRoutes | `manage_vector_stores` / `manage_storage_backends` |
| `/agents`、`/agents/:id/shares|embed-channels|im-channels` | RegisterCustomAgentRoutes 等 | `full_access` / `manage_channels` |
| `/organizations`、`/user/favorites`、`/skills` | 对应 Register 函数 | `manage_spaces` 等 |
| `/im-channels`、`/embed-channels`、`/wechat` | RegisterIMChannelRoutes / RegisterEmbedChannelRoutes | `manage_channels` |
| `/datasource`、`/knowledgebase/:kb_id/wiki`、`/chunker/preview` | RegisterDataSourceRoutes / RegisterWikiPageRoutes / RegisterChunkerDebugRoutes | `manage_datasources` / `ingest` |
### 4.3 rbacGuards:集中式权限矩阵
`internal/router/rbac.go` 定义 `rbacGuards`,由 `NewRouter` 构造一次后传入每个 Register 函数。守卫分三类,路由行内联使用,一眼可见权限要求:
```go
kb.PUT("/:id", g.OwnedKBOrAdmin(), handler.UpdateKnowledgeBase)
```
- **角色守卫**(问"调用者在租户内是什么角色"):`Viewer()` / `Contributor()` / `Admin()` / `Owner()` / `AdminOrSystemAdmin()` / `SystemAdmin()`,底层调 `middleware.RequireRole`;
- **所有权守卫**(问"是否为该资源创建者或 Admin+"):`OwnedKBOrAdmin()`、`OwnedAgentOrAdmin()`、`OwnedKnowledgeKBOrAdmin()`、`OwnedChunkKBOrAdmin()`、`OwnedWikiKBOrAdmin()` 等——子资源(chunk/wiki/FAQ/tag)通过 `KBCreatorLookupFromKnowledgeID` 等闭包沿 URL 参数回溯到所属 KB 的 `creator_id`,与父资源共用同一条规则;
- **知识库访问守卫**(三层解析:自有 KB / 跨组织共享 KB / 共享 Agent 可见 KB):`KBAccessRead|Write(param)` 及 `...FromKnowledgeIDParam` / `...FromChunkIDParam` 变体,底层为 `middleware.RequireKBAccess`;
- **租户边界守卫**:`CrossTenant()`(平台级操作需 `EnableCrossTenantAccess` + `CanAccessAllTenants`)、`PathTenantMatch()`(`/tenants/:id` 必须与上下文租户一致)。
源码注释给出了选择守卫的决策树(有 creator 的资源用 OwnedXxxOrAdmin;租户级基础设施用 Admin;创建入口用 Contributor),并明确所有守卫尊重 `cfg.Tenant.EnableRBAC` 开关——关闭时仅记录"本应拒绝"日志后放行(灰度迁移期行为)。
**API Key 策略**与角色守卫正交:`apiKeyGroup(grp, policy)` 包装 gin RouterGroup,在注册路由的同时把 `(method, fullPath) → APIKeyRoutePolicy` 写入 `APIKeyRouteAuthorizer` 策略表;策略构造器有 `apiKeyFullAccess()`、`apiKeyPlatform(...)` 及 17 种能力包装器(`apiKeyRetrieve` / `apiKeyChat` / `apiKeyIngest` / `apiKeyManageModels` ...)。未注册策略的路由对 API Key 主体默认 **fail-closed 拒绝**。
## 5. 中间件清单(internal/middleware)
按请求经过的先后顺序:
| 中间件 | 文件 | 职责与关键逻辑 |
| --- | --- | --- |
| `cors.New`(gin-contrib) | router.go | 允许 `Authorization`、`X-API-Key`、`X-Tenant-ID`、`X-Embed-Session` 等头;MaxAge 12h |
| `RequestID()` | logger.go | 复用请求头 `X-Request-ID` 或生成 UUID,写入 gin context 与 `Request.Context()`,贯穿日志/追踪 |
| `Language()` | language.go | 决定文档处理语言:`WEKNORA_LANGUAGE` 环境变量 > `Accept-Language` 首个标签 > 默认 `zh-CN` |
| `Logger()` | logger.go | 请求/响应全量日志;正则脱敏密码/令牌字段、截断 base64 图片 data URL、SSE 响应标记跳过、单条上限 10KB |
| `Recovery()` | recovery.go | panic 捕获 + 堆栈记录 + 500 响应 |
| `ErrorHandler()` | error_handler.go | 读取 `c.Errors` 末位错误:`*errors.AppError` 按其 `HTTPCode` 返回 `{success:false, error:{code,message,details}}` 统一信封;其余 500 |
| `EmbedAuth(...)` | embed_auth.go | 仅挂在 `/api/v1/embed/:channel_id` 公开组:校验 publish token,注入 Embed 渠道上下文;Redis 三级限流(每 IP/分钟、渠道全局/分钟、渠道/日) |
| `PublicAuthRateLimit()` | auth_public_ratelimit.go | 免认证的邀请查询/受邀注册路由:**进程内存**滑动窗口,60s/30 次/IP,后台每 2 分钟清理过期桶,超限返回 429 |
| `Auth(...)` | auth.go | 核心认证,三态:① JWT(`Authorization: Bearer`,`userService.ValidateToken`);② API Key(`X-API-Key`,`AuthenticateAPIKey`);③ `noAuthAPI` 白名单。支持 `X-Tenant-ID` 切换租户(`IsTenantAccessible` 三层校验:自有租户/跨租户超管/active membership),`resolveTenantRole` 解析租户内角色。写入 context:`TenantIDContextKey`、`TenantInfoContextKey`、`UserContextKey`、`UserIDContextKey`、`TenantRoleContextKey`、`SystemAdminContextKey`、`PrincipalContextKey` 等 |
| `langfuse.GinMiddleware()` | tracing/langfuse | LLM 可观测 trace;未配置 LANGFUSE_* 时为 no-op |
| `AuditServiceProvider()` | audit_provider.go | 把 `AuditLogService` 注入 gin context,供 RBAC 拒绝路径记审计;服务为 nil 时优雅降级 |
| `APIKeyRouteAuthorizer.Middleware()` | api_key_gate.go | API Key 主体的路由级网关:查 `(method, fullPath)` 策略表,校验 `PlatformOnly` / `RequireFullAccess` / `Capabilities`;未声明路由默认拒绝;JWT 用户直接透传 |
| `RequireRole(min)` 等 | rbac.go | 租户内角色下限校验(owner=40 > admin=30 > contributor=20 > viewer=10);`RequireOwnershipOrRole(min, creatorLookup)` 允许资源创建者越过角色下限;API Key 主体短路(其授权归 APIKeyGate);跨租户超管临时获得 Admin;拒绝时调用 `AuditService.LogDenied` |
| `RequireCrossTenantAccess()` / `RequirePathTenantMatch()` | access.go | 平台级操作网关与 URL 租户一致性校验 |
| `RequireKBAccess(resolver, perm, ...)` | kb_access.go | KB 三层访问解析(自有 → 组织共享 → 共享 Agent 只读),并**改写** `Request.Context()` 中的 `TenantIDContextKey` 为 KB 源租户,使下游检索自动落到正确租户的数据 |
| `asynqdl.Middleware()` | asynqdl/ | 非 HTTP:Asynq 任务重试预算耗尽时写入 `task_dead_letters` 表,可挂 `OnDeadLetter` 回调联动业务状态(如标记知识解析失败) |
## 6. 领域模型总览(internal/types)
`internal/types/` 含约 26 个 GORM 持久化实体。核心关系:
```mermaid
erDiagram
TENANT ||--o{ USER : "主租户 (users.tenant_id)"
TENANT ||--o{ TENANT_MEMBER : "成员"
USER ||--o{ TENANT_MEMBER : "加入多个空间"
TENANT ||--o{ TENANT_API_KEY : "API Key (tenant_id 为空则平台级)"
TENANT ||--o{ KNOWLEDGE_BASE : "拥有"
TENANT ||--o{ MODEL : "模型配置"
TENANT ||--o{ CUSTOM_AGENT : "自定义 Agent"
TENANT ||--o{ VECTOR_STORE : "向量库实例"
TENANT ||--o{ STORAGE_BACKEND : "存储后端"
TENANT ||--o{ DATA_SOURCE : "外部数据源"
TENANT ||--o{ SESSION : "会话"
TENANT ||--o{ AUDIT_LOG : "审计 (tenant_id=0 为系统级)"
KNOWLEDGE_BASE ||--o{ KNOWLEDGE : "文档 (knowledge.knowledge_base_id)"
KNOWLEDGE_BASE ||--o{ KNOWLEDGE_TAG : "标签"
KNOWLEDGE_BASE ||--o| VECTOR_STORE : "创建时绑定 vector_store_id"
KNOWLEDGE_BASE ||--o| STORAGE_BACKEND : "创建时绑定 storage_backend_id"
KNOWLEDGE_BASE ||--o{ WIKI_PAGE : "Wiki 页面"
KNOWLEDGE_BASE ||--o{ WIKI_FOLDER : "Wiki 目录树"
KNOWLEDGE ||--o{ CHUNK : "分块 (chunk.knowledge_id)"
DATA_SOURCE ||--o{ SYNC_LOG : "同步记录"
SESSION ||--o{ MESSAGE : "消息 (message.session_id)"
MESSAGE }o--|| CUSTOM_AGENT : "agent_id"
MESSAGE }o--|| MODEL : "model_id"
TENANT {
uint64 id PK
string name
int64 storage_quota
json retriever_engines
json credentials_config "AES-256 加密"
}
USER {
string id PK "UUID"
string username UK
string email UK
uint64 tenant_id FK
bool is_system_admin
bool can_access_all_tenants
}
TENANT_MEMBER {
uint64 id PK
string user_id FK "uniq(user_id,tenant_id)"
uint64 tenant_id FK
string role "owner/admin/contributor/viewer"
string status "active/invited/suspended"
}
KNOWLEDGE_BASE {
string id PK "UUID"
uint64 tenant_id FK
string creator_id FK "RBAC 所有权判定"
string type "document/faq/wiki"
json chunking_config
json indexing_strategy "vector/keyword/wiki/graph 四管道开关"
string embedding_model_id FK
}
KNOWLEDGE {
string id PK "UUID"
string knowledge_base_id FK
string parse_status "pending→processing→finalizing→completed 等 7 态"
string channel "web/api/wechat/feishu... 14 种"
int pending_subtasks_count
}
CHUNK {
string id PK "UUID"
string knowledge_id FK
string chunk_type "text/parent_text/faq/entity/wiki_page... 12 种"
string parent_chunk_id "父子分块"
string content_hash
}
SESSION {
string id PK "UUID"
uint64 tenant_id FK
string user_id "用户/API 主体/embed 访客"
json last_request_state
}
MESSAGE {
string id PK "UUID"
string session_id FK
string role "user/assistant/system"
json knowledge_references "检索引用"
json agent_steps "Agent 推理轨迹"
text rendered_content "RAG 增强后的完整提示"
}
MODEL {
string id PK
uint64 tenant_id FK
string type "Embedding/Rerank/KnowledgeQA/VLLM/ASR"
string source "18+ 提供商"
json parameters "APIKey AES 加密"
}
```
设计要点:
- **多租户隔离**:几乎所有实体带 `TenantID`;`tenant_id=0` 表示系统级(如系统审计);
- **敏感字段静态加密**:`Model.Parameters`、`VectorStore.ConnectionConfig`、`StorageBackend.Config`、`DataSource.Config`、`TenantAPIKey.APIKey` 等在 GORM `Value()` 时以 `SYSTEM_AES_KEY`(32 字节)做 AES-256-GCM 加密、`Scan()` 时宽松解密(解密失败视为未配置而非报错);
- **创建时绑定不可变**:KB 的 `VectorStoreID`(gorm tag `<-:create`)与 `StorageBackendID` 一经创建不可修改,保证索引/文件一致性;
- **异步状态机**:`Knowledge.ParseStatus` 七态 + `PendingSubtasksCount` 追踪 finalizing 阶段并行富化子任务(summary/question/graph);
- **审计 append-only**:`AuditLog` 无更新/软删字段,覆盖 50+ 种 `AuditAction`;
- 非实体的重要类型:`SearchResult` 检索结果、`Pagination`、`Task`/队列拓扑(`task.go`)、各类 JSONB 配置结构(`ChunkingConfig`、`IndexingStrategy`、`CustomAgentConfig` 等)、context key 与取值助手(`context_helpers.go`)。
## 7. 错误处理规范(internal/errors)
统一错误载体是 `AppError`:
```go
// internal/errors/errors.go
type AppError struct {
Code ErrorCode // 业务错误码
Message string
Details any
HTTPCode int // HTTP 状态映射
}
```
- **错误码分段**:1000–1999 通用 HTTP 语义(`ErrBadRequest=1000`、`ErrUnauthorized=1001`、`ErrForbidden=1002`、`ErrNotFound=1003`、`ErrTooManyRequests=1006`、`ErrServiceUnavailable=1008`);2000–2099 租户;2100–2199 Agent;2200–2299 向量库;
- **构造函数**:`NewBadRequestError` / `NewUnauthorizedError` / `NewForbiddenError` / `NewNotFoundError` / `NewValidationError` / `NewConflictError` / `NewTooManyRequestsError` / `NewServiceUnavailableError` 等;
- **配合方式**:Handler/中间件用 `c.Error(appErr)` 挂错,`ErrorHandler` 中间件末端统一渲染 `{success:false, error:{code,message,details}}` 信封,前端据 `error.code` 做 i18n;非 `AppError` 一律 500;
- `session.go` 提供会话域哨兵错误(`ErrSessionNotFound` 等);`parse_error_codes.go` 定义文档解析阶段的字符串错误码(`DOCREADER_TIMEOUT`、`EMBEDDING_RATE_LIMIT`、`VECTORSTORE_WRITE_FAILED`、`TASK_TIMEOUT` 等),落在 `Knowledge.ErrorMessage` 供前端翻译展示。
## 8. 日志体系(internal/logger)
- 基于 **logrus**,私有 `appLogger` 单例 + 自定义 Formatter(彩色终端输出;`LOG_FORMAT` 可用 `%d` `%level` `%traceId` `%msg` 等占位符自定义模板;`LOG_PATH` 设置后经 lumberjack 轮转写文件并剥离 ANSI 颜色码);
- **request_id 贯穿**:`middleware.RequestID` 写入 context → `logger.GetLogger(ctx)` 自动提取并注入 `request_id` 字段;常用出口为 `logger.Infof/Warnf/Errorf(ctx, format, ...)` 与 `ErrorWithFields`;
- **LLM 调试日志**(`llm_logger.go`):`LLM_DEBUG_LOG=true` 时启用,按 request_id 分文件记录每次 LLM 调用(`LLMCallRecord`:CallType Chat/Embedding/Rerank/VLM、模型、耗时、完整消息与工具调用、错误),7 天自动清理——排查 Prompt/上下文问题的第一工具。
## 9. 关键工具库(internal/common、internal/utils)
| 位置 | 工具 | 用途 |
| --- | --- | --- |
| `common/tools.go` | `Deduplicate` / `DeduplicateWithScore`、`ParseLLMJsonResponse`、`CleanInvalidUTF8`、`PipelineLog` 系列 | 泛型去重(检索合并保最高分)、解析 LLM 返回的 ```json 代码块、清洗非法 UTF-8、RAG 管道阶段日志 |
| `common/db_retry.go` | `WithDeadlockRetry(ctx, fn)` | 数据库死锁检测重试(最多 3 次,50→100→200ms 退避) |
| `common/redis_tls.go` | `RedisTLSConfig()` | 按 `REDIS_USE_TLS` 等环境变量生成 Redis TLS 配置 |
| `utils/crypto.go` | `EncryptAESGCM` / `DecryptAESGCM`(`enc:v1:` 前缀,幂等) | 上文所有敏感字段静态加密的底层实现 |
| `utils/security.go` | `SanitizeHTML`、`ValidateFilePath`、`SanitizeForLog` | XSS 清洗、目录穿越防护、日志脱敏 |
| `utils/inject.go` | `ValidateSQL`(基于 `pganalyze/pg_query_go`) | Agent 数据分析生成 SQL 的白名单表校验与注入模式检测 |
| `utils/presign.go` | `GeneratePresignURL` / `ValidatePresignURL` | HMAC-SHA256 预签名文件 URL(默认 2h,IM 内嵌图片使用) |
| `utils/oidc_state.go` | `GenerateState` / `ValidateState` | OIDC 授权 state 的 HMAC 签名与 10 分钟 TTL(防 CSRF) |
| `utils/log_sanitize.go` | `CompactImageDataURLForLog` | 截断超长图片 data URL,防日志爆炸 |
| `utils/storage_error.go` | `SanitizeStorageConnectivityError` | 把存储连接错误转为用户友好提示并隐藏内部主机名 |
| 其余 | `taskid.go` / `fileutil.go` / `filesize.go` / `httputil.go` / `json.go` | 任务 ID、文件与大小格式化、HTTP 下载、JSON Schema 生成等 |
---
至此,后端从进程启动、依赖装配、请求进入到数据落库的全链路已经闭环。后续章节将分别展开 RAG 检索流水线(`chat_pipeline`)、Agent 引擎(`internal/agent`)与文档解析服务(`docreader`)的内部实现。
@@ -0,0 +1,541 @@
# 文档入库流程(Document Ingestion Pipeline)
本文完整描述 WeKnora 中一篇文档从"上传"到"可检索"的全链路:入口 API → 文件存储 → 异步任务 → 解析(docreader)→ 分块 → 向量化 → 索引写入 → 后处理富化(摘要 / 问题生成 / 图谱 / Wiki / 图片多模态)→ 状态机与进度追踪,以及失败重试、Housekeeping 自愈、删除清理、FAQ 导入与知识克隆/移动等配套链路。
各环节对应的源码位置:
| 环节 | 源码位置 |
|------|----------|
| HTTP 入口 | `internal/handler/knowledge.go`、`internal/router/router.go` |
| 创建与入队 | `internal/application/service/knowledge_create.go`、`knowledge_task_options.go` |
| 文件存储 | `internal/application/service/file/`(`factory.go`、各后端实现) |
| 解析基础设施 | `internal/infrastructure/docparser/`、`docreader/`(Python 服务) |
| 主处理管线 | `internal/application/service/knowledge_process.go` |
| 处理配置合并 | `internal/application/service/knowledge_process_config.go` |
| 后处理 | `internal/application/service/knowledge_post_process.go`、`image_multimodal.go` |
| 进度追踪 | `internal/application/service/knowledge_span_tracker.go`、`internal/types/knowledge_span.go` |
| 自愈 | `internal/application/service/knowledge_housekeeping.go` |
| 删除 | `internal/application/service/knowledge_delete.go` |
| FAQ | `internal/application/service/knowledge_faq.go`、`knowledge_faq_import.go` |
| 克隆/移动 | `internal/application/service/knowledge_clone_move.go` |
## 1. 总体架构
WeKnora 的入库链路是一条**基于 Asynq(Redis)的分布式异步管道**。HTTP Handler 只负责落库与入队,所有耗时工作(解析、向量化、LLM 富化)都由独立的 Worker 池消费队列完成。
```mermaid
flowchart TD
subgraph Entry["入口层 (internal/handler/knowledge.go)"]
A1["POST /knowledge-bases/:id/knowledge/file<br/>(文件上传)"]
A2["POST /knowledge-bases/:id/knowledge/url<br/>(URL 导入)"]
A3["POST /knowledge-bases/:id/knowledge/manual<br/>(手动创建)"]
A4["POST /knowledge/:id/reparse<br/>(重新解析)"]
end
subgraph Create["创建层 (knowledge_create.go)"]
B1["calculateFileHash<br/>(MD5 去重)"]
B2["FileService.SaveFile<br/>(写入存储后端)"]
B3["创建 Knowledge 记录<br/>parse_status=pending"]
B4["Asynq Enqueue<br/>TypeDocumentProcess"]
end
subgraph Worker["核心 Worker (knowledge_process.go)"]
C1["convert: DocReader 解析<br/>(gRPC/HTTP → docreader)"]
C1a["ASR 转写<br/>(音频文件)"]
C2["ImageResolver<br/>(图片提取并上传存储)"]
C3["chunker.Split /<br/>SplitParentChild (分块)"]
C4["processChunks:<br/>CreateChunks (写 DB)"]
C5["BatchIndex<br/>(Embedding + 向量/关键词索引)"]
end
subgraph Enrich["富化 Worker (knowledge_post_process.go)"]
D1["TypeImageMultimodal<br/>(OCR + VLM Caption)"]
D2["TypeSummaryGeneration<br/>(摘要)"]
D3["TypeQuestionGeneration<br/>(问题生成, 每批 20 chunk)"]
D4["TypeChunkExtract<br/>(图谱抽取, 每 chunk 一任务)"]
D5["TypeWikiIngest<br/>(Wiki 页面生成)"]
end
A1 --> B1 --> B2 --> B3 --> B4
A2 --> B3
A3 --> B3
A4 --> B4
B4 -->|"Queue: default"| C1
C1 --> C1a --> C2 --> C3 --> C4 --> C5
C5 -->|"Queue: multimodal"| D1
C5 -->|"TypeKnowledgePostProcess"| D2
C5 --> D3
C5 --> D4
C5 --> D5
D2 -->|"FinalizeSubtask 原子递减"| E["parse_status=completed"]
D3 --> E
D4 --> E
D5 --> E
```
## 2. 入口层:三种创建方式
路由注册在 `internal/router/routes_knowledge.go`:
```go
kb.POST("/file", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.CreateKnowledgeFromFile)
kb.POST("/url", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.CreateKnowledgeFromURL)
kb.POST("/manual", g.OwnedKBOrAdmin(), g.KBAccessWrite("id"), handler.CreateManualKnowledge)
```
配套的管理端点:`POST /knowledge/:id/reparse`(重新解析)、`POST /knowledge/:id/cancel-parse`(取消解析)、`POST /knowledge/batch-reparse`、`POST /knowledge/batch-delete`、`POST /knowledge/move`(跨库移动)。
### 2.1 文件上传(CreateKnowledgeFromFile)
- 表单参数:`file`、`fileName`、`metadata`、`enable_multimodel`、`tag_ids`、`process_config`(每次上传可覆盖 KB 级处理配置,见 §5)。
- 流程:扩展名校验 → MD5 去重 → `FileService.SaveFile` 存储 → 创建 `Knowledge` 记录 → 入队。
### 2.1.1 统一的扩展名闸门
`internal/application/service/knowledge_util.go` 里的 `supportedImportFileExtensions` 是**所有导入路径的唯一事实来源**——直接上传、文件 URL 下载、以及 worker 下载完成后的复检都查同一张表:
```
pdf txt docx doc epub html htm mhtml md markdown
png jpg jpeg gif csv xlsx xls pptx ppt json
mp3 wav m4a flac ogg
```
此前 URL 导入维护着一份更短的独立白名单,导致「直接上传 xlsx 可以、URL 导入 xlsx 被拒」这类不一致(#2447);现在统一由 `isSupportedImportExtension()` / `validateImportFileType()` 判定,视频类型会给出「暂不支持上传视频文件」的明确提示。
表格类扩展名(`csv` / `xlsx` / `xls`,`dataTableFileExtensions`)在文档处理任务之后额外挂一个表摘要任务(`enqueueDataTableSummaryIfNeeded`)。
图片、音频类文件的额外前置校验(对象存储配置是否完整、VLM / ASR 模型是否配置)与 `process_config` 校验一起收敛到 `resolveFileImportProcessConfig()`,上传与 URL 导入共用。
### 2.2 URL 导入(CreateKnowledgeFromURL)
- JSON Body:`{url, file_name?, file_type?, enable_multimodel?, title?, tag_ids?, channel?, process_config?}`。
- `isFileURL()` 按上面的统一扩展名集合判断这是「下载文件」还是「抓取网页」。
- Handler 与 Service 双层做 SSRF 防护(`internal/handler/knowledge.go` 与 `knowledge_create.go` 均调用):
```go
if err := secutils.ValidateURLForSSRF(req.URL); err != nil {
c.Error(errors.NewBadRequestError(secutils.FormatSSRFError("URL", req.URL, err)))
return
}
```
Worker 侧在真正抓取前会再次校验(`knowledge_process.go` 的 `convert()`),三重防线防止 TOCTOU。
### 2.3 手动创建(CreateManualKnowledge)
- JSON Body 为 `types.ManualKnowledgePayload{Title, Content, Status, TagIDs, Channel, ProcessConfig}`,支持草稿(Draft)状态;发布时走 `triggerManualProcessing()` 进入与文件相同的分块/索引管线(跳过 DocReader 阶段)。
### 2.4 去重机制
`knowledge_create.go` 对上传文件计算 MD5,并按四元组查库:
```go
hash, err := calculateFileHash(file) // MD5
exists, existingKnowledge, err := s.repo.CheckKnowledgeExists(ctx, tenantID, kbID,
&types.KnowledgeCheckParams{
Type: "file",
FileName: fileName,
FileType: getFileType(fileName),
FileSize: file.Size,
FileHash: hash,
})
if exists {
return existingKnowledge, types.NewDuplicateFileError(existingKnowledge)
}
```
命中时不重复入库,返回已有 Knowledge 并附带 `DuplicateFileError`(前端据此提示"文件已存在")。`FileType` 参与判定:重复只在**同一文件类型内**成立,因此内容完全相同的 `notes.md` 与 `notes.txt` 会作为两条独立知识共存(`CheckKnowledgeExists` 在哈希与「文件名 + 大小」两条分支上都追加了 `LOWER(file_type)` 条件)。
### 2.5 初始状态
新建 Knowledge 记录的关键初始字段(`knowledge_create.go`):
```go
knowledge := &types.Knowledge{
ID: uuid.New().String(),
Type: "file", // 或 "url" / "manual"
ParseStatus: "pending", // 初始解析状态
EnableStatus: "disabled", // 索引完成前不可检索
FileHash: hash,
...
}
```
对 CSV/Excel 数据表类知识,创建后还会额外入队 `TypeDataTableSummary`(`datatable:summary`)任务,生成 `table_summary` / `table_column` 类型的 Chunk 用于表格问答。
## 3. 文件存储层(FileService 与存储后端)
### 3.1 接口定义
`internal/types/interfaces/file.go`:
```go
type FileService interface {
CheckConnectivity(ctx context.Context) error
SaveFile(ctx context.Context, file *multipart.FileHeader, tenantID uint64, knowledgeID string) (string, error)
SaveBytes(ctx context.Context, data []byte, tenantID uint64, fileName string, temp bool) (string, error)
GetFile(ctx context.Context, filePath string) (io.ReadCloser, error)
GetFileURL(ctx context.Context, filePath string) (string, error)
DeleteFile(ctx context.Context, filePath string) error
CopyFile(ctx context.Context, srcPath string, tenantID uint64, knowledgeID string) (string, error)
}
```
### 3.2 支持的存储后端
工厂函数 `NewFileServiceFromStorageConfig()`(`internal/application/service/file/factory.go`)根据 `types.StorageEngineConfig.DefaultProvider` 选择后端。实际支持的后端清单:
| Provider | 路径前缀 | 实现文件 | 说明 | 关键配置 |
|----------|----------|----------|------|----------|
| `local` | `local://` | `file/local.go` | 单机本地磁盘 | `LocalEngineConfig.PathPrefix`,基目录取 `LOCAL_STORAGE_BASE_DIR`,外链签名取 `APP_EXTERNAL_URL` |
| `minio` | `minio://` | `file/minio.go` | MinIO / S3 兼容 | `MinIOEngineConfig`(`mode: docker` 时读环境变量 `MINIO_ENDPOINT` / `MINIO_ACCESS_KEY_ID` / `MINIO_SECRET_ACCESS_KEY` / `MINIO_BUCKET_NAME`;`mode: remote` 时读配置字段) |
| `cos` | `cos://` | `file/cos.go` | 腾讯云 COS | `SecretID/SecretKey/Region/BucketName/AppID`,支持独立临时桶 `TempBucketName/TempRegion` |
| `oss` | `oss://` | `file/oss.go` | 阿里云 OSS | `Endpoint/Region/AccessKey/SecretKey/BucketName`,支持临时桶 |
| `s3` | `s3://` | `file/s3.go` | AWS S3 / 兼容协议 | `Endpoint/Region/AccessKey/SecretKey/BucketName/UseSSL/ForcePathStyle` |
| `tos` | `tos://` | `file/tos.go` | 火山引擎 TOS | 同上,支持临时桶 |
| `obs` | `obs://` | `file/obs.go` | 华为云 OBS | `Endpoint/Region/AccessKey/SecretKey/BucketName/UseSSL` |
| `ks3` | `ks3://` | `file/ks3.go` | 金山云 KS3 | `Endpoint/Region/AccessKey/SecretKey/BucketName` |
| `dummy` | `dummy://` | `file/dummy.go` | 测试用空实现 | 无 |
### 3.3 对象 Key 组织规则
- 正式文件:`{tenantID}/{knowledgeID}/{uuid或纳秒时间戳}{ext}`,例如 `local://12345/kb-001/1722045600000000000.pdf`。
- 导出/临时/克隆产物:`{tenantID}/exports/{fileName}_{timestamp}{ext}`。
- 路径安全:`secutils.SafePathUnderBase`(防目录穿越)、`secutils.SafeFileName`、对象存储侧 `utils.SafeObjectKey`。
### 3.4 两个包装层
- **`backend_scoped.go`**:多存储后端部署时给路径加实例前缀,形如 `storage://{backendID}/{innerPath}`,`wrap/unwrap` 编解码并拒绝跨后端操作。KB 可通过 `StorageBackendID` 绑定到具体后端实例。
- **`resource_catalog.go`**:把物理路径注册为稳定的 `resource://{uuid}` 引用,支持 `Bind`(资源与 knowledge 等 owner 关联)、`MarkDeleted`、`CreateAccessGrant`(生成临时访问令牌,产出 `/r/{token}` 形式的 URL)。应用层持有 `resource://` 引用即可无感迁移底层存储。
## 4. 异步任务机制(Asynq + Redis)
### 4.1 入队
`knowledge_create.go` 组装 `types.DocumentProcessPayload`(含 `TenantID/KnowledgeID/KnowledgeBaseID/FilePath/FileName/FileType/EnableMultimodel/EnableQuestionGeneration/QuestionCount/Language/Attempt` 等),任务选项来自 `knowledge_task_options.go`:
```go
opts := []asynq.Option{
asynq.Queue(types.QueueDefault),
asynq.Timeout(config.DocumentProcessTimeout(cfg)), // 默认 30 分钟
asynq.MaxRetry(3), // 失败最多重试 3 次
}
task := asynq.NewTask(types.TypeDocumentProcess, payloadBytes, opts...)
info, err := s.task.Enqueue(task)
```
入队失败时会将 `ParseStatus` 置为 `failed`(文件已保存,可通过 reparse 重新触发)。
### 4.2 队列拓扑与 Worker 池
`internal/types/task.go` 定义的队列:
| 队列常量 | 名称 | 用途 |
|----------|------|------|
| `QueueDefault` | `default` | 核心文档处理(解析/分块/嵌入/索引) |
| `QueuePostProcess` | `postprocess` | 后处理编排任务 |
| `QueueSummary` | `summary` | 摘要 / 问题生成类 LLM 任务 |
| `QueueMultimodal` | `multimodal` | 图片 OCR / VLM Caption |
| `QueueMaintenance` | `low` | 维护类任务(FAQ 批量导入等) |
默认并发数(`internal/types/task.go`):核心池 `DefaultCoreWorkerConcurrency = 8`、后处理池 `2`、富化池 `12`、维护池 `4`。
### 4.3 失败重试语义
- `TypeDocumentProcess`:`MaxRetry(3)` → 初始 + 3 次重试共 4 次尝试;每次尝试受 `DocumentProcessTimeout`(默认 30 分钟)约束。
- Payload 携带 `Attempt`(重新解析时取历史最大 attempt+1);Span Tracker 用 attempt 隔离每轮处理的进度树,新 attempt 会"取代"(supersede)旧任务的收尾动作。
- 处理函数区分"是否最后一次 asynq 尝试"(`isLastRetry`):非最后一次的失败直接返回错误让 asynq 重试,最后一次才把 `ParseStatus` 落为 `failed` 并写 `ErrorMessage`。
## 5. 处理配置:KB 默认值 + 单次上传覆盖
`knowledge_process_config.go` 的 `ResolveProcessConfig(kb, overrides)` 把 KB 默认配置与上传时携带的 `process_config`(`types.KnowledgeProcessOverrides`)合并为 `types.EffectiveProcessConfig`:
- 可覆盖项:`ChunkingConfig`(chunk 大小/重叠/策略/父子分块等)、`EnableMultimodel`、`VLMConfig`、`ASRConfig`、`QuestionGenerationConfig`、`GraphEnabled`、`ExtractConfig`、`ParserEngineRules`。
- 约束:`eff.GraphEnabled = eff.GraphEnabled && eff.ExtractConfig.Enabled`(图谱依赖抽取配置开启)。
- `ValidateProcessOverrides` 会按文件类型前置校验:上传图片必须配 VLM 模型,上传音频必须配 ASR 模型,多模态还要求对象存储配置完整(`validateImageMultimodalConfig`)。
- 覆盖配置通过 `knowledge.SetProcessOverrides` 持久化在 Knowledge 行上,reparse 时沿用。
哪些管线会跑由 KB 的 `IndexingStrategy`(`internal/types/indexing_strategy.go`)决定:
```go
type IndexingStrategy struct {
VectorEnabled bool // 语义向量索引
KeywordEnabled bool // BM25 关键词索引
WikiEnabled bool // 自动 Wiki 页面生成
GraphEnabled bool // 知识图谱抽取
}
```
`NeedsEmbedding() = Vector || Keyword`,`NeedsChunks() = 任一开启`。默认值为 vector+keyword 开启。
## 6. 核心处理管线(knowledge_process.go)
Worker 消费 `TypeDocumentProcess` 后按五个规范化阶段推进,每个阶段对应一个 Span(见 §8):
`docreader → chunking → embedding → multimodal → postprocess`
### 6.1 解析(convert,Stage: docreader)
1. `beginStage(StageDocReader)` 记录输入(file_name/file_type/is_url)。
2. URL 模式再次 `ValidateURLForSSRF`,失败即 `failStage` + `ParseStatus=failed`。
3. 引擎选择:`eff.ChunkingConfig.ResolveParserEngine(fileType)`(URL 用虚拟类型 `"url"`),按 KB 配置的 `ParserEngineRules`(文件类型 → 引擎)路由;`MergeParserEngineOverrides` 合并租户级与上传级引擎参数覆盖。
4. `resolveDocReader` 返回 `interfaces.DocReader`:
- **builtin**:通过 gRPC(`docparser/grpc_parser.go`)或 HTTP(`http_parser.go`)调用 Python **docreader** 服务;
- **simple**:Go 原生解析 md/txt/csv/json/图片/音频(`builtin_converter.go`,CSV→Markdown 表格、JSON→递归分割的代码块,图片/音频转占位引用);
- **anydoc**:Go 进程内解析 docx/doc/pptx/ppt/xlsx/xls/odf/rtf/epub/csv/pdf(`anydoc_reader.go`),底层是通过 cgo 链接的 anydoc Rust 库。office 文档的嵌入图按文档模型插回 Markdown 原位;无文字层的扫描件 PDF 在 DocReader 可用时回退到 builtin 整页渲染。仅在带 `anydoc` 构建标签的二进制中可用,其余构建里该引擎在引擎列表中显示为不可用;
- **weknoracloud / mineru / mineru_cloud / paddleocr_vl / paddleocr_vl_cloud**:HTTP 转换器(`engines.go` 注册,按 `mineru_endpoint`、`mineru_api_key`、`paddleocr_vl_endpoint` 等配置判定可用性)。
引擎目录集中在 `internal/infrastructure/docparser/engines.go`:每个引擎同时声明元数据(名称、描述、文件类型、可用性探针)与 `NewReader` 工厂,`docparser.NewReader` 按名字分发,未注册的名字(如只存在于 docreader 的 `markitdown`)落到 docreader 客户端。
5. 文件模式:从 `FileService.GetFile(payload.FilePath)` 读回字节填入 `ReadRequest.FileContent`。
**docreader 服务侧**(`docreader/`,Python gRPC):proto 定义 `docreader/proto/docreader.proto`,服务方法 `Read` / `ReadStream`(流式:首帧 meta + 每图一帧,避免大扫描件 PDF 触发 gRPC 消息上限)/ `ListEngines`。内置 parser 覆盖 docx/doc/pdf/md/xlsx/xls/epub/html/htm/mhtml/图片/网页(`WebParser` 处理 URL),并可选注册 `markitdown`(微软 MarkItDown)与 `opendataloader`(PDF 版面分析,需 Java 11+)引擎,Go 侧通过 `ListEngines` 自发现远程引擎。返回统一为 `ReadResult{MarkdownContent, ImageRefs, Metadata, IsAudio, AudioData}` —— **解析产物统一是 Markdown 文本 + 图片字节**,图片持久化由 Go 侧负责。
### 6.2 ASR 转写(音频文件)
`convertResult.IsAudio` 为真时(音频文件解析为占位符 + 原始字节):
```go
asrModel, err := s.modelService.GetASRModel(ctx, eff.ASRConfig.ModelID)
transcriptionResult, err := asrModel.Transcribe(ctx, convertResult.AudioData, knowledge.FileName)
```
转写文本替换 MarkdownContent 后继续走普通文本管线;未配置 ASR 则直接失败。
### 6.3 图片提取与上传
`docparser/image_resolver.go` 的 `ImageResolver.ResolveAndStore`:
1. 依次处理 `<!link>` 包装图、`data:` URI、HTML 内联 base64、裸 base64、docreader 返回的 `ImageRefs` 内联字节;
2. 过滤图标级小图(宽高 < 64px 或 < 512 字节,`IsOriginal=true` 的原始上传件除外);
3. `SaveBytes` 上传到当前 KB 的存储后端,`savedRefs` 缓存去重;
4. 把 Markdown 中的引用重写为存储 URL(`markdown_image_scanner.go` 精确定位 `![alt](target)` 位置)。
随后 `ResolveRemoteImages` 再把 Markdown 里的外部 `http(s)` 图片下载转存(同样受 SSRF 防护)。产出 `storedImages []docparser.StoredImage` 供多模态阶段使用。
### 6.4 分块(Stage: chunking)
分块在 **Go 侧**完成(`internal/infrastructure/chunker`,详见《分块机制》一章):
```go
chunkCfg := buildSplitterConfigFromChunking(eff.ChunkingConfig)
if eff.ChunkingConfig.EnableParentChild {
parentCfg, childCfg := buildParentChildConfigs(eff.ChunkingConfig, chunkCfg)
pcResult := chunker.SplitParentChild(convertResult.MarkdownContent, parentCfg, childCfg)
// children → types.ParsedChunk(含 ParentIndex);parents → ParsedParentChunk
} else {
splitChunks := chunker.Split(convertResult.MarkdownContent, chunkCfg)
}
```
### 6.5 写库与索引(processChunks,Stage: chunking + embedding)
`processChunks` 是核心装配函数:
1. **父块**(父子分块模式):为每个 parent 建 `ChunkTypeParentText` 记录,串好 `PreChunkID/NextChunkID` 链表;父块**只入 DB、不进向量索引**(检索命中子块后回捞父块内容)。
2. **文本块**:每个 `ParsedChunk` 建 `ChunkTypeText` 记录,携带 `StartAt/EndAt`(原文 rune 偏移,可用于还原/高亮)与内存态 `ContextHeader`(标题面包屑,不落库);父子模式下写 `ParentChunkID`。
3. `chunkService.CreateChunks(ctx, insertChunks)` 批量写库;失败则 `ParseStatus=failed` + `failStage(StageChunking)`。
4. **向量化与索引**(`kb.NeedsEmbeddingModel()` 时):
```go
indexContent := titlePrefix + chunk.EmbeddingContent() // 标题 + 面包屑 + 内容
indexInfoList = append(indexInfoList, &types.IndexInfo{
Content: indexContent, SourceID: chunk.ID, SourceType: types.ChunkSourceType,
ChunkID: chunk.ID, KnowledgeID: knowledge.ID, KnowledgeBaseID: ..., IsEnabled: true,
})
err = retrieveEngine.BatchIndex(ctx, embeddingModel, indexInfoList)
```
索引失败时执行**补偿回滚**:删除已写入的 chunks(`DeleteChunksByKnowledgeID`)并清向量索引(`DeleteByKnowledgeIDList`),置 `failed`,保证不留半成品。
5. **图片多模态任务扇出**:`enableMultimodel && len(storedImages) > 0` 时,`enqueueImageMultimodalTasks` 为**每张图片**入队一个 `TypeImageMultimodal` 任务(`QueueMultimodal`),payload 含 `ImageURL/EnableOCR/EnableCaption/Attempt/ImageIndex`。
6. `finalizeIndexedKnowledgeState`:若还有多模态/后处理要跑则保持 `processing`,否则直接 `completed`;同时置 `EnableStatus="enabled"`(此刻文档即可被检索)并累计租户存储用量。
### 6.6 后处理编排(knowledge_post_process.go,Stage: postprocess)
多模态全部完成(或无多模态)后入队 `TypeKnowledgePostProcess`。该任务是**富化子任务的编排器**,用原子计数器保证终态收敛:
```go
willSpawnSummary := len(textChunks) > 0
willSpawnQuestion := willSpawnSummary && kb.NeedsEmbeddingModel() && eff.QuestionGenerationConfig.Enabled
willSpawnWiki := kb.IndexingStrategy.WikiEnabled && len(textChunks) > 0
willSpawnGraph := eff.GraphEnabled && len(textChunks) > 0
// questionGenChunkBatchSize = 20:问题生成按每 20 个 chunk 一批
expectedSubtasks = summary(0/1) + questionBatchCount + wiki(0/1) + graphChunkCount
// 原子地把 parse_status 从 processing 提升为 finalizing,并写入 pending_subtasks_count
promoted, err := s.knowledgeRepo.SetFinalizing(ctx, payload.KnowledgeID, expectedSubtasks)
```
- `expectedSubtasks == 0` 走快速路径直接 `completed`。
- 每个子任务终态退出时调用 `FinalizeSubtask` 原子递减 `pending_subtasks_count`,减到 0 时自动升级为 `completed`。
- **短缺协调**:若实际入队数少于计划数(如某队列入队失败),立即补偿递减差额,防止永远卡在 `finalizing`。
- `finalizeSubtaskDetached`(`knowledge.go`):递减动作使用 `context.WithoutCancel` + 10 秒超时的**脱离上下文**执行,避免 worker 优雅退出时 ctx 取消导致计数丢失、知识永久滞留 `finalizing`。
四类富化子任务:
| 任务 | 队列 | 粒度 | 说明 |
|------|------|------|------|
| `TypeSummaryGeneration` | `summary` | 每知识 1 个 | 生成文档摘要,`summary_status` 独立状态机 |
| `TypeQuestionGeneration` | question 队列 | 每 20 个 chunk 一批 | 为 chunk 生成检索问题 |
| `TypeChunkExtract` | graph 队列 | 每 chunk 1 个 | 实体/关系抽取写入图引擎 |
| `TypeWikiIngest` | wiki 队列 | 防抖批量 | 生成/更新 Wiki 页面 |
#### 摘要刷新(knowledge_summary_refresh.go)
首次入库之外,分块内容编辑、分块启停、自定义元数据变更都会让已有摘要过期,此时入队一次**摘要刷新**(也可由 `POST /knowledge/:id/regenerate-summary` 手动触发):
- 任务开始时记录输入快照:各源分块的 `content_revision` / `is_enabled`,以及 `custom_metadata` 的版本;
- 生成完成后用 `summarySourceChanged()` 复核快照。若期间又被编辑,返回 `ErrSummaryRefreshStale`,**丢弃本次结果且不改动 `summary_status`**,让更新的那次刷新收尾——否则旧摘要会覆盖新摘要;
- 数据库读取失败与「输入已变更」分开处理,避免把瞬时读错误当成过期任务静默丢弃;
- 刷新跑在 Asynq worker 里,没有 HTTP 中间件注入的租户上下文,因此 `restoreSummaryRefreshTenantInfo()` 会重建完整租户配置——检索引擎工厂需要它。
### 6.7 图片多模态(image_multimodal.go)
`ImageMultimodalService.Handle` 消费单图任务:
1. `readImageBytes` 从存储/URL 取图;`resolveVLM` 取 KB 的 VLM 配置;
2. 生成 Caption(VLM,prompt 由 `buildVLMCaptionPrompt` 按 `DescriptionLanguage/CustomInstructions` 组装)与 OCR 文本;
3. 结果写回所属文本 Chunk 的 `ImageInfo`(JSON),并创建/更新两个**子 Chunk**:`ChunkTypeImageCaption` 与 `ChunkTypeImageOCR`,`ParentChunkID` 指向文本块,随后单独 `indexChunks` 入向量索引 —— 使"搜图片描述也能召回原文块";
4. `shouldDropOrphanedMultimodal` 检查父块是否已被删除/取代,孤儿任务直接丢弃;
5. `checkAndFinalizeAllImages`:全部图片处理完毕后,`enqueueKnowledgePostProcessTask` 触发 §6.6 的后处理编排。
## 7. 状态机
### 7.1 Knowledge 主状态(ParseStatus)
`internal/types/knowledge.go` 定义的完整取值:
| 值 | 含义 |
|----|------|
| `pending` | 已创建,等待 worker 领取 |
| `processing` | 解析/分块/嵌入/多模态执行中 |
| `finalizing` | 主流程完成,等待富化子任务(`pending_subtasks_count > 0`) |
| `completed` | 全部完成 |
| `failed` | 处理失败(`ErrorMessage` 记录原因) |
| `deleting` | 删除中(防并发标记) |
| `cancelled` | 用户取消解析 |
辅助状态:`EnableStatus ∈ {enabled, disabled}`(是否可检索,索引成功即 enabled,不等富化);`SummaryStatus ∈ {none, pending, processing, completed, failed}`。
```mermaid
stateDiagram-v2
[*] --> pending: 创建 Knowledge 并入队
pending --> processing: worker 领取任务
processing --> finalizing: SetFinalizing 原子提升<br/>写入 expectedSubtasks
processing --> completed: 无富化任务的快速路径
finalizing --> completed: pending_subtasks_count 减至 0
pending --> failed: 入队失败或前置校验失败
processing --> failed: 解析/分块/索引失败<br/>且为最后一次重试
processing --> cancelled: 用户 cancel-parse
pending --> cancelled: 用户 cancel-parse
failed --> pending: reparse attempt+1
completed --> pending: reparse attempt+1
cancelled --> pending: reparse
completed --> deleting: DeleteKnowledge
failed --> deleting: DeleteKnowledge
processing --> failed: housekeeping 判定卡死<br/>心跳超时且无排队任务
finalizing --> failed: housekeeping 判定卡死
deleting --> [*]: 清理完成后删除 DB 行
```
### 7.2 阶段级进度(Span Tracker)
`knowledge_span_tracker.go` + `internal/types/knowledge_span.go` 提供逐阶段进度树(前端时间线即由此渲染):
- 五个规范阶段:`StageDocReader / StageChunking / StageEmbedding / StageMultimodal / StagePostProcess`(`types.AllStages`)。
- Span 状态:`pending / running / done / failed / skipped / cancelled`。`skipped` 用于主动跳过(如未开启多模态),`cancelled` 用于上游失败连带取消。
- 每轮处理有独立 `Attempt`(`repo.NextAttempt`),根 Span `name="knowledge_processing"`、`Kind=SpanKindRoot`;阶段以 `beginStage / endStage / failStage / skipStage` 打点,输入输出记录在 `JSONMap`(如 `chunks_planned` / `chunks_written` / `total_text_chars`)。
- 每次打点同时 `touchKnowledgeHeartbeat` 刷新心跳 —— Housekeeping 用它区分"慢但活着"与"真的卡死"。
## 8. Housekeeping 自愈(knowledge_housekeeping.go)
后台每 **5 分钟**一轮(`WEKNORA_HOUSEKEEPING_ENABLED` 可关闭),修复因 worker 崩溃 / Redis 丢任务导致的僵尸状态:
**Sweep A —— 卡死知识恢复**,三阶段过滤:
1. 粗筛:`parse_status IN (pending, processing, finalizing) AND updated_at < cutoff`;
2. `filterByLastSpanActivity`:查 `knowledge_processing_spans` 的 `MAX(updated_at)` 心跳,心跳仍在阈值内的保留(仍在处理),无任何 span 的也判为卡死;
3. `filterOutQueued`:通过 asynq TaskInspector 检查是否仍有排队任务,有则保留(只是在排队)。
判定卡死的知识被更新为:
```sql
UPDATE knowledge SET parse_status = 'failed',
error_message = 'task stuck in processing > [threshold], recovered by housekeeping',
pending_subtasks_count = 0
WHERE id IN (stuck_ids)
```
阈值 `staleThreshold() = max(1h, DocumentProcessTimeout) + 10min`。
**Sweep B —— 摘要卡死恢复**:`summary_status = 'processing' AND updated_at < 1 小时前` → 置 `failed`。
## 9. 删除清理链路(knowledge_delete.go)
`DeleteKnowledge(ctx, id)` 的顺序经过精心设计(**先删行、后删文件**,失败可重试):
1. 标记 `ParseStatus = deleting`(阻止并发任务写入);
2. 对 `pending/processing` 状态的知识执行 `dequeueKnowledgeTasks()` 取消队列中的下游任务;
3. **errgroup 并行清理**四类资源:
- 向量/关键词索引:`retrieveEngine.DeleteByKnowledgeIDList`(按 embedding 维度与 KB 类型路由);
- Wiki:`cleanupWikiOnKnowledgeDelete`(写 Redis tombstone → 清 pending ingest → reconcile 现有页面 → 入队 WikiRetract);
- Chunks:`chunkService.DeleteChunksByKnowledgeID`;
- 图谱:`graphEngine.DelGraph`;
4. 删除 Tag 关联 → 删除 Knowledge 数据库行;
5. **最后 best-effort 清理物理文件**:源文件 + 从 `chunk_image_info` 收集的所有提取图片(`collectImageURLs` + `deleteExtractedImages`),并回冲租户存储统计。
批量版 `DeleteKnowledgeList` 预加载各 KB 的 FileService、按 KB 分组图片 URL、按 embedding 模型分组删索引,避免 goroutine 内重复查询。
## 10. FAQ 类知识导入(knowledge_faq.go / knowledge_faq_import.go)
FAQ 知识库不走文档解析管线:每个 FAQ KB 只有**一个** Knowledge 实例(`ensureFAQKnowledge`),每条问答对是一个 `ChunkTypeFAQ` 的 Chunk,元数据存于 `Chunk.Metadata`:
```go
type FAQChunkMetadata struct {
StandardQuestion string // 标准问
SimilarQuestions []string // 相似问
NegativeQuestions []string // 反例问(负例过滤, 不参与索引)
Answers []string
AnswerStrategy AnswerStrategy // "all" | "random"
...
}
```
- **单条创建** `CreateFAQEntry`:清洗校验 → 查重(`checkFAQQuestionDuplicate`)→ 构建 Chunk(`buildFAQChunkContent` 按 `FAQIndexMode` 决定是否把答案写进 Content)→ `indexFAQChunks` 同步索引 → `ChunkStatusIndexed`。
- **索引模式**(KB 级配置):`FAQIndexModeQuestionOnly`(`question_only`,仅索引问题)/ `FAQIndexModeQuestionAnswer`(`question_answer`,问题+答案);问题索引又分 `FAQQuestionIndexModeCombined`(标准问+相似问合并一个向量)与 `FAQQuestionIndexModeSeparate`(每个相似问独立向量,source_id 形如 `{chunkID}-{index}`,支持增量索引 `incrementalIndexFAQEntry`)。
- **批量导入** `UpsertFAQEntries`:
- 模式 `append`(追加/合并)或 `replace`(全量替换),支持 `DryRun` 仅校验;
- 超过 200 条或 50KB 时条目先 `SaveBytes` 上传对象存储,payload 只带 `EntriesURL`;
- 入队 `TypeFAQImport` → `QueueMaintenance`,`MaxRetry 5`(dry-run 3),Timeout 2 小时;同一 KB 同时只允许一个导入任务(Redis 锁);
- 去重基于 `CalculateFAQContentHash`:对标准问/相似问/反例/答案**归一化**(去 URL、转小写、繁转简、全角转半角、智能空格)后 SHA256;
- append 模式做四阶段校验(标准问冲突→整条失败;相似问/反例冲突→部分失败仅剔除冲突项;标准问已存在→合并并集);
- 进度写 Redis(`FAQImportProgress`:`pending/processing/completed/failed`、成功/失败/部分失败/跳过计数),失败条目导出为带 UTF-8 BOM 的 CSV 供下载。
## 11. 知识克隆与移动(knowledge_clone_move.go)
### 11.1 克隆(CloneKnowledgeBase / CloneChunk)
- KB 级克隆先复制 KB 配置,再按集合差(`AminusB`)增删 Knowledge,并行处理(删除批 10、克隆逐个)。
- Chunk 级克隆(批量 100)复制 `Text/ParentText/Summary/ImageCaption/ImageOCR` 五类 chunk:
- **图片深拷贝**:`cloneChunkImageInfo` 从源存储读字节 → 写入目标租户 `exports/` 命名空间,`urlCache` 去重;`rewriteContentImageURLs` 将 Content 中旧 URL 全部替换(最长 URL 优先避免部分匹配);
- 标签映射 `getOrCreateTagInTarget`(同名复用,否则新建);
- 重建 `PreChunkID/NextChunkID/ParentChunkID` 映射后批量插入;
- 向量索引通过 `retrieveEngine.CopyIndices()` 直接复制,不重算 embedding。
- FAQ KB 克隆走差量同步:`chunkRepo.FAQChunkDiff` 按 `content_hash` 算出增/删/匹配三组,匹配对仅同步状态(`IsEnabled/Flags/TagID/AnswerStrategy`)。
- 进度写 Redis(`KBCloneProgress`)。
### 11.2 移动(ProcessKnowledgeMove)
门槛检查:源/目标 KB **类型必须相同**且 **EmbeddingModelID 必须相同**。两种模式:
- `reuse_vectors`:要求 `sourceKB.SharesStoreWith(targetKB)`(同一向量存储实例),`CopyIndices` 复制索引 → 删源索引 → `MoveChunksByKnowledgeID` 改 chunk 归属 → 清 Tag 关联 → 更新 Knowledge 的 KB ID;
- `reparse`:跨向量存储时使用。`cleanupKnowledgeResources`(删索引/chunks/图谱/回冲存储统计)→ Knowledge 重置为 `pending` 挂到目标 KB → 重新入队 `TypeDocumentProcess`(manual 类型走 `triggerManualProcessing`)。
## 12. 端到端时序小结
一篇启用了多模态、问题生成与图谱的 PDF,完整旅程是:
1. `POST /knowledge-bases/:id/knowledge/file` → MD5 去重 → `cos://tenant/kb/uuid.pdf` → Knowledge(`pending`) → asynq `document:process`;
2. Worker:Span attempt=1 开根 → `docreader` 阶段 gRPC 调 Python 服务拿 Markdown+图片字节 → 图片上传存储并重写 URL → `chunking` 阶段 Go chunker 切块 → 写 chunks → `embedding` 阶段 BatchIndex → `EnableStatus=enabled`(此刻已可检索)→ 每图入队 multimodal 任务;
3. 多模态 worker 逐图 OCR+Caption,生成 image_caption/image_ocr 子 chunk 并索引;全部完成后触发 post-process;
4. 编排器计算 `expectedSubtasks`(1 摘要 + N/20 问题批 + M 图谱 + 0/1 Wiki)→ `SetFinalizing` → 扇出;每个子任务终态 `FinalizeSubtask` 递减,减到 0 → `completed`;
5. 期间任一环节僵死,Housekeeping 5 分钟一轮按"updated_at + span 心跳 + 队列检查"三重判据回收为 `failed`,用户可 reparse(attempt+1)重来。
@@ -0,0 +1,525 @@
# 检索问答全流程(RAG Pipeline)
本文完整描述 WeKnora 中一次"知识问答"请求从 HTTP 入口到流式回答落盘的全链路:SSE 会话装配 → 事件驱动 Pipeline(意图识别 / 查询改写 / 并行检索 / 重排 / 融合合并 / 过滤 / 数据分析 / 上下文组装 / 流式生成)→ 引用(citation)展开 → 流式输出与断线续传。
各环节对应的源码位置:
| 环节 | 源码位置 |
|------|----------|
| HTTP 入口 / SSE 装配 | `internal/handler/session/qa.go`、`helpers.go`、`stream.go` |
| EventBus → 流事件桥接 | `internal/handler/session/agent_stream_handler.go` |
| Pipeline 编排 | `internal/application/service/session_knowledge_qa.go` |
| 插件框架与全部阶段插件 | `internal/application/service/chat_pipeline/` |
| 插件注册(DI 容器) | `internal/container/container.go` |
| 事件/状态类型 | `internal/types/chat_manage.go`、`internal/types/chat.go`、`internal/event/event.go` |
| 跨库混合检索 | `internal/application/service/knowledgebase_search*.go` |
| 流管理器(断线续传) | `internal/stream/`(`factory.go`、`memory_manager.go`、`redis_manager.go`) |
| 会话 / 消息管理 | `internal/application/service/session.go`、`message.go` |
| 引用别名与展开 | `internal/llmreference/`、`internal/llmresource/` |
| 文本工具 | `internal/searchutil/` |
| Prompt 模板 | `config/prompt_templates/`、`internal/config/config.go` |
## 1. 总体架构
WeKnora 的问答链路是一条**事件驱动的插件管线(Event-Driven Plugin Pipeline)**:每个阶段是一个实现了 `Plugin` 接口的插件,注册到 `EventManager` 上;编排器(`KnowledgeQAByEvent`)按动态组装的 `EventType` 列表逐个触发事件,插件通过责任链(`next()`)串联。生成结果不直接写 HTTP 响应,而是通过每请求独立的 `EventBus` 发布事件,由 `AgentStreamHandler` 落入共享 `StreamManager`(内存或 Redis),HTTP 层以 100ms 轮询将事件推给 SSE 客户端——这一设计天然支持**断线重连续传**与**分布式多副本部署**。
```mermaid
flowchart TD
subgraph HTTP["HTTP 层 (internal/handler/session)"]
A1["POST /sessions/:id/knowledge-qa"]
A2["POST /sessions/:id/agent-qa"]
A3["GET /sessions/continue-stream/:id"]
A4["POST /sessions/:id/stop"]
end
subgraph Setup["SSE 装配 (qa.go executeQA / setupSSEStream)"]
B1["创建 user/assistant Message"]
B2["每请求独立 EventBus"]
B3["AgentStreamHandler.Subscribe"]
B4["startStopWatcher 停止监视"]
B5["GenerateTitleAsync 异步标题"]
end
subgraph Pipeline["事件驱动 Pipeline (session_knowledge_qa.go)"]
C0["LOAD_HISTORY"]
C1["QUERY_UNDERSTAND 改写+意图+实体"]
C2["CHUNK_SEARCH_PARALLEL 并行检索"]
C3["CHUNK_RERANK 重排+Wiki加权"]
C4["WEB_FETCH 网页全文抓取"]
C5["CHUNK_MERGE 融合合并"]
C6["FILTER_TOP_K 截断"]
C7["DATA_ANALYSIS DuckDB分析"]
C8["INTO_CHAT_MESSAGE 上下文组装"]
C9["CHAT_COMPLETION_STREAM 流式生成"]
end
subgraph Streaming["流式输出"]
D1["EventBus 事件"]
D2["AgentStreamHandler"]
D3["StreamManager 内存/Redis"]
D4["SSE 100ms 轮询推送"]
end
A1 --> Setup
A2 --> Setup
Setup --> C0 --> C1 --> C2 --> C3 --> C4 --> C5 --> C6 --> C7 --> C8 --> C9
C9 --> D1 --> D2 --> D3 --> D4
A3 --> D3
A4 --> D3
```
## 2. 事件驱动的插件框架
### 2.1 Plugin 接口与责任链
`internal/application/service/chat_pipeline/chat_pipeline.go` 定义了核心抽象:
```go
type Plugin interface {
OnEvent(ctx context.Context, eventType types.EventType,
chatManage *types.ChatManage, next func() *PluginError) *PluginError
ActivationEvents() []types.EventType
}
```
`EventManager` 维护 `eventType → []Plugin` 映射。`Register` 时按注册顺序追加,并用 `buildHandler` 从后往前构造嵌套闭包责任链:**同一事件上先注册的插件在链外层,后注册的在内层**,外层插件在 `OnEvent` 内调用 `next()` 才会进入内层。插件可以在 `next()` 之前做前置处理(多数插件),也可以先 `next()` 再做后置处理(如 `PluginWikiBoost` 在重排之后加权)。
错误通过 `*PluginError` 传播,预定义错误包括 `ErrSearchNothing`(检索无结果,触发兜底回复而非失败)、`ErrRerank`、`ErrGetChatModel`、`ErrModelCall` 等(`chat_pipeline.go`)。
### 2.2 注册顺序(container.go)
所有插件在 DI 容器中通过 `container.Invoke` 构造并自注册(`internal/container/container.go`),注册顺序即同事件链上的执行顺序:
```go
must(container.Provide(chatpipeline.NewEventManager))
must(container.Invoke(chatpipeline.NewPluginSearch)) // CHUNK_SEARCH
must(container.Invoke(chatpipeline.NewPluginRerank)) // CHUNK_RERANK(链外层)
must(container.Invoke(chatpipeline.NewPluginWebFetch)) // WEB_FETCH
must(container.Invoke(chatpipeline.NewPluginMerge)) // CHUNK_MERGE
must(container.Invoke(chatpipeline.NewPluginDataAnalysis)) // DATA_ANALYSIS
must(container.Invoke(chatpipeline.NewPluginIntoChatMessage)) // INTO_CHAT_MESSAGE
must(container.Invoke(chatpipeline.NewPluginChatCompletion)) // CHAT_COMPLETION
must(container.Invoke(chatpipeline.NewPluginChatCompletionStream)) // CHAT_COMPLETION_STREAM
must(container.Invoke(chatpipeline.NewPluginFilterTopK)) // FILTER_TOP_K
must(container.Invoke(chatpipeline.NewPluginQueryUnderstand)) // QUERY_UNDERSTAND(链外层)
must(container.Invoke(chatpipeline.NewPluginLoadHistory)) // LOAD_HISTORY
must(container.Invoke(chatpipeline.NewPluginExtractEntity)) // QUERY_UNDERSTAND(链内层)
must(container.Invoke(chatpipeline.NewPluginSearchEntity)) // ENTITY_SEARCH
must(container.Invoke(chatpipeline.NewPluginSearchParallel)) // CHUNK_SEARCH_PARALLEL
must(container.Invoke(chatpipeline.NewPluginWikiBoost)) // CHUNK_RERANK(链内层)
```
事件与插件的完整映射(含同事件链序):
| EventType | 插件(按链序) | 源文件 |
|-----------|---------------|--------|
| `load_history` | PluginLoadHistory | `load_history.go` |
| `query_understand` | PluginQueryUnderstand → PluginExtractEntity | `query_understand.go`、`extract_entity.go` |
| `chunk_search` | PluginSearch | `search.go`、`query_expansion.go` |
| `chunk_search_parallel` | PluginSearchParallel(内部组合 PluginSearch + PluginSearchEntity) | `search_parallel.go` |
| `entity_search` | PluginSearchEntity | `search_entity.go` |
| `chunk_rerank` | PluginRerank → PluginWikiBoost | `rerank.go`、`wiki_boost.go` |
| `web_fetch` | PluginWebFetch | `web_fetch.go` |
| `chunk_merge` | PluginMerge | `merge.go`、`merge_overlap.go`、`merge_expand.go`、`merge_faq.go`、`merge_history.go` |
| `data_analysis` | PluginDataAnalysis | `data_analysis.go` |
| `into_chat_message` | PluginIntoChatMessage | `into_chat_message.go` |
| `chat_completion` | PluginChatCompletion | `chat_completion.go` |
| `chat_completion_stream` | PluginChatCompletionStream | `chat_completion_stream.go` |
| `filter_top_k` | PluginFilterTopK | `filter_top_k.go` |
### 2.3 ChatManage:贯穿全程的状态对象
`internal/types/chat_manage.go` 中的 `ChatManage` 由三部分嵌入组成:
- **PipelineRequest**(不可变请求配置):`Query`、`KnowledgeBaseIDs`/`KnowledgeIDs`/`SearchTargets`、`VectorThreshold`/`KeywordThreshold`/`EmbeddingTopK`、`RerankModelID`/`RerankTopK`/`RerankThreshold`、`ChatModelID`/`SummaryConfig`、`FallbackStrategy`、`CitationEnabled`、`EnableRewrite`/`EnableQueryExpansion`、FAQ 策略(`FAQPriorityEnabled`/`FAQDirectAnswerThreshold`/`FAQScoreBoost`)、`DataAnalysisEnabled`、多模态(`Images`/`VLMModelID`/`ChatModelSupportsVision`)、Web 搜索(`WebSearchEnabled`/`WebFetchEnabled`/`WebFetchTopN`)等。
- **PipelineState**(插件间读写的中间态):`RewriteQuery`、`Intent`、`History`、`SearchResult` → `RerankResult` → `MergeResult` 三级结果、`Entity`/`EntityKBIDs`/`GraphResult`、`UserContent`、`RenderedContexts`、`SystemPromptOverride` 等。
- **PipelineContext**(运行时句柄):`EventBus`、`MessageID`(assistant 消息 ID)、`UserMessageID`。
`ChatManage.Clone()` 提供深拷贝(并行检索时避免共享 slice 的并发读写),但**不**拷贝 `PipelineContext`。
### 2.4 动态管线组装(PipelineBuilder)
`session_knowledge_qa.go` 的 `KnowledgeQA` 按请求特征动态组装事件列表:
```go
// 纯聊天(无 KB 且未开 Web 搜索)
pipeline = types.NewPipelineBuilder().
AddIf(hasHistory, types.LOAD_HISTORY).
Add(types.CHAT_COMPLETION_STREAM).Build()
// RAG
pipeline = types.NewPipelineBuilder().
AddIf(hasHistory, types.LOAD_HISTORY).
Add(types.QUERY_UNDERSTAND).
Add(types.CHUNK_SEARCH_PARALLEL).
Add(types.CHUNK_RERANK).
AddIf(req.WebSearchEnabled, types.WEB_FETCH).
Add(types.CHUNK_MERGE).
Add(types.FILTER_TOP_K).
AddIf(chatManage.DataAnalysisEnabled, types.DATA_ANALYSIS).
Add(types.INTO_CHAT_MESSAGE).
Add(types.CHAT_COMPLETION_STREAM).Build()
```
`types.Pipeline` map 中还保留了 `chat` / `chat_stream` / `chat_history_stream` / `rag` / `rag_stream` 等静态预设,供不需要动态组装的调用方使用。
### 2.5 编排器 KnowledgeQAByEvent
`KnowledgeQAByEvent`(`session_knowledge_qa.go`)逐个 `eventManager.Trigger`,并做了大量周边工作:
- 每个阶段包一个 Langfuse span(`pipeline.<event_type>`);`CHAT_COMPLETION_STREAM` 例外(其 OnEvent 立即返回,span 会早于流结束)。
- **进度事件**:`progress.go` 把 `CHUNK_SEARCH_PARALLEL → CHUNK_RERANK → CHUNK_MERGE → FILTER_TOP_K`(含条件性的 `WEB_FETCH`/`DATA_ANALYSIS`)合并为一个前端可见的 `knowledge_search` tool_call 进度窗口;`QUERY_UNDERSTAND` 单独一个 `query_understand` 窗口。错误/短路路径也会关闭窗口,避免前端"正在检索知识库"一直转圈。
- **引用先行**:在触发 `CHAT_COMPLETION_STREAM` 之前调用 `emitKnowledgeReferencesEvent` 把 `MergeResult` 以 `references` 事件发出——保证 SSE 连接关闭前客户端已拿到引用列表。
- **取消优先**:每阶段结束先检查 `ctx.Err()`(用户 stop 会取消上下文),必须先于 `ErrSearchNothing` 判断,否则停止会被误判为"检索无结果"而写入兜底回复。
- **兜底**:`ErrSearchNothing` → `handleFallbackResponse`:`FallbackStrategyFixed` 直接发固定文案 `FallbackResponse`;`FallbackStrategyModel` 用 `FallbackPrompt` 让模型自由回答。
## 3. 各阶段插件详解
### 3.1 LOAD_HISTORY — 加载会话历史
`load_history.go`。`MaxRounds <= 0` 表示 Agent 显式关闭多轮(`MultiTurnEnabled=false`),直接跳过,**不**回退全局默认。否则调用 `loadAndProcessHistory`(`common.go`):
1. `messageService.GetRecentMessagesBySession` 取最近 `maxRounds*2+10` 条消息;
2. 按 `RequestID` 把 user/assistant 配对成 `types.History`(user 侧附加图片 Caption 与附件 prompt;assistant 侧用正则 `regThinkTags` 剥掉思考标签,并携带 `KnowledgeReferences`);
3. 按时间倒序截取 `maxRounds` 轮后再反转为时间正序,写入 `chatManage.History`。
注意:历史 user 消息回放的是原始 `Content` 而非 `RenderedContent`(避免旧版上下文封套混入当前协议),历史引用单独经 `merge_history.go` 注入。
### 3.2 QUERY_UNDERSTAND — 查询改写 + 意图识别(+ 实体抽取)
同一事件上串联两个插件:
**PluginQueryUnderstand**(`query_understand.go`)负责改写与意图分类:
- 输入组合分三种:纯文本(chat model)、文本+图片、纯图片(优先用支持视觉的 chat model,否则 `VLMModelID`)。
- Prompt 来自 `config/prompt_templates/rewrite.yaml`(system + user 对),可被 Agent 级 `RewritePromptSystem`/`RewritePromptUser` 覆盖;占位符 `{conversation}` / `{query}` / `{language}` 由 `types.RenderPromptPlaceholders` 渲染。
- 模型要求输出 JSON:`{"rewrite_query":"...","intent":"kb_search","image_description":"..."}`;解析容错(markdown 包裹、字段别名、OCR 字段合并),JSON 完全解析失败时把原文当作改写结果并默认 `kb_search`。
- 意图枚举(`types.QueryIntent`):`kb_search`、`web_search`、`greeting`、`chitchat`、`follow_up`、`image_only`、`doc_only`、`summarize`、`clarification`。`NeedsKBRetrieval()` 仅对 `kb_search`/`clarification`/`summarize`/空值返回 true;`ChatManage.NeedsRetrieval()` 对 `web_search` 额外看 `WebSearchEnabled`。**后续所有检索类插件都以 `NeedsRetrieval()` 作为跳过条件**。
- 非检索意图时 `applyIntentPromptOverride` 用 `config/prompt_templates/intent_prompts.yaml`(模板 id 与意图值一一对应,如 `greeting`)或 Agent 覆盖设置 `SystemPromptOverride`。
- 图片描述异步回写到 user 消息的 `Images[0].Caption`(供下一轮历史使用)。
- 可用 `QueryUnderstandModelID` 为该阶段单独指定小模型,失败回退 `ChatModelID`。
**PluginExtractEntity**(`extract_entity.go`)在链内层执行:仅当 `NEO4J_ENABLE=true` 且检索范围内存在 `ExtractConfig.Enabled` 的知识库时,用 `config.ExtractManager.ExtractEntity` 模板(`graph_extraction.yaml`)调用 LLM 抽取查询实体,写入 `chatManage.Entity` / `EntityKBIDs` / `EntityKnowledge`,供 `ENTITY_SEARCH` 使用。
### 3.3 CHUNK_SEARCH_PARALLEL — 并行检索(chunk + 图谱实体)
`search_parallel.go`。`NeedsRetrieval()` 为假直接跳过。否则将 `chatManage` `Clone()` 两份,用 `RunParallel` 并发执行:
- `chunk_search`:内部(未注册的)`PluginSearch.OnEvent(CHUNK_SEARCH, ...)`;
- `entity_search`:有实体时执行 `PluginSearchEntity.OnEvent(ENTITY_SEARCH, ...)`,在 Neo4j 中按 `NameSpace{KnowledgeBase, Knowledge}` 并行 `SearchNode`,将命中的图节点/关系转换为 SearchResult 并组装 `GraphResult`。
两路结果合并后 `removeDuplicateResults` 去重(按 chunk ID + 内容签名 `searchutil.BuildContentSignature`)。两路都空时返回 `ErrSearchNothing`。
**PluginSearch**(`search.go`)内部又是两路并发:
1. **KB 检索** `searchByTargets`:
- 按"embedding 模型身份(`model.Name + BaseURL`,跨租户可共享)"对 `SearchTargets` 分组(`ResolveEmbeddingModelKeys`),每组只算一次查询向量(`GetQueryEmbedding`);
- 组内无标签/文档约束的整库目标合并为**一次** `HybridSearch` 调用(`params.KnowledgeBaseIDs` 携带多库),带约束的目标逐个 `searchSingleTarget`(携带 `KnowledgeIDs`/`TagIDs`/`ScopeTagIDs`,且显式圈定范围的目标可 `DisableRecallThresholds` 关闭召回阈值);
2. **Web 搜索** `searchWebIfEnabled`:`WebSearchEnabled` 时用租户/Agent 解析出的 `WebSearchProviderID` 调用 `webSearchService.Search`,结果经 `searchutil.ConvertWebSearchResults` 转为 SearchResult(URL 作为 ID,`KnowledgeSource="web_search"`)。
**查询扩展**(`query_expansion.go`):`EnableQueryExpansion` 且初次召回数少于 `EmbeddingTopK` 时触发。不调用 LLM,本地生成查询变体(去停用词、词序调整、关键短语抽取等)。中文分词走 `types.Jieba.CutForSearch`:连续汉字段落整体交给 jieba 切成词,中英文/数字混排按脚本切换分段处理,不再退化成「一个汉字一个 token」;停用词与长度过滤按 rune 计数,避免多字节字符被误判为单字符,对每个(变体 × SearchTarget)组合并发(信号量上限 16)执行 `HybridSearch`,关键词阈值放宽为原值的 0.8,TopK 放大为 `max(EmbeddingTopK, RerankTopK) * 2`。
### 3.4 CHUNK_RERANK — 重排、复合打分、MMR、Wiki 加权
**PluginRerank**(`rerank.go`,720 行):
1. **Passage 清洗** `cleanPassageForRerank`:重排模型做的是语义相似度,Markdown 结构语法是噪声。代码块与 `$$...$$` 公式块**只脱掉围栏、保留内部正文**(早期实现整块删除,纯代码或纯公式的候选会被清成空串而丢分);HTML 标签、图片引用、链接标记(保留文字)、裸 URL、表格分隔行(数据行转逗号拼接)、标题/引用/加粗/列表标记按序剥离,最后压缩多余空行。
2. **Passage 增强** `getEnrichedPassage`:拼入 `ImageInfo` 的 Caption/OCR 文本与 `ChunkMetadata` 中的生成问题(GeneratedQuestions)。
3. 调用 `rerankModel.Rerank(ctx, RewriteQuery, passages)`,按 `RerankThreshold` 过滤:
- 全部低于阈值且 top1 ≥ `rerankFallbackMinScore`(默认 0.15;用户显式圈定标签/文档范围时为 0,保留权威范围的最佳候选)→ 保留 top1 兜底;
- 无结果且阈值 > 0.3 → **阈值降级**重试一次(`threshold * 0.7`,下限 0.3);
- Rerank API 失败 → 回退原始检索结果继续管线。
4. **复合打分** `compositeScore`:`0.6*模型分 + 0.3*检索基础分 + 0.1*来源权重`(web_search 来源权重 0.95,其余 1.0),clamp 到 [0,1]。基础分/模型分记录在 `Metadata["base_score"]` / `["model_score"]`。早期版本还会乘一个「越靠文档前部越高」的位置先验(±0.05),因为它与分块编辑后的偏移变化耦合且收益不明确,已被移除。
5. **FAQ 加权**:`FAQPriorityEnabled` 且 `FAQScoreBoost > 1.0` 时,FAQ chunk 分数乘以 boost(上限 1.0),记 `Metadata["faq_boosted"]`。
6. **MMR 多样性选择** `applyMMR`(λ=0.7,k=`RerankTopK`):`mmr = 0.7*relevance - 0.3*max_jaccard_redundancy`,用 `searchutil.TokenizeSimple` + `Jaccard` 并行预计算 token 集合,迭代贪心选出 `RerankResult`。
**PluginWikiBoost**(`wiki_boost.go`)注册在同事件链内层,OnEvent 先 `next()`(等重排完成)再后置处理:若 `RerankResult` 中存在 `wiki_page` 类型 chunk 且检索目标中确有开启 Wiki 的 KB,则分数乘 `wikiBoostFactor = 1.3` 并稳定重排序——Wiki 页面是 LLM 预综合的知识,优先于原始 chunk。
### 3.5 WEB_FETCH — 网页全文抓取
`web_fetch.go`。仅当 `WebFetchEnabled && WebSearchEnabled`。取 `RerankResult` 中前 `WebFetchTopN`(默认 3)个 web 结果,并行 `web_fetch.FetchURLContent(ctx, url)` 抓取正文,替换摘要 snippet(截断至 8000 字节)。位于重排之后、合并之前——只为进入上下文的高分网页付出抓取成本。
### 3.6 CHUNK_MERGE — 八步融合合并
`merge.go` 的 `OnEvent` 注释即流程说明:
1. **选择输入**:优先 `RerankResult`,为空则回退 `SearchResult`(按分排序);
2. **去重**:ID + 内容签名;
3. **注入历史引用**(`merge_history.go`):从最近一轮带引用的历史取 `KnowledgeReferences`,与当前查询做 Jaccard 相似度过滤(阈值 0.15),分数打 0.6 折,最多注入 3 条,标记 `MatchTypeHistory`;
4. **父子块解析** `resolveParentChunks`:text 子块与 image_ocr/image_caption 子块都用**当前** parent_text 内容补齐上下文;图片 Markdown 的收窄靠稳定的图片 URL(`PruneMarkdownImagesByImageInfo`)而不是解析器坐标;ImageInfo 严格限定在命中的 text 子块,避免图片密集的父块把兄弟页面的 OCR 全部灌进上下文。image → text → parent_text 这条链只在确实命中图片结果时才多查一次祖父块;
5. **分组顺序合并** `groupAndMergeCurrentContent`:按 `KnowledgeID + ChunkType` 分组,组内按 `ChunkIndex` 排序后 `mergeSequentialChunks`——序号连续、或一方内容包含另一方时用 `searchutil.JoinChunkContent` 拼接,保留最高分,`SubChunkID` 记录被合并块,`mergeImageInfo` 按 URL 去重合并图片信息;
6. **FAQ 答案填充**(`merge_faq.go`):FAQ 类型 chunk 批量回表读 `FAQMetadata`,重写 Content 为 `Q: 标准问题 + Answer: 答案列表`;
7. **短上下文邻居扩展**(`merge_expand.go`):text 块内容不足 350 字符时,批量取 `PreChunkID`/`NextChunkID` 邻居拼接至最长 850 字符;
8. 扩展引入的新重复**再合并一次**,最终去重 + `removePartialOverlaps`(归一化包含判断 / token 重合率 ≥ 0.85 的跨库近重复删除,低分者被删)。
结果写入 `chatManage.MergeResult`。
::: tip 为什么不再用字符偏移
分块支持手工编辑后,`StartAt` / `EndAt` 这些解析器坐标不再能可靠地表示「当前内容在原文中的位置」——一次编辑就可能让区间长度与正文长度对不上。因此合并阶段全面改用**当前正文 + `ChunkIndex` 序号**判断相邻与包含关系(`JoinChunkContent` / `ContainsChunkContent` 做文本级去重拼接),源坐标只保留给引用定位使用。`FILTER_TOP_K` 的平局决胜键也从 `StartAt`/`EndAt` 换成了 `ChunkIndex`。
:::
### 3.7 FILTER_TOP_K — 确定性排序与截断
`filter_top_k.go`。对 `MergeResult`(缺省依次回退 `RerankResult`/`SearchResult`)执行 `sortSearchResultsDeterministically`——分数降序,并以 `KnowledgeID`/`ChunkType`/`ChunkIndex`/`ID` 作稳定平局决胜(merge 阶段的 map 遍历会打乱顺序,此处恢复全局可复现排序),然后截断至 `RerankTopK`。
### 3.8 DATA_ANALYSIS — DuckDB 表格数据分析
`data_analysis.go`。默认关闭(`DataAnalysisEnabled` 来自 Agent 配置)。若 `MergeResult` 命中 CSV/Excel 文件:先滤掉 `table_column`/`table_summary` 类型 chunk,取第一个数据文件,用 `tools.NewDataAnalysisTool` 将文件载入 DuckDB 取得 schema,让 LLM 判断是否需要数据分析并生成 DuckDB SQL(结构化输出 `DataAnalysisInput`),执行后把结果作为 `MatchTypeDataAnalysis`、score=1.0 的合成 SearchResult 追加进 `MergeResult`。
### 3.9 INTO_CHAT_MESSAGE — 上下文组装
`into_chat_message.go`:
- `utils.ValidateInput` 校验查询安全性(注入防护);
- 非检索意图路径:仍走 `ContextTemplate` 渲染(`contexts` 为空),以注入 `current_time` 等运行时元数据;
- **FAQ 优先策略**:`FAQPriorityEnabled` 时把 FAQ 与文档结果分为 `source type="faq" priority="high"` 与 `source type="document" priority="supplementary"` 两个分节;最高分 FAQ ≥ `FAQDirectAnswerThreshold` 时其 context 标记 `match="exact"`(提示模型可直接采纳该答案);
- 普通路径按 `context id="N"` 顺序编号包裹每个增强后的 passage(`getEnrichedPassageForChat` 会把 ImageInfo 以 Markdown 图片+描述内联进内容);
- 头部 `buildDocumentHeader` 输出去重后的文档元信息(title/description);
- 渲染 `SummaryConfig.ContextTemplate`(来自 `config/prompt_templates/context_template.yaml`),占位符 `{query}` / `{contexts}` / `{language}`;追加图片描述(非视觉模型)、引用上下文 `QuotedContext`、附件 prompt;
- 组装后的 `UserContent` **异步回写**到 user 消息的 `RenderedContent`(`persistRenderedContent`),供审计与调试;`RenderedContexts` 保存纯 contexts 串供引用替换用。
### 3.10 CHAT_COMPLETION / CHAT_COMPLETION_STREAM — 生成
两个插件共享 `common.go` 的辅助函数:
- `prepareChatModel`:取 chat model 并从 `SummaryConfig` 装配 `ChatOptions`(Temperature/TopP/Seed/MaxTokens/Thinking 等);
- `prepareMessagesWithHistory`:system prompt = `SystemPromptOverride`(意图覆盖)或 `SummaryConfig.Prompt`(`system_prompt.yaml`),渲染占位符后若检索上下文含 Markdown 图片则追加"检索图片输出要求"段落(`appendRetrievedImageOutputRequirement`);随后按时间序追加历史 Q/A 对,最后是当前 user 消息(视觉模型附带 `Images`)。
`references.go` 的 `prepareMessagesWithReferences` 在此之上做**引用别名替换**(详见 §7):把 `RenderedContexts` 中的位置编号上下文替换为 `llmreference.Registry` 生成的按请求隔离的 chunk 别名视图,并在 system prompt 末尾追加引用协议。
**流式版**(`chat_completion_stream.go`)要求 `EventBus` 必须存在,调用 `chatModel.ChatStream` 后启动 goroutine 消费响应通道:
- `ResponseTypeThinking` → 经 `llmresource.StreamDecoder`(还原 res:// 资源别名)与 `llmreference.StreamExpander`(展开 ref 引用标签)后以 `EventAgentThought` 发出;
- `ResponseTypeAnswer` → 同样双解码后以 `EventAgentFinalAnswer` 发出。带 `Done` 的终态回答**只转发一次**:部分厂商会先按 `finish_reason` 发一次完成、再按流结束哨兵发一次,重复转发会让答案事件排到会话 complete 事件之后;
- `ResponseTypeError` → `EventError`;
- 通道关闭或 ctx 取消时 `flushDecoders` 冲刷解码器缓存的尾部字节(跨 chunk 的别名不丢失)再关闭 thinking 流。
**非流式版**(`chat_completion.go`)直接 `Chat`,然后 `resourceRefs.DecodeResponse` + `sourceRefs.ExpandResponse` 还原全文,结果写 `chatManage.ChatResponse`。
## 4. 完整 RAG 流程图
```mermaid
flowchart TD
Q["用户查询 POST knowledge-qa"] --> P0["LOAD_HISTORY 按 RequestID 配对历史"]
P0 --> P1["QUERY_UNDERSTAND"]
P1 --> P1a["LLM 改写 + 意图分类 + 图片描述"]
P1a --> INT{"NeedsRetrieval 判定"}
P1 --> P1b["ExtractEntity 图谱实体抽取 NEO4J_ENABLE"]
INT -- "greeting / chitchat 等" --> P8
INT -- "kb_search 等" --> P2["CHUNK_SEARCH_PARALLEL"]
P2 --> P2a["chunk_search: 按 embedding 模型分组"]
P2a --> P2b["整库目标合并一次 HybridSearch"]
P2a --> P2c["标签/文档目标逐个检索"]
P2 --> P2d["entity_search: Neo4j SearchNode"]
P2 --> P2e["web search Provider"]
P2b --> P2f["召回不足则本地查询扩展"]
P2c --> P2f
P2d --> DEDUP["去重 ID + 内容签名"]
P2e --> DEDUP
P2f --> DEDUP
DEDUP --> P3["CHUNK_RERANK"]
P3 --> P3a["passage 清洗 + Caption/OCR/问题增强"]
P3a --> P3b["Rerank 模型打分, 阈值过滤/降级/top1 兜底"]
P3b --> P3c["复合分 0.6 model + 0.3 base + 0.1 source"]
P3c --> P3d["FAQ boost + MMR lambda 0.7"]
P3d --> P3e["WikiBoost x1.3 后置加权"]
P3e --> P4["WEB_FETCH 前 N 网页抓全文"]
P4 --> P5["CHUNK_MERGE 八步融合"]
P5 --> P5a["历史引用注入 + 父子块解析"]
P5a --> P5b["重叠合并 + FAQ 答案填充 + 邻居扩展"]
P5b --> P6["FILTER_TOP_K 确定性排序截断"]
P6 --> P7["DATA_ANALYSIS DuckDB 可选"]
P7 --> P8["INTO_CHAT_MESSAGE 上下文模板渲染"]
P8 --> REF["references 事件先行推送"]
REF --> P9["CHAT_COMPLETION_STREAM"]
P9 --> ANS["thinking / answer 流式事件"]
P3b -. "ErrSearchNothing" .-> FB["Fallback 固定文案或模型自由回答"]
DEDUP -. "全空" .-> FB
```
## 5. 会话与消息管理
### 5.1 Session Service(`session.go`)
- CRUD 全套:`CreateSession` / `GetSession`(租户+共享范围)/ `GetOwnedSession`(严格属主,用于 stop 等破坏性操作)/ 分页列表 / `SetSessionPinned` / `UpdateSessionLastRequestState`(记忆输入栏状态:Agent/模型/KB/Web 搜索选择,纯 UI 用)/ 单删、批删、清空。
- **标题生成**:`GenerateTitleAsync` 在 SSE 装配阶段异步触发(会话无标题时),用 `generate_session_title.yaml` 模板调用对话同款模型,结果经 `EventSessionTitle` 事件流出(SSE `response_type=session_title`),HTTP 层在 complete 后最多再等 3 秒接收标题事件。
### 5.2 Message Service(`message.go`)
- user 与 assistant 消息以相同 `RequestID` 关联成一轮;user 消息随请求即 `IsCompleted=true`,assistant 消息在流式结束(或 stop)后由 `completeAssistantMessage` 补全内容与引用。
- `UpdateMessageRenderedContent` / `UpdateMessageImages` 分别被 `INTO_CHAT_MESSAGE` 与 `QUERY_UNDERSTAND` 异步调用回写。
- `GetRecentMessagesBySession` 是历史加载的数据源。
- 附加能力:`IndexMessageToKB`(把问答对写入"聊天历史知识库"供跨会话搜索)、`SearchMessages`(向量+rerank 的消息搜索)。
## 6. 流式输出机制
### 6.1 StreamManager:append-only 事件流
`internal/stream/factory.go` 按 `STREAM_MANAGER_TYPE` 环境变量选择实现:
| 实现 | 存储 | 关键点 |
|------|------|--------|
| `memory`(默认) | 进程内 `map[sessionID]map[messageID]*events` + RWMutex | 单机部署;`GetEvents` 返回事件副本避免竞态 |
| `redis` | Redis List,key = `{REDIS_PREFIX 或 stream:events}:{sessionID}:{messageID}` | `AppendEvent` = RPUSH + 刷新 TTL(工厂传 1 小时);`GetEvents` = LRANGE offset..-1;多副本部署共享,stop 事件也经它跨节点传递 |
接口只有两个方法:`AppendEvent(ctx, sessionID, messageID, StreamEvent)` 与 `GetEvents(ctx, sessionID, messageID, fromOffset) (events, nextOffset, error)`——**生产者只追加,消费者按 offset 拉取**,这使得任意时刻、任意节点都能从头重放。
### 6.2 事件流转:EventBus → AgentStreamHandler → StreamManager → SSE
1. `setupSSEStream`(`qa.go`)为每个请求创建**独立** `event.EventBus` 和可取消的 `asyncCtx`;
2. `AgentStreamHandler.Subscribe()`(`agent_stream_handler.go`)订阅 `thought` / `tool_call` / `tool_result` / `references` / `final_answer` / `reflection` / `error` / `session_title` / `agent.complete` / 工具审批 / MCP OAuth 等事件,将其转换为 `StreamEvent` 追加进 StreamManager。它同时在内存中累积 `answerSegments`(按 answer 事件 ID 分段,非终局轮的"开场白"在后续 tool_call 出现时标记 superseded,不落入持久化答案)与 `knowledgeRefs`,流结束时组装 assistant 消息落库;
3. HTTP 层 `handleAgentEventsForSSE`(`stream.go`)以 100ms ticker 轮询 `GetEvents`,把每个 `StreamEvent` 经 `buildStreamResponse` 包装为 `types.StreamResponse` 后 `c.SSEvent("message", response)` 推送;收到 `complete` 事件结束(新会话可再等 3s 标题事件)。
### 6.3 SSE 协议与 response_type 事件类型
SSE 头由 `setSSEHeaders` 设置(`text/event-stream`、`no-cache`、`keep-alive`、`X-Accel-Buffering: no`)。每条 SSE `message` 是一个 JSON `StreamResponse`(`internal/types/chat.go`):
```go
type StreamResponse struct {
ID string `json:"id"` // request_id
ResponseType ResponseType `json:"response_type"`
Content string `json:"content"` // 增量 chunk,前端负责累积
Done bool `json:"done"`
KnowledgeReferences References `json:"knowledge_references,omitempty"`
SessionID string `json:"session_id,omitempty"`
AssistantMessageID string `json:"assistant_message_id,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
...
}
```
`response_type` 完整清单(`internal/types/chat.go`,另有 handler 层使用的 `stop`):
| response_type | 含义 |
|---------------|------|
| `agent_query` | 查询已受理,携带 `session_id` / `assistant_message_id`(客户端由此拿到续传所需的 message_id) |
| `thinking` | 思考过程增量(reasoning_content) |
| `answer` | 回答文本增量 |
| `references` | 知识引用列表(`knowledge_references` 字段) |
| `tool_call` / `tool_result` | Agent/进度工具调用与结果(RAG 管线的 `knowledge_search`、`query_understand` 进度也走这两类) |
| `reflection` | Agent 反思 |
| `session_title` | 异步生成的会话标题 |
| `error` | 错误(`Done=true` 表示终局错误) |
| `complete` | 流结束标记(前端以此收尾,不再依赖空 answer+done) |
| `tool_approval_required` / `tool_approval_resolved` | 危险 MCP 工具审批请求/结果 |
| `mcp_oauth_required` / `mcp_oauth_resolved` | MCP OAuth 授权请求/结果 |
| `stop` | 用户停止通知(handler 层构造) |
### 6.4 断线续传(continue-stream)与停止
**续传**:`GET /sessions/continue-stream/:session_id?message_id=...`(`stream.go` ContinueStream)。校验会话与消息后,从 offset 0 `GetEvents` **重放全部历史事件**;若已含 `complete` 直接收尾,否则继续 100ms 轮询推送新事件直到 complete——由于生成 goroutine 与 SSE 连接完全解耦(事件写在 StreamManager),刷新页面/网络闪断都不会中断生成。
**停止**:`POST /sessions/:id/stop`(严格属主校验)向 StreamManager 追加 `stop` 事件;两条路径消费它:SSE 轮询循环检测到即向 EventBus 发 `EventStop`;独立的 `startStopWatcher`(300ms 轮询,与客户端连接无关,2 小时兜底超时)保证客户端已断开时 stop 依然能取消生成。`setupStopEventHandler` 收到 `EventStop` 后 `cancel()` asyncCtx,并用 `context.WithoutCancel` 保存已流出的部分内容。
### 6.5 流式问答时序图
```mermaid
sequenceDiagram
participant C as 客户端
participant H as Handler qa.go
participant B as EventBus 每请求
participant S as AgentStreamHandler
participant M as StreamManager 内存/Redis
participant P as Pipeline KnowledgeQAByEvent
participant L as LLM ChatStream
C->>H: POST /sessions/:id/knowledge-qa
H->>H: 创建 user+assistant Message
H->>M: AppendEvent agent_query
H->>B: 创建 EventBus + asyncCtx
H->>S: Subscribe 订阅全部事件
H-->>P: go KnowledgeQA(异步)
H->>M: 100ms 轮询 GetEvents(offset)
M-->>C: SSE agent_query
P->>P: 改写/检索/重排/合并
P->>B: tool_call knowledge_search 进度
B->>S: handleToolCall
S->>M: AppendEvent tool_call
M-->>C: SSE tool_call (pending/completed)
P->>B: references (MergeResult, 先于答案)
B->>S: handleReferences
S->>M: AppendEvent references
M-->>C: SSE references
P->>L: ChatStream(messages)
loop 流式 token
L-->>P: thinking / answer chunk
P->>B: EventAgentThought / FinalAnswer
B->>S: handleThought / handleFinalAnswer
S->>M: AppendEvent thinking / answer
M-->>C: SSE thinking / answer 增量
end
L-->>P: 通道关闭 (Done)
S->>S: 组装最终答案 + 引用, 落库 assistant 消息
S->>M: AppendEvent complete
M-->>C: SSE complete, 连接关闭
Note over C,M: 断线后 GET continue-stream 从 offset 0 重放再续推
```
## 7. 引用(Citation)生成机制
### 7.1 llmreference:请求级来源别名与 ref 展开
`internal/llmreference/registry.go`。目标:**内部 ID 不进模型上下文、模型输出的引用可安全展开**。
- `Registry`(每次回答一个实例,含 Agent 的所有工具轮次,绝不跨请求持久化)为来源分配低熵别名:`cN`=知识 chunk、`wN`=网页、`dN`=文档、`bN`=知识库。
- `ProtocolPrompt(citationsEnabled)` 追加到 system prompt:启用引用时要求模型用 `ref id="cN"` 形式的自闭合标签内联引用(禁止自造 kb/web 标签);禁用时(`PipelineRequest.CitationEnabled=false`,默认为启用)禁止任何引用输出。
- `references.go` 的 `prepareMessagesWithReferences` 把 `MergeResult` 按 FAQ 优先序 `RegisterSearchResults` 注册,用 `ModelOutput`(`model_output.go`)把知识/网页结果渲染为面向模型的紧凑 XML 视图(`display_type=search_results` / `web_search_results`),并**替换**消息中原来的 `RenderedContexts`。
- 模型输出中的 `ref` 标签由 `ExpandText` / `StreamExpander`(流式,处理跨 chunk 分裂的标签)展开为公开标签:chunk → `kb` 标签(携带 chunk_id、knowledge_id 等属性),网页 → `web url title` 标签;未知别名 fail-closed 直接删除。前端据此渲染角标引用。
- 独立于内联引用,`MergeResult` 始终以 `references` SSE 事件整体推送(驱动"召回结果"面板),即使内联引用被禁用。
### 7.2 llmresource:存储资源句柄别名
`internal/llmresource/registry.go` 解决另一类问题:`resource://`、`minio://`、`cos://` 等高熵存储句柄以及 wiki `summary/<uuid>` slug 进入模型上下文后,模型复述时容易篡改 URL。`EncodeMessages` 把它们替换为 `res://0001` 形态的低熵别名;流式输出经 `StreamDecoder` 还原(`Flush` 保证跨 chunk 别名不截断丢失),工具调用参数在解码后同样回填真实句柄。
## 8. 跨库并发检索与融合(HybridSearch)
`internal/application/service/knowledgebase_search.go` 是所有检索的汇聚点(chat pipeline、Agent 工具、搜索 API 共用):
1. **授权与校验**:批量加载 KB(含跨租户 Organization 共享库),逐库 `authorizeKBAccess`;`validateSameEmbeddingModel` 拒绝跨 embedding 空间的多库检索(wiki/graph 无向量库有豁免)。
2. **入参归一化 + 过召回**:`MatchCount <= 0`(调用方未传时 JSON 反序列化即为 0)先经 `normalizedMatchCount` 归一化为 `types.DefaultRetrievalTopK`(50),使过召回下限、FAQ 迭代触发条件、末尾截断三处读到同一个值——否则截断会把结果集切成 `[:0]`,负数还会越界 panic;随后 `matchCount = max(MatchCount*5, 50) * len(KBs)`,上限 `maxRetrievalPoolSize`(500)。
3. **查询向量只算一次**,随 `params.QueryEmbedding` 传播到所有 store 组。
4. **storeGroup 分组**(`knowledgebase_search_storegroup.go`):按 `(VectorStoreID, 属主租户)` 分组;每组经 `retriever.CreateRetrieveEngineForKB` 解析出 `CompositeRetrieveEngine`。`buildRetrievalParams` 按组内每个 KB 的类型路由:FAQ 库走 FAQ 向量索引(`KnowledgeType=faq`,无关键词索引),文档库走默认向量索引 + 关键词索引。
5. **fan-out**(`knowledgebase_search_fanout.go`):单组直查零开销;多组用 `errgroup` 并发(上限 4),每组超时 `MULTI_STORE_RETRIEVE_TIMEOUT_SEC`(默认 30s),all-or-nothing 失败策略;结果跨引擎类型时用 `EngineAwareNormalizer` 把向量分归一化到 [0,1](详见检索引擎文档)。
6. **融合**(`knowledgebase_search_fusion.go`):
- 仅向量或仅关键词 → `deduplicateByScore`(按 chunk 保留最高分);
- 混合 → **加权 RRF**:`score = vectorWeight/(k+vectorRank) + keywordWeight/(k+keywordRank)`,`k` 与权重来自租户 `RetrievalConfig`(有缺省值),rank 基于各自检索器返回顺序(1-indexed),对分数尺度免疫。
7. **FAQ 命中策略**(`knowledgebase_search_faq.go`,仅 FAQ 类型 KB):
- **迭代检索**:去重后不足 `MatchCount` 且首轮已打满 → 从 `TopK*3` 起最多 5 轮翻倍扩大 TopK 重检索,跨 store 组统一生效,chunk 数据缓存避免重复回表;种子与每轮增长均封顶 `maxRetrievalPoolSize`,触顶即停(再迭代只会重发同一个查询);
- **负例问题过滤**:查询与 FAQ 的 `NegativeQuestions` 精确匹配(小写去空格)即剔除该条——支持"这个问题不要用这条 FAQ 答"的运营配置。
8. 截断到 `MatchCount` 后 `processSearchResults` 补全 chunk 元数据(管线场景 `SkipContextEnrichment=true`,上下文组装留给 merge 阶段)。
FAQ 在管线侧的配套策略(Agent 配置 `FAQPriorityEnabled` / `FAQScoreBoost` / `FAQDirectAnswerThreshold`)见 §3.4 与 §3.9。
## 9. 关键词提取与 searchutil
`config/prompt_templates/keywords_extraction.yaml` 提供"从问题中提取至多 5 个关键词"的 system+user 模板对,经 `internal/config/config.go` 的 `prompt_templates` 加载器载入并通过租户模板 API(`internal/handler/tenant.go`)暴露给前端配置。管线内的查询扩展(§3.3)则采用无 LLM 的本地启发式生成关键词变体。
`internal/searchutil/` 是检索/合并共用的纯函数库:
| 文件 | 关键函数 | 用途 |
|------|----------|------|
| `textutil.go` | `BuildContentSignature` / `NormalizeContent` / `IsContentContained` / `ContentOverlapRatio` | 内容签名去重、归一化、包含/重合率判定(merge 去重) |
| `textutil.go` | `TokenizeSimple` / `Jaccard` | 简易分词(中文按字、英文按词)与 Jaccard 相似度(MMR、历史引用过滤) |
| `chunkmerge.go` | `AppendWithOverlap` / `MergeTextChunks` | 按文本匹配拼接重叠 chunk |
| `imageinfo.go` / `imageinfo_match.go` | `CollectImageInfoByChunkIDs`、`EnrichContentWithImageInfoForChat`、`FilterImageInfoByMatchRange`、`PruneMarkdownImagesOutsideRange`、`SliceContentByDocumentRange` | 图片信息收集、按命中窗口过滤、内容富化 |
| `conversion.go` | `ConvertWebSearchResults` | Web 搜索结果转 SearchResult |
| `normalize.go` | `NormalizeKeywordScores` | 关键词分数归一化工具 |
## 10. Prompt 模板与代码对应
模板由 `internal/config/config.go` 的 `loadPromptTemplates` 从 `config/prompt_templates/` 目录加载进 `PromptTemplatesConfig`,每个 yaml 为一组带 `id`/`i18n`/`default` 的模板列表;`system_prompt_id` / `context_template_id` 等配置项按 id 解析出默认模板文本。
| 模板文件 | 配置字段 | 使用位置 |
|----------|----------|----------|
| `rewrite.yaml` | `Conversation.RewritePromptSystem/User` | `query_understand.go` 改写+意图分类(含 `{conversation}`/`{query}`/`{language}` 占位符) |
| `intent_prompts.yaml` | `Conversation.IntentSystemPrompts` | 非检索意图的 system prompt 覆盖(模板 id = intent 值) |
| `system_prompt.yaml` | `Conversation.Summary.Prompt` | RAG 回答 system prompt(`common.go prepareMessagesWithHistory`) |
| `context_template.yaml` | `Conversation.Summary.ContextTemplate` | 检索上下文渲染(`into_chat_message.go`) |
| `fallback.yaml` | `Conversation.FallbackPrompt/Response` | 检索无结果兜底(`handleFallbackResponse`) |
| `generate_session_title.yaml` | — | 会话标题异步生成(`session.go GenerateTitle`) |
| `keywords_extraction.yaml` | `PromptTemplates.KeywordsExtraction` | 关键词提取模板(租户模板 API 暴露) |
| `generate_questions.yaml` / `generate_summary.yaml` | — | 入库富化(问题生成/摘要,见文档入库文档) |
| `graph_extraction.yaml` | `ExtractManager.ExtractEntity/ExtractGraph` | 查询实体抽取(`extract_entity.go`)与图谱构建 |
| `agent_system_prompt.yaml` | — | Agent 模式 system prompt(见 Agent 文档) |
占位符统一用 `types.RenderPromptPlaceholders` 渲染(`{query}`、`{contexts}`、`{conversation}`、`{language}` 等)。引用协议(§7.1)是系统级追加,**不在**任何用户可编辑模板中。
@@ -0,0 +1,355 @@
# 异步任务系统
WeKnora 的文档解析、索引构建、富化(摘要 / 问题生成 / 图谱抽取 / 多模态)、Wiki 生成、数据源同步、批量删除与重解析等所有耗时操作,都通过基于 [asynq](https://github.com/hibiken/asynq)(Redis 作为 broker)的异步任务系统执行。涉及的主要源码:
| 模块 | 源码路径 |
| --- | --- |
| 任务注册与 worker pool 构建 | `internal/router/task.go` |
| Lite 模式同步执行器(无 Redis) | `internal/router/sync_task.go` |
| 任务巡检 / 取消 / 运维面板 | `internal/router/task_inspector.go`、`internal/router/task_inspector_errors.go` |
| 队列拓扑与任务类型定义 | `internal/types/task.go` |
| 死信中间件 | `internal/middleware/asynqdl/asynqdl.go` |
| 持久化任务队列 / 死信仓储 | `internal/application/repository/task_queue.go` |
| 死信 / 待处理操作模型 | `internal/types/task_dead_letter.go`、`internal/types/task_pending_op.go` |
| 事件总线 | `internal/event/`(`event.go`、`event_data.go`、`global.go`、`middleware.go`、`adapter.go`) |
| 运行时辅助(DI 容器、启动横幅、uptime) | `internal/runtime/`(`container.go`、`server.go`、`startup.go`) |
| 卡死任务兜底清扫 | `internal/application/service/knowledge_housekeeping.go` |
## 1. 总体架构:双执行模式
WeKnora 有两种任务执行模式,通过部署形态选择:
- **asynq 模式(标准部署)**:任务经 `asynq.Client` 序列化为 JSON payload 写入 Redis 队列,由多个独立的 `asynq.Server`(worker pool)消费。`internal/router/task.go` 中 `RunAsynqServer()` 构建统一的 `asynq.ServeMux` 并在 6 个 pool 上运行。
- **Lite 模式(单机 / macOS App,无 Redis)**:`internal/router/sync_task.go` 的 `SyncTaskExecutor` 实现同一个 `interfaces.TaskEnqueuer` 接口,`Enqueue` 直接把任务派发到 goroutine 执行,支持 `ProcessIn`(延迟)与 `MaxRetry` 选项;重试为线性退避(`attempt * 5s`,上限 30s)。
```go
// internal/router/sync_task.go
// SyncTaskExecutor executes tasks synchronously (in a goroutine) without Redis.
// Used in Lite mode as a drop-in replacement for *asynq.Client.
```
两种模式注册的 handler 集合完全一致(对比 `RunAsynqServer` 与 `RegisterSyncHandlers`),保证任务语义不因部署形态漂移。
## 2. Redis 在系统中的角色
| 角色 | 说明 | 源码位置 |
| --- | --- | --- |
| asynq broker | 所有任务队列(pending list、scheduled/retry ZSET、archived ZSET)都存储在 Redis 中;dequeue 原子(`BRPOPLPUSH`),保证一个任务只被一个 worker 执行 | `internal/router/task.go` `getAsynqRedisClientOpt()` |
| 任务巡检数据源 | `asynq.Inspector` + 直接的 `LPos`/`ZRank`/`ZRevRank` 分页读取 | `internal/router/task_inspector.go` |
| Wiki ingest 互斥锁 | `wiki:active:<kbID>`、finalize 锁、slug 锁均为 `SetNX` + TTL | `internal/application/service/wiki_ingest.go`、`wiki_ingest_batch.go` |
| 多模态子任务计数器 | 图片子任务完成计数(DECR),最后一个 attempt 触发 finalize | `image_multimodal` 相关服务 |
| 限流 | 滑动窗口限流 ZSET(见可观测性文档) | `internal/ratelimit/limiter.go` |
Redis 连接参数来自环境变量 `REDIS_ADDR` / `REDIS_USERNAME` / `REDIS_PASSWORD` / `REDIS_DB` / TLS 配置。读写超时由 `WEKNORA_REDIS_OP_TIMEOUT_MS` 控制,默认 500ms(写超时为其 2 倍以吸收队头阻塞):
```go
// internal/router/task.go
const defaultRedisOpTimeoutMs = 500
opt := &asynq.RedisClientOpt{
Addr: os.Getenv("REDIS_ADDR"),
ReadTimeout: time.Duration(timeoutMs) * time.Millisecond,
WriteTimeout: time.Duration(timeoutMs*2) * time.Millisecond,
...
}
```
## 3. 任务类型清单
任务类型常量定义在 `internal/types/task.go`:
| 任务类型 | 常量 | 用途 | 队列 |
| --- | --- | --- | --- |
| `document:process` | `TypeDocumentProcess` | 文档解析入口(DocReader / 切分 / 向量化) | `default` |
| `manual:process` | `TypeManualProcess` | 手工知识更新(cleanup + 重新索引) | `default` |
| `temporary_document:process` | `TypeTemporaryDocumentProcess` | 会话临时文档(聊天附件)解析 | `chat_attachment` |
| `knowledge:post_process` | `TypeKnowledgePostProcess` | 知识后处理统一调度(fan-out 富化子任务) | `postprocess` |
| `summary:generation` | `TypeSummaryGeneration` | 摘要生成 | `summary` |
| `datatable:summary` | `TypeDataTableSummary` | 表格摘要 | `summary` |
| `image:multimodal` | `TypeImageMultimodal` | 图片 OCR + VLM Caption | `multimodal` |
| `chunk:extract` | `TypeChunkExtract` | 图谱实体/关系抽取(按 chunk) | `graph` |
| `question:generation` | `TypeQuestionGeneration` | 问题生成(按 chunk 批次 fan-out) | `question` |
| `datasource:sync` | `TypeDataSourceSync` | 数据源同步 | `sync` |
| `faq:import` | `TypeFAQImport` | FAQ 导入(含 dry run) | `low`(maintenance) |
| `kb:clone` | `TypeKBClone` | 知识库复制 | `low` |
| `kb:delete` | `TypeKBDelete` | 知识库删除 | `low` |
| `index:delete` | `TypeIndexDelete` | 索引删除 | `low` |
| `knowledge:list_delete` | `TypeKnowledgeListDelete` | 批量删除知识 | `low` |
| `knowledge:list_reparse` | `TypeKnowledgeListReparse` | 批量重解析 | `low` |
| `knowledge:move` | `TypeKnowledgeMove` | 知识移动 | `low` |
| `wiki:ingest` | `TypeWikiIngest` | Wiki 页面生成/同步 | `wiki` |
| `wiki:finalize` | `TypeWikiFinalize` | Wiki KB 级收尾(防抖:索引重建/死链清理/交叉链接) | `wiki` |
所有 payload 结构体(如 `DocumentProcessPayload`、`ImageMultimodalPayload`)都内嵌 `types.TracingContext`,用于跨进程传递 Langfuse/W3C traceparent(见可观测性文档),并统一携带 `tenant_id` / `knowledge_id` / `knowledge_base_id` 等路由字段,供死信归档与取消匹配使用。
## 4. Worker Pool 拓扑与治理策略
`internal/types/task.go` 中的 `queueDefinitions` 是队列拓扑的**唯一事实来源**(single source of truth),worker server 构建(`QueueWeightsForPool`)与运维面板展示(`QueueStats`)共用该注册表,防止权重漂移。
### 4.1 六个独立 worker pool
每个 pool 是一个独立的 `asynq.Server`,并发度**硬隔离**(不是权重偏好)。默认并发与配置键(system_settings 键 / 环境变量,见 `types.ResolveWorkerPoolConcurrency`):
| Pool | 默认并发 | 消费队列(权重) | 配置键 / 环境变量 |
| --- | --- | --- | --- |
| `core` | 8 | `default`(1)、`chat_attachment`(3) | `asynq.core_concurrency` / `WEKNORA_ASYNQ_CORE_CONCURRENCY` |
| `postprocess` | 2 | `postprocess`(1) | `asynq.postprocess_concurrency` / `WEKNORA_ASYNQ_POSTPROCESS_CONCURRENCY` |
| `enrichment` | 12 | `summary`(2)、`multimodal`(1)、`graph`(1)、`question`(1) | `asynq.enrichment_concurrency` / `WEKNORA_ASYNQ_ENRICHMENT_CONCURRENCY` |
| `maintenance` | 4 | `sync`(2)、`low`(1) | `asynq.maintenance_concurrency` / `WEKNORA_ASYNQ_MAINTENANCE_CONCURRENCY` |
| `shared`(弹性层) | 6 | core + enrichment 中 `SharedWeight > 0` 的队列 | `asynq.shared_concurrency` / `WEKNORA_ASYNQ_SHARED_CONCURRENCY` |
| `wiki` | 8 | `wiki`(1) | `asynq.wiki_concurrency` / `WEKNORA_WIKI_ASYNQ_CONCURRENCY` |
设计要点(源码注释均可佐证):
- **保障容量 + 弹性借用**:core/postprocess/enrichment/maintenance 提供最低保障容量;`shared` pool 同时订阅 core 与 enrichment 的队列,闲置容量可被任一阶段借用(`NewSharedAsynqServer`:Redis dequeue 原子,多 server 订阅同一队列每个任务仍只执行一次)。post-process 与 maintenance 被刻意排除在 shared 之外(`QueueWeightsForSharedPool` 注释:post-process 需要延迟保证,长 maintenance 任务不应占用面向用户的突发容量)。
- **Wiki 硬隔离**:`wiki` pool 只拉取 `wiki` 队列,防止解析流水线与 Wiki 生成互相饿死(`NewWikiAsynqServer` 注释)。
- **聊天附件优先**:`chat_attachment` 在 core pool 权重 3 高于 `default` 的 1,大批量 KB 导入不会让交互式聊天上传排队。
- **滚动升级兼容**:`QueueMaintenance` 常量的物理 Redis 队列名保持旧版的 `"low"`,旧版本入队的任务在滚动部署期间仍可被消费。
### 4.2 Worker Pool 架构图
```mermaid
flowchart LR
subgraph Producers["生产者 (API handlers / services)"]
API["HTTP API<br/>(上传 / 重解析 / 删除 / 同步...)"]
CRON["调度器<br/>(datasource scheduler 等)"]
end
API -->|"asynq.Client.Enqueue"| REDIS
CRON -->|"asynq.Client.Enqueue"| REDIS
subgraph REDIS["Redis (asynq broker)"]
Q1["default (1)"]
Q2["chat_attachment (3)"]
Q3["postprocess (1)"]
Q4["summary (2)"]
Q5["multimodal (1)"]
Q6["graph (1)"]
Q7["question (1)"]
Q8["sync (2)"]
Q9["low (1, maintenance)"]
Q10["wiki (1)"]
end
subgraph Workers["六个独立 asynq.Server (共享同一个 ServeMux)"]
CORE["core pool<br/>并发 8"]
PP["postprocess pool<br/>并发 2"]
EN["enrichment pool<br/>并发 12"]
MT["maintenance pool<br/>并发 4"]
SH["shared pool (弹性)<br/>并发 6"]
WK["wiki pool<br/>并发 8"]
end
Q1 --> CORE
Q2 --> CORE
Q3 --> PP
Q4 --> EN
Q5 --> EN
Q6 --> EN
Q7 --> EN
Q8 --> MT
Q9 --> MT
Q10 --> WK
Q1 -. "弹性借用" .-> SH
Q2 -. "弹性借用" .-> SH
Q4 -. "弹性借用" .-> SH
Q5 -. "弹性借用" .-> SH
Q6 -. "弹性借用" .-> SH
Q7 -. "弹性借用" .-> SH
subgraph MW["ServeMux 中间件链 (安装顺序)"]
M1["1. asynqdl 死信中间件<br/>(最先安装, 看到原始错误)"]
M2["2. backgroundTaskMiddleware<br/>(标记后台任务, 模型并发治理)"]
M3["3. langfuse.AsynqMiddleware<br/>(trace 续接 + SPAN 包裹)"]
end
Workers --> MW --> H["业务 Handler<br/>(KnowledgeService.ProcessDocument 等)"]
```
### 4.3 中间件治理
`RunAsynqServer`(`internal/router/task.go`)在同一个 mux 上按顺序安装三个中间件:
1. **`asynqdl.MiddlewareWithCallback`(死信)** — 必须最先安装,以便看到 handler 返回的原始错误(后续中间件可能转换错误)。见第 7 节。
2. **`backgroundTaskMiddleware`** — 对每个任务 context 打 `types.WithBackgroundTask` 标记,使 per-model 聊天并发治理器(chat concurrency governor)对 ingestion/enrichment 的 LLM 调用限流,但不影响交互式用户聊天。
3. **`langfuse.AsynqMiddleware`** — Langfuse 关闭时为直通;开启时续接上游 HTTP trace 或新开独立 trace,将 handler 执行包成 SPAN。
### 4.4 重试退避策略
默认使用 asynq 的指数退避(约 10s、40s、90s、2.5m…),但对 Wiki ingest 锁冲突做了定制(`asynqRetryDelayFunc`):
```go
// internal/router/task.go
func asynqRetryDelayFunc(n int, e error, t *asynq.Task) time.Duration {
if errors.Is(e, service.ErrWikiIngestConcurrent) {
return wikiIngestRetryDelay // 固定 15s
}
return asynq.DefaultRetryDelayFunc(n, e, t)
}
```
原因:孤儿锁 TTL ≤ 60s,固定 15s 重试几乎必然成功;指数退避反而会让崩溃重启后的 KB 卡 7–10 分钟。
## 5. 任务生命周期状态机
asynq 侧的运行时状态(`internal/router/task_inspector.go` 中 `runtimeTaskState` 映射为 `types.RuntimeTaskState`):`pending`、`active`、`scheduled`、`retry`、`archived`、`completed`。业务侧知识行的 `parse_status`(`internal/types/knowledge.go`):`pending` → `processing` → `finalizing` → `completed`,以及 `failed` / `deleting` / `cancelled`。
```mermaid
stateDiagram-v2
direction LR
[*] --> pending : Enqueue()
[*] --> scheduled : Enqueue(ProcessIn=delay)
scheduled --> pending : 到达 NextProcessAt
pending --> active : worker 原子 dequeue
active --> completed : handler 返回 nil
active --> retry : handler 返回 error<br/>且 retried < max_retry
retry --> pending : 退避时间到<br/>(默认指数, wiki 锁冲突固定 15s)
active --> archived : 最后一次重试仍失败<br/>asynqdl 写 task_dead_letters<br/>+ 回调置 Knowledge=failed
active --> [*] : CancelProcessing 信号<br/>(context.Canceled)
pending --> [*] : TaskInspector.DeleteTask<br/>(取消 / KB 删除)
scheduled --> [*] : TaskInspector.DeleteTask
retry --> [*] : TaskInspector.DeleteTask
archived --> pending : 运维操作 run_now<br/>(Inspector.RunTask, 保留重试计数)
archived --> [*] : 运维删除 / 清空 archived
completed --> [*] : 保留期到期 (asynq TTL)
```
对应的知识行状态(由任务驱动):
```mermaid
stateDiagram-v2
direction LR
[*] --> k_pending : 创建知识
k_pending --> k_processing : document:process 开始
k_processing --> k_finalizing : 主解析完成, 富化子任务在飞<br/>(pending_subtasks_count > 0)
k_finalizing --> k_completed : 最后一个子任务原子归零
k_processing --> k_failed : 死信回调 / housekeeping 清扫
k_finalizing --> k_failed : 死信回调 / housekeeping 清扫
k_processing --> k_cancelled : 用户取消解析
k_finalizing --> k_cancelled : 用户取消解析
k_cancelled --> k_pending : reparse 重新触发
k_failed --> k_pending : reparse 重新触发
k_pending : pending
k_processing : processing
k_finalizing : finalizing
k_completed : completed
k_failed : failed
k_cancelled : cancelled
```
## 6. 任务巡检、取消与运维面板(TaskInspector)
`internal/router/task_inspector.go` 实现 `interfaces.TaskInspector`,asynq 模式下由 `asynq.Inspector` + 原生 Redis client 支撑;Lite 模式为 `noopTaskInspector`(goroutine 无法在启动前被摘除,checkpoint 式中止是唯一停止信号)。
### 6.1 按知识 / 知识库取消
- `CancelTasksForKnowledge(ctx, knowledgeID)`:扫描全部注册队列(`queuesScanned` 来自 `types.QueueDefinitions()`)的 pending/scheduled/retry/active 四个状态,payload 中 `knowledge_id` 匹配即处理。可取消的任务类型白名单 `taskTypesForKnowledgeCancel`:`document:process`、`manual:process`、`image:multimodal`、`knowledge:post_process`、`question:generation`、`summary:generation`、`chunk:extract`(刻意不含 FAQ 导入 / KB 级任务)。
- 取消流程分三阶段(`cancelMatchingTasks`):① 先删干净排队态;② 快照 active 任务后调用 `Inspector.CancelProcessing` 发信号,并在 1s 的 settle 窗口内轮询(25ms 间隔)删除因 `context.Canceled` 转入 retry 的记录(`deleteCancelledTransitions`);③ 再扫一遍排队态,兜住取消期间新入队的下游任务。
- `CancelTasksForKnowledgeBase`:KB 删除后的孤儿任务清理;`kb:delete` 与 `index:delete` 明确排除(它们携带快照、负责真正的存储清理,删掉会泄漏资源)。clone/move 的语义 KB 字段(`source_id`/`target_id`/`source_kb_id`/`target_kb_id`)也参与匹配。
- 一切均为 best-effort:Redis 抖动时记 Warn 日志并吞掉,取消 API 依然返回成功。
- `HasQueuedTasksForKnowledge`:只读探测,housekeeping 清扫用它区分"积压但未孤儿"的行,避免误标 failed。
### 6.2 运维面板(SystemAdmin Runtime Dashboard)
- `QueueStats()`:逐队列 `GetQueueInfo`,输出 `types.QueueStat`(size/pending/active/scheduled/retry/archived/completed、当日 processed/failed、paused、`latency_ms`(最老 pending 任务年龄)、内存占用),并附上静态 pool/weight 元数据。从未创建过的队列返回零值行(`isAsynqQueueNotFound` 同时兼容 asynq v0.26 泄漏的内部 `NOT_FOUND` 错误串,见 `task_inspector_errors.go`)。
- `ListRuntimeTasks()`:基于 Redis 键 `asynq:{<queue>}:<state>` 直接分页 —— pending/active 是 LIST(最新在前),scheduled/retry 是按 `NextProcessAt` 升序的 ZSET,archived/completed 按分数倒序。游标为 base64 编码的锚点窗口(最多 32 个锚点,`runtimeTaskCursorMaxAnchors`),锚点消失(任务完成/重试/删除)时可继续分页。payload 只投影白名单路由元数据(tenant/kb/knowledge/task/sync 等 ID),**绝不暴露文档内容或密钥**。
- 任务动作由 `runtimeTaskActions` 状态检查约束:`cancel`(pending/active/scheduled/retry 且可取消类型)、`run_now`(scheduled/retry/archived,asynq 保留重试计数)、`delete`(仅 archived);另有 `PurgeArchivedRuntimeTasks` 一键清空单队列 archived 集合。
- `WorkerServerStats()`:读取 asynq server 心跳(并发、活跃 worker 数、状态、队列权重),跨副本聚合后区分"配置的单实例容量"与"实际集群容量"。
对应 HTTP API(`internal/router/router.go`,SystemAdmin + 平台 API Key capability 门控):
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/system/admin/runtime/queues` | 队列深度快照 + worker 心跳 |
| GET | `/api/v1/system/admin/runtime/queues/:queue/tasks` | 按状态游标分页任务列表 |
| POST | `/api/v1/system/admin/runtime/queues/:queue/tasks/:task_id/actions/:action` | `cancel` / `run_now` / `delete`(写平台审计) |
| DELETE | `/api/v1/system/admin/runtime/queues/:queue/archived` | 清空 archived(写平台审计 `system.queue_archived_purged`) |
## 7. 失败重试与死信处理
### 7.1 asynq 死信中间件(`internal/middleware/asynqdl/asynqdl.go`)
- 只在**最后一次尝试**失败时(`isFinalAttempt`:`retried >= max_retry`)写一行 `task_dead_letters`,避免瞬时抖动每次都产生一行。
- `buildDeadLetter` 用宽容的 `payloadProbe` 从任意 payload 提取 `tenant_id` / `knowledge_base_id` / `kb_id` / `knowledge_id` / `source_kb_id`,`inferScope` 按"爆炸半径"推断 scope(`knowledge_base` > `knowledge` > `tenant` > `unknown`)。payload 原样保留(可用于将来重放),`last_error` 截断到 8KB。
- 插入是 best-effort:DB 失败只记日志,原始任务错误始终原样向 asynq 上抛(进入 archived)。
- `OnDeadLetter` 回调(`internal/router/task.go` 的 `newDeadLetterKnowledgeFailer`):`document:process` / `knowledge:post_process` / `manual:process` 耗尽重试时,单条 UPDATE 把知识行 `parse_status=failed` + `error_message` 一并写入(避免半更新),并调用 `SpanTracker.FinalizeAttempt` 关闭对应 attempt 的根 span,让时间线不再显示"进行中"。`knowledge:list_delete` 有专门分支 `markKnowledgeListDeleteFailed`。`image:multimodal` 刻意**不**标记父知识失败(finalize-on-last-attempt 已保证进度)。回调用 `context.Background()` 执行且 panic 被捕获,绝不改变原始任务错误。
### 7.2 持久化任务队列与服务级死信(`internal/application/repository/task_queue.go`)
`task_pending_ops` 表是 Redis list 队列的持久化替代(重启不丢、无 TTL 驱逐),队列身份是 `(task_type, scope, scope_id)` 三元组,目前主要消费者是 Wiki ingest:
- `Enqueue` / `EnqueueIfKnowledgeBaseActive`:后者在事务中用 Postgres `SHARE` 行锁校验 KB 仍存活,防止 KB 软删后仍写入新的持久化工作。
- `ClaimBatch`:按 `dedup_key`(=文档)**整组**原子认领。核心不变量:同一文档的多个 op(如 ingest 后跟 retract)绝不拆到两个并发批次;有新鲜 claim(`claimed_at >= staleBefore`)的 key 整体跳过,晚到的兄弟 op 等待持有者完成或 claim 过期。Postgres 上用每个 key 的 anchor 行 `FOR UPDATE SKIP LOCKED` 保证并发认领者拿到**不相交**的 key 集;SQLite(Lite/测试)依赖单写者引擎。
- `IncrFailCount`(`UPDATE ... RETURNING` 单往返原子自增)配合服务侧上限(wiki 的 `wikiMaxFailRetries`):超限后该 op 从 `task_pending_ops` 移入 `task_dead_letters`(`internal/application/service/wiki_ingest.go` 直接 `deadLetterRepo.Insert`)。
- `ReleaseByIDs` / `DeleteByIDs` / `DeleteByScope` / `DeleteByDedupKey` / `PendingCount` 提供释放、消费确认、KB 生命周期清理与积压观测。
死信仓储 `taskDeadLetterRepository` 提供 `ListByScope` / `ListByTaskType`(id 倒序游标分页,limit 1–200)与 `DeleteByID`;运维可直接 SQL 按任务类型 / scope / 租户查询失败,无需翻日志。
### 7.3 兜底:housekeeping 清扫
`internal/application/service/knowledge_housekeeping.go`:cron 每 5 分钟(`0 */5 * * * *`)扫描卡在 `pending`/`processing`/`finalizing` 超过 stale 阈值的知识行并标记 failed。这是 asynq 重试、死信回调、multimodal finalize 之外的最后防线(worker 被 kill 在 handler 中间、defer 没跑到等场景)。清扫结合 span 心跳、`updated_at` 与 `TaskInspector.HasQueuedTasksForKnowledge`,避免误杀"积压但未孤儿"的行。可用 `WEKNORA_HOUSEKEEPING_ENABLED=false` 关闭。
## 8. 事件总线(`internal/event`)
事件总线用于**进程内**的会话/Agent 流式事件分发(如 SSE 推送、IM 回调),与 asynq(跨进程持久任务)互补。
### 8.1 结构与投递保证
```go
// internal/event/event.go
type Event struct {
ID string // 事件ID (自动生成UUID,用于流式更新追踪)
Type EventType
SessionID string
Data interface{}
Metadata map[string]interface{}
RequestID string
}
```
- `EventBus.On(type, handler)` 注册(同类型可多 handler),`Off` / `Clear` 移除;`HasHandlers` / `GetHandlerCount` 查询。
- **同步模式**(`NewEventBus`,默认):`Emit` 顺序执行 handler,任一 handler 出错立即返回错误(at-most-once,出错中断后续 handler)。
- **异步模式**(`NewAsyncEventBus`):`Emit` 对每个 handler 起 goroutine,fire-and-forget,错误被丢弃,panic 被 recover 记日志。
- `EmitAndWait`:两种模式下都并行执行全部 handler 并等待完成,收集错误与 panic。
- **投递保证为进程内、非持久化**:无注册 handler 时事件被静默丢弃(返回 nil);进程崩溃丢失在飞事件。持久化诉求应走 asynq 或 `task_pending_ops`。
- `global.go` 提供全局单例(`event.On` / `event.Emit`);实践中会话级流式处理使用**独立的 bus 实例**(见订阅者)。
- `middleware.go` 提供 handler 中间件:`WithLogging`(触发/失败日志)、`WithTiming`(耗时写入 metadata)、`WithRecovery`(panic 转 `PanicError`),`Chain` / `ApplyMiddleware` 组合。
- `adapter.go` 的 `EventBusAdapter` 把 `*EventBus` 适配为 `types.EventBusInterface`,避免循环依赖。
### 8.2 事件类型清单(`internal/event/event.go`)
| 分组 | 事件类型 |
| --- | --- |
| 查询处理 | `query.received`、`query.validated`、`query.preprocess`、`query.rewrite`、`query.rewritten` |
| 检索 | `retrieval.start`、`retrieval.vector`、`retrieval.keyword`、`retrieval.entity`、`retrieval.complete` |
| 重排 | `rerank.start`、`rerank.complete` |
| 合并 | `merge.start`、`merge.complete` |
| 聊天生成 | `chat.start`、`chat.complete`、`chat.stream` |
| Agent 生命周期 | `agent.query`、`agent.plan`、`agent.step`、`agent.tool`、`agent.complete` |
| Agent 流式(实时反馈) | `thought`、`tool_call`、`tool_result`、`reflection`、`references`、`final_answer` |
| MCP 工具人工审批 | `tool_approval_required`、`tool_approval_resolved` |
| MCP OAuth 会话内授权 | `mcp_oauth_required`、`mcp_oauth_resolved` |
| 错误 / 会话 / 控制 | `error`、`session_title`、`stop` |
每类事件的数据结构定义在 `internal/event/event_data.go`(如 `AgentToolCallData` 携带 `tool_call_id`/`tool_name`/`arguments`/`hint`,`AgentFinalAnswerData` 携带 `content`/`done`/`is_fallback` 等)。
### 8.3 主要订阅者
| 订阅者 | 源码 | 订阅内容 |
| --- | --- | --- |
| SSE Agent 流式 handler | `internal/handler/session/agent_stream_handler.go` | `thought`、`tool_call`、`tool_result`、`references`、`final_answer`、`reflection`、`error`、`session_title`、`agent.complete`、tool approval 与 MCP OAuth 四类 |
| 知识问答 handler | `internal/handler/session/qa.go`、`helpers.go` | `thought`、`final_answer`、`stop` |
| IM 集成(企微等) | `internal/im/service.go` | `final_answer`、`error`、`references`、`agent.complete`、`thought`、`tool_call`、`tool_result`、`mcp_oauth_required` 等,转译为各 IM 平台消息 |
## 9. `internal/runtime` 包
该包很小,是运行时基础设施而非 worker 逻辑:
- `container.go`:`init()` 创建全局 `*dig.Container`(uber dig),`GetContainer()` 供各包注册/解析依赖。所有 asynq server、handler、repository 都经它装配(实际大规模装配在 `internal/container/container.go`)。
- `server.go`:`MarkServerStarted()` / `ServerStartedAt()` / `ServerUptime()` —— 进程启动时刻记录,供运维面板显示 uptime。
- `startup.go`:`SilenceGinRouteSpam()` 抑制约 150 行 Gin 路由注册日志并汇总为一行(`LogGinRouteCount`);`LogStartupEnv()` 打印精选环境变量横幅(敏感值只显示 `set (N chars)`),并对典型 footgun 发出显式警告(如 `SYSTEM_AES_KEY` 长度不等于 32 时加密实际被禁用、`REDIS_TLS_INSECURE_SKIP_VERIFY=true`)。
## 10. 如何监控任务
1. **运维面板 / Runtime API**(第 6.2 节):队列深度、最老 pending 延迟(`latency_ms`)、当日 processed/failed、worker 心跳;按状态浏览任务、查看 `last_error`、`retried/max_retry`、执行 `run_now`/`cancel`/`delete`。
2. **死信表 SQL**:`SELECT * FROM task_dead_letters WHERE scope='knowledge_base' AND scope_id='<kbID>' ORDER BY id DESC;` 或按 `task_type` 聚合失败率;`task_pending_ops` 的 `PendingCount` / `enqueued_at` 可发现从未排空的积压。
3. **日志**:worker 侧统一走 `internal/logger`,关键前缀有 `[TaskInspector]`(取消/巡检)、`asynq dead-letter`、`[SyncTask]`(Lite 模式)、`[Housekeeping]`;启动时每个 pool 打印 `asynq <pool> server starting with concurrency=...`。
4. **Langfuse trace**:开启后每个 asynq 任务是一个 `asynq.<task_type>` SPAN(含 queue、retry、payload 大小元数据),与触发它的 HTTP 请求同 trace(见可观测性文档)。
5. **平台审计**:对 archived 任务的 `run_now`/`delete`/purge 操作写入 `audit_logs`(`system.queue_task_*` 动作),可追责。