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
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:
@@ -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` 精确定位 `` 位置)。
|
||||
|
||||
随后 `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_*` 动作),可追责。
|
||||
Reference in New Issue
Block a user