This commit is contained in:
2026-08-05 10:28:44 +08:00
commit 0bacf2e0a4
103 changed files with 9415 additions and 0 deletions
+881
View File
@@ -0,0 +1,881 @@
# rag-local 本地知识库 实现方案
> 技术栈:GoFrame v2 + Vue 3 + Eino(字节 CloudWeGo LLM 编排框架)
> 数据库:全部文件型 —— SQL = SQLite,向量 = sqlite-vecSQLite 扩展),全文检索 = SQLite FTS5
> 部署:docker-compose 一键部署,前后端不分离,统一端口暴露
> 鉴权:单用户 + 启动生成的访问令牌(面向边缘设备部署)
---
## 1. 项目概述
本地私有知识库系统(面向 NAS / 小主机等边缘设备单机部署),支持:
- 文档管理:上传(txt / md / pdf / docx / html)、自动解析、分块、向量化
- 混合检索:语义(向量)检索 + 关键词(全文)检索,RRF 融合排序
- RAG 问答:基于 Eino 编排的流式对话,答案附带引用来源
- 模型可配置:对话模型 / 嵌入模型均为 OpenAI 兼容 API,可对接任意供应商(本地 Ollama、DeepSeek、硅基流动、OpenAI 等)
- 单用户访问令牌登录:token 首次启动随机生成并打印在控制台,登录页输入即可使用
设计原则:
1. **全文件型数据库**:业务 SQL、向量、全文检索全部落地为普通文件,随数据目录一键备份/迁移,无任何外部数据库服务
2. **按业务领域分库文件**:系统 / 数据集 / 问答 三个 SQLite 文件,互不干扰
3. **分层文件与表对齐**:每张业务表对应一组 `entity / dao / service / controller / dto` 文件,数量严格对齐(参照 video-factory 17 表 × 5 层的组织方式)
4. **前后端不分离**Vue 构建产物由 GoFrame 统一端口托管,单容器部署
5. **零配置**:不设用户体系与角色权限(单机单人场景),启动即用
---
## 2. 技术选型
| 能力 | 选型 | 说明 |
|---|---|---|
| Web 框架 | GoFrame v2.10+ | 与 video-factory 一致,复用其分层/路由/鉴权模式 |
| SQL 数据库 | SQLitemodernc.org/sqlite 纯 Go 实现) | GoFrame `contrib/drivers/sqlite/v2`,免 CGO`CGO_ENABLED=0` |
| 向量检索 | **sqlite-vec**`modernc.org/sqlite/vec` 子包) | modernc 从 **v1.47.0 起内置** sqlite-vec 的 CGO-free 版本,`vec0` 虚拟表提供 KNN 检索;与业务表**同库同事务**,天然文件型 |
| 全文检索 | SQLite FTS5modernc 内置) | BM25 打分;中文通过**应用层分词**(纯 Go `github.com/go-ego/gse`)写入分词列,simple tokenizer 索引 |
| LLM 编排 | Eino `github.com/cloudwego/eino` | ChatModel / Embedding 自研 OpenAI 兼容 HTTP 实现(eino-ext 为空壳,仅依赖 eino 接口);Indexer / Retriever 自研(SQLite 后端),实现 Eino 标准接口 |
| 文档解析 | pdfcpupdf)、纯 Go zip+xml 解析(docx)、goqueryhtml)、标准库(txt/md | 全部纯 Go,免 CGO |
| 前端 | Vue 3 + Vite + Element Plus + axios | hash 路由,构建产物 `ui-src/dist`,由 Go 统一端口托管 |
| 部署 | Docker 多阶段构建 + docker-compose | 单服务单端口,数据目录挂载宿主机卷 |
### 2.1 关键版本说明(向量扩展)
- sqlite-vec 需要 `modernc.org/sqlite >= v1.47.0`2026-03-17 起内置,免 CGO
- GoFrame v2.10.2 驱动链默认锁定 `modernc.org/sqlite v1.23.1`(过旧),**必须在 go.mod 中显式升级**
```bash
go get modernc.org/sqlite@v1.47.0
```
Go modules 最小版本选择(MVS)会使全链路统一使用 v1.47+glebarez 为薄封装,API 兼容。主程序只需:
```go
import _ "modernc.org/sqlite/vec" // 空导入,init 自动注册 vec0 扩展
```
- **风险与验证**:升级后首步执行 `go build` 验证 glebarez 与 v1.47 兼容;若出现编译错误,启用备选方案(见 §14.1)
---
## 3. 总体架构
```
┌─────────────────────────── 浏览器 ───────────────────────────┐
│ http://host:8080 │
└──────────────────────────────┬───────────────────────────────┘
│ 统一端口(SPA 静态资源 + REST API + SSE 流式)
┌──────────────────────────────▼───────────────────────────────┐
│ GoFrame HTTP Server (:8080) │
│ ┌───────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ 静态资源 │ │ API 路由 │ │ 访问令牌鉴权 / CORS / │ │
│ │ ui-src/dist│ │ /dataset │ │ panic 恢复 中间件 │ │
│ └───────────┘ │ /document ... │ └────────────────────────┘ │
│ └──────┬───────┘ │
│ ┌──────────────┼──────────────┐ │
│ ┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼──────┐ │
│ │ controller │→│ service │→│ dao │ │
│ │ (10 个文件) │ │ (10 个文件) │ │ (10 个文件) │ │
│ └────────────┘ └──────┬─────┘ └──────┬──────┘ │
│ │ │ │
│ ┌─────────▼──────┐ ┌────▼─────────────────────┐ │
│ │ Eino RAG 编排 │ │ SQLite × 3(文件型) │ │
│ │ Graph/Chain │ │ business.db: 业务表+向量+ │ │
│ │ Indexer/Retr. │ │ FTSvec0 + fts5 │ │
│ │ ChatModel │ │ system.db: 令牌/配置 │ │
│ │ Embedding │ │ chat.db: 会话/消息 │ │
│ └───────┬────────┘ └───────────────────────────┘ │
│ │ HTTPOpenAI 兼容 API
└──────────────────────┼────────────────────────────────────────┘
┌────────▼────────┐
│ 模型供应商(可配置)│
│ Ollama/OpenAI/ │
│ DeepSeek/硅基流动 │
└─────────────────┘
```
数据流:
```
上传文档 → 解析文本 → 分块 → Embedding 向量化 ──┐
├─→ business.dbchunk 表 + vec0 + fts5 同库同事务)
关键词检索(FTS5 BM25)───────────────────────┤
问题 → 混合检索(向量 + 关键词 + RRF 融合)→ 组装上下文 → ChatModel 流式回答 → SSE
```
---
## 4. 目录结构
```
rag-local/
├── main.go # 入口:路由注册、静态资源托管、任务轮询启动、访问令牌生成/打印
├── config.yml # 多库配置 + 服务配置
├── go.mod / go.sum
├── Dockerfile # 多阶段构建(ui-builder → builder → runtime
├── docker-compose.yml # 一键部署,挂载数据卷
├── common/ # 公共层(与 video-factory 对齐)
│ ├── http.go # RouteRegister 自动路由 + OpenAPI + 中间件注册
│ ├── auth.go # JWT 签发/解析 + 访问令牌指纹校验
│ ├── auth_middleware.go # 鉴权中间件(白名单 + 静态资源放行)
│ ├── base_dao.go # InsertAndReturnId / GetOneByPk / UpdateByPk / DeleteByPk
│ ├── cache.go # DAO 查询缓存 TTLdatabase.cache.ttl
│ ├── util.go # 通用工具
│ ├── parser.go # 解析器接口 + 注册表(按扩展名分发)
│ ├── pdf_parser.go # pdfcpu 解析 PDF
│ ├── docx_parser.go # zip+xml 解析 word/document.xml
│ ├── html_parser.go # goquery 解析 HTML
│ ├── text_parser.go # txt / md
│ └── tokenizer.go # gse 中文分词(写索引/检索共用)
├── kb/ # 业务模块(knowledge base,对应 video-factory 的 shortdrama
│ ├── consts/
│ │ ├── table_name.go # 表名常量 + 数据库组常量(DbGroupSystem 等)
│ │ ├── status.go # 文档状态 / 任务状态 / 消息角色
│ │ ├── content_type.go # 文件类型 / 模型类型(chat/embedding
│ │ └── consts.go # 通用常量(向量维度默认值、TopK、access_token 配置键)
│ ├── model/
│ │ ├── entity/ # 10 个文件,与 10 张实体表一一对应
│ │ ├── dto/ # 10 个文件,与 entity 对应(含 g.Meta 路由定义)
│ │ └── domain/ # 领域对象(RAG 检索结果、引用来源、流式事件等)
│ ├── dao/ # 10 个文件,与实体表一一对应
│ ├── service/ # 10 个文件,与 dao 对应(文档流水线、RAG 问答编排归入对应 service
│ ├── controller/ # 10 个文件,与 service 对应
├── ui-src/ # Vue 3 前端工程
│ ├── package.json
│ ├── vite.config.js # base:'/'、build.outDir:'dist'
│ └── src/
│ ├── main.js / App.vue
│ ├── router/index.js # hash 路由(登录守卫)
│ ├── api/ # axios 封装 + 各模块 API(与后端 dto 对齐)
│ ├── store/ # Piniaauth / dataset 状态)
│ ├── views/
│ │ ├── Login.vue # 输入访问令牌登录
│ │ ├── Layout.vue
│ │ ├── DatasetList.vue # 数据集列表
│ │ ├── DatasetDetail.vue # 文档管理 + 上传 + 解析状态
│ │ ├── Chat.vue # RAG 问答(SSE 流式 + 引用来源)
│ │ └── Settings.vue # 模型配置 + 访问令牌管理
│ └── components/
│ ├── DocumentUpload.vue
│ ├── ChunkList.vue
│ └── MessageBubble.vue # 流式消息 + 引用折叠面板
├── data/ # 仅数据库文件(gitignore
│ ├── business.db # 数据集领域
│ ├── system.db # 系统领域
│ └── chat.db # 问答领域
├── workspace/ # 上传的文档源文件(gitignore,与数据库分离)
│ └── {datasetId}/{yyyymmdd}/{uuid}.{ext}
└── docs/
└── 实现方案.md
```
> 分层文件与表对齐规则(硬性约定,参照 video-factory):
> - `model/entity/` 每个文件定义一张实体表的结构体,`orm` 标签命名
> - `dao/` 每个文件 = 一张表的单例 DAO(`var Xxx = &xxxDao{}`),`init()` 内建表 + 索引 + 迁移
> - `service/` 每个文件对应一个 DAO,承载业务逻辑(文档流水线、RAG 调用)
> - `controller/` 每个文件对应一个 service,暴露 REST 接口(`g.Meta` 定义 path/method
> - `model/dto/` 每个文件定义一张表的 Req/Res 结构体
> - 虚拟表(vec0 / FTS5)是 chunk 表的附属索引,**不单独建分层文件**,由 `chunk_dao.go` 统一管理
> - **不建 parser/rag 等技术目录**:纯技术能力(文档解析、中文分词)平铺在 `common/`;业务编排(分块、Indexer、Retriever、工作流)归入对应 service 文件
---
## 5. 数据库设计
### 5.1 数据库文件划分(按业务领域)
| 文件 | 配置组名 | 领域 | 说明 |
|---|---|---|---|
| `data/business.db` | `default` | 数据集 | 数据集、文档、分块、向量(vec0)、全文索引(FTS5)、解析任务 |
| `data/system.db` | `system` | 系统 | 访问令牌、模型配置、系统配置 |
| `data/chat.db` | `chat` | 问答 | 会话、消息 |
config.yml
```yaml
database:
default:
name: data/business.db
type: sqlite
debug: true
system:
name: data/system.db
type: sqlite
debug: true
chat:
name: data/chat.db
type: sqlite
debug: true
cache:
ttl: 60
server:
address: :8080
name: rag-local
workerId: 1
clientMaxBodySize: 209715200 # 200MB,支持大文件上传
requestTimeout: 3000 # 秒;支持 AI 长响应
```
### 5.2 表清单(10 张实体表 + 2 张虚拟表)
| # | 表名 | 库 | 类型 | 说明 |
|---|---|---|---|---|
| 1 | `system_config` | system | 实体 | 访问令牌、默认模型等键值配置 |
| 2 | `model_config` | system | 实体 | 模型配置(chat / embedding |
| 3 | `kb_dataset` | business | 实体 | 数据集 |
| 4 | `kb_document` | business | 实体 | 文档 |
| 5 | `kb_chunk` | business | 实体 | 分块 |
| 6 | `kb_chunk_vec` | business | **虚拟表 vec0** | 分块向量(chunk_id 与 kb_chunk 1:1 |
| 7 | `kb_chunk_fts` | business | **虚拟表 FTS5** | 分块全文索引 |
| 8 | `kb_parse_task` | business | 实体 | 文档解析/向量化任务 |
| 9 | `kg_entity` | business | 实体 | 知识图谱实体(LLM 抽取) |
| 10 | `kg_relation` | business | 实体 | 知识图谱关系(头实体-关系-尾实体) |
| 11 | `chat_conversation` | chat | 实体 | 问答会话 |
| 12 | `chat_message` | chat | 实体 | 问答消息 |
> 实体表 10 张 → `entity / dao / service / controller / dto` 各 10 个文件,严格对齐。
> 无 user / login_log / 角色权限:单用户场景,访问令牌存于 system_config。
> 知识图谱(`kg_entity` / `kg_relation`)属数据集领域,见 §7.7。
> 注:用 `sqlite3 .tables` 会看到 `kb_chunk_fts_*`5 张)与 `kb_chunk_vec_*`4 张)等额外表,
> 它们是 FTS5 / vec0 虚拟表自动生成的**内部影子表**(倒排索引、向量分块等存储),由 SQLite 自动维护,
> 不是业务表,不可删除,删除会导致虚拟表损坏。
### 5.3 建表 DDL
表名前缀:`kb_`(数据集领域)、`chat_`(问答领域);`consts.TableNameXxx` 常量集中管理,参照 video-factory。
```sql
-- ==================== system.db ====================
CREATE TABLE IF NOT EXISTS system_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cfg_key TEXT NOT NULL DEFAULT '',
cfg_value TEXT NOT NULL DEFAULT '',
updated_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_system_config_key ON system_config(cfg_key);
-- 预置键:
-- access_token 访问令牌(首次启动生成,明文存储,见 §8)
-- default_chat_model 默认对话模型配置 id
-- default_dataset 默认问答数据集 id
CREATE TABLE IF NOT EXISTS model_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '', -- 配置名称,如 "DeepSeek Chat"
model_type TEXT NOT NULL DEFAULT 'chat', -- chat / embedding
model_name TEXT NOT NULL DEFAULT '', -- 模型名,如 deepseek-chat
endpoint_url TEXT NOT NULL DEFAULT '', -- 如 https://api.deepseek.com
api_key TEXT NOT NULL DEFAULT '',
dimension INTEGER NOT NULL DEFAULT 1024, -- 仅 embedding 有效(向量维度)
extra TEXT NOT NULL DEFAULT '', -- JSON 扩展(temperature 等)
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime'))
);
-- ==================== business.db ====================
CREATE TABLE IF NOT EXISTS kb_dataset (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
embedding_cfg_id INTEGER NOT NULL DEFAULT 0, -- 绑定 embedding 模型配置;切换需重新向量化
status INTEGER NOT NULL DEFAULT 1, -- 1 正常 / 0 禁用
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS kb_document (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL DEFAULT 0,
filename TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL DEFAULT '', -- workspace 下相对路径(workspace/3/20260804/xxx.pdf
file_size INTEGER NOT NULL DEFAULT 0,
file_type TEXT NOT NULL DEFAULT '', -- txt/md/pdf/docx/html
status INTEGER NOT NULL DEFAULT 0, -- 0 待处理 1 处理中 2 完成 3 失败
chunk_count INTEGER NOT NULL DEFAULT 0,
error_msg TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS kb_chunk (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL DEFAULT 0,
document_id INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL DEFAULT 0, -- 分块序号
content TEXT NOT NULL DEFAULT '', -- 分块原文
meta TEXT NOT NULL DEFAULT '', -- JSON:来源页码/标题路径等
created_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_kb_chunk_document ON kb_chunk(document_id);
-- 向量虚拟表(sqlite-vec,维度按 embedding 模型定,默认 1024
CREATE VIRTUAL TABLE IF NOT EXISTS kb_chunk_vec USING vec0(
chunk_id INTEGER PRIMARY KEY,
embedding float[1024]
);
-- 全文索引虚拟表(FTS5,默认 unicode61 tokenizer + 应用层 gse 中文分词;
-- FTS5 不支持 simple tokenizer,中文分词在应用层完成,content_tokens 存分词后空格连接文本)
CREATE VIRTUAL TABLE IF NOT EXISTS kb_chunk_fts USING fts5(
chunk_id UNINDEXED,
dataset_id UNINDEXED,
title,
content_tokens -- gse 分词后空格连接
);
CREATE TABLE IF NOT EXISTS kb_parse_task (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL DEFAULT 0,
dataset_id INTEGER NOT NULL DEFAULT 0,
task_type TEXT NOT NULL DEFAULT 'parse', -- parse / reembed
status INTEGER NOT NULL DEFAULT 0, -- 0 待处理 1 处理中 2 完成 3 失败
error_msg TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_kb_parse_task_status ON kb_parse_task(status);
-- 知识图谱(LLM 抽取,见 §7.7)
CREATE TABLE IF NOT EXISTS kg_entity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '', -- 实体名
entity_type TEXT NOT NULL DEFAULT '', -- 类型(person/org/location/...
meta TEXT NOT NULL DEFAULT '', -- JSON 扩展
created_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_kg_entity_dataset_name ON kg_entity(dataset_id, name);
CREATE TABLE IF NOT EXISTS kg_relation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL DEFAULT 0,
head_id INTEGER NOT NULL DEFAULT 0, -- 头实体 kg_entity.id
relation TEXT NOT NULL DEFAULT '', -- 关系类型
tail_id INTEGER NOT NULL DEFAULT 0, -- 尾实体 kg_entity.id
meta TEXT NOT NULL DEFAULT '', -- JSON:来源 chunk、置信度
created_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_kg_relation_head ON kg_relation(head_id);
CREATE INDEX IF NOT EXISTS idx_kg_relation_tail ON kg_relation(tail_id);
-- ==================== chat.db ====================
CREATE TABLE IF NOT EXISTS chat_conversation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL DEFAULT 0, -- 问答绑定的数据集
title TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS chat_message (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL DEFAULT 0,
role TEXT NOT NULL DEFAULT 'user', -- user / assistant / system
content TEXT NOT NULL DEFAULT '',
citations TEXT NOT NULL DEFAULT '', -- JSON:引用来源(文档/分块/得分)
created_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_chat_message_conversation ON chat_message(conversation_id);
```
### 5.4 关键设计决策
1. **向量与业务同库同事务**`kb_chunk_vec` 与 `kb_chunk` 同在 business.db。删除文档时,`chunk → vec → fts` 在一个事务内删除,无一致性问题。
2. **数据库与源文件分离**`data/` 仅存 3 个 db 文件;上传源文件存 `workspace/`。备份 = 两个目录分别打包(db 小而关键、workspace 大而可重建),清理与迁移互不影响。
3. **向量维度跟随模型**`model_config.dimension` 决定 vec0 建表维度;数据集绑定 embedding 配置(`kb_dataset.embedding_cfg_id`)。切换 embedding 模型时前端提示"重新向量化",触发 `reembed` 任务(重建该库全部向量 + FTS)。
4. **中文分词在应用层**SQLite 内置 tokenizer 无法正确切分中文。写入 FTS5 时用 gse 分词后以空格连接存入 `content_tokens`;查询时对 query 同样分词。备选:trigram tokenizer(召回差但零依赖)。
5. **FTS5 表自包含**:只存 `chunk_id + dataset_id + title + content_tokens`,原文在 `kb_chunk`,命中后回表 join 取原文与元数据,避免 FTS 表膨胀。
6. **解析任务用表驱动**`kb_parse_task` 轮询模式(与 video-factory 的 `StartVideoPoller` 一致),单 goroutine 串行消费,避免 SQLite 并发写冲突。
7. **SQLite 并发**:3 个库文件各自独立连接;写操作集中在任务轮询 goroutine 与用户操作,使用 WAL 模式(`PRAGMA journal_mode=WAL`)提升读写并发。
---
## 6. 分层实现规范(参照 video-factory
### 6.1 consts 层
```go
// kb/consts/table_name.go
const (
TableNameSystemConfig = "system_config"
TableNameModelConfig = "model_config"
TableNameDataset = "kb_dataset"
TableNameDocument = "kb_document"
TableNameChunk = "kb_chunk"
TableNameChunkVec = "kb_chunk_vec"
TableNameChunkFts = "kb_chunk_fts"
TableNameParseTask = "kb_parse_task"
TableNameConversation = "chat_conversation"
TableNameMessage = "chat_message"
)
// 数据库组
const (
DbGroupDefault = "" // business.db
DbGroupSystem = "system" // system.db
DbGroupChat = "chat" // chat.db
)
```
### 6.2 entity 层
每文件一张表,`orm` 标签与列名一致,时间用 `*gtime.Time`(参照 video-factory 的 `user.go`):
```go
type Chunk struct {
Id int64 `orm:"id" json:"id"`
DatasetId int64 `orm:"dataset_id" json:"dataset_id"`
DocumentId int64 `orm:"document_id" json:"document_id"`
Seq int `orm:"seq" json:"seq"`
Content string `orm:"content" json:"content"`
Meta string `orm:"meta" json:"meta"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
}
```
### 6.3 dao 层
- 单例模式:`var Chunk = &chunkDao{}`
- `init()` 内执行 `CREATE TABLE IF NOT EXISTS` + 索引 + 迁移(参照 video-factory `user_dao.go`
- 每表一个文件;`chunk_dao.go` 额外管理 `kb_chunk_vec`(向量 KNN 查询、批量插入)与 `kb_chunk_fts`(BM25 检索、分词写索引),并提供事务内删除(chunk+vec+fts
- 查询缓存:`gdb.CacheOption{Duration: common.CacheTTL(), Name: ...}`,写操作后清理(参照 video-factory `clearUserCache`
- 通用 CRUD 用 `common/base_dao.go`InsertAndReturnId / GetOneByPk / UpdateByPk / DeleteByPk
向量与全文检索 DAO 示例:
```go
// 向量 KNN(余弦)
func (d *chunkDao) VecSearch(ctx context.Context, datasetId int64, vector []float32, topK int) ([]VecHit, error) {
vecStr := vectorToJson(vector) // [0.1,0.2,...]
r, err := g.DB(consts.DbGroupDefault).GetCtx(ctx).Raw(
"SELECT chunk_id, vec_distance_cosine(embedding, vec_f32(?)) AS d "+
"FROM "+consts.TableNameChunkVec+" WHERE embedding MATCH ? ORDER BY d LIMIT ?",
vecStr, vecStr, topK,
)
...
}
// 全文检索(BM25
func (d *chunkDao) FtsSearch(ctx context.Context, datasetId int64, query string, topK int) ([]FtsHit, error) {
tokens := common.TokenizeQuery(query)
r, err := g.DB(consts.DbGroupDefault).GetCtx(ctx).Raw(
"SELECT chunk_id, bm25(kb_chunk_fts) AS score FROM kb_chunk_fts "+
"WHERE kb_chunk_fts MATCH ? AND dataset_id = ? ORDER BY score LIMIT ?",
tokens, datasetId, topK,
)
...
}
```
### 6.4 service 层
每表一个文件 + 业务方法(解析与分词等纯技术能力在 `common/`,业务编排全部归入对应 service):
- `system_config_service.go`:**访问令牌**生成/校验/重新生成、系统设置读写
- `dataset_service.go`:数据集 CRUD
- `document_service.go`:上传落盘、创建文档记录、提交解析任务(解析器调 `common/parser.go`
- `chunk_service.go`:文本分块(splitter,标题感知+800字+100重叠)、`InsertAll`(批量向量化 + 写 chunk+vec0+FTS5,等价 Indexer 职责)、分块列表、编辑(改文本后重分词)
- `parse_task_service.go`:任务队列消费(`StartParsePoller`,与 video-factory `StartVideoPoller` 同模式;解析→分块→索引全流程编排)
- `conversation_service.go` / `message_service.go`:会话 CRUD、历史消息
- `chat_service.go`:**RAG 问答入口**(模型组件工厂、`HybridRetriever`vec0+FTS5+RRF)、问答工作流编排,SSE 流式回传,落库 message;检索调 `common/tokenizer.go` 分词)
- `model_config_service.go`:模型配置 CRUD + 连通性测试
### 6.5 controller 层 + dto 层
- 结构体命名决定路由前缀:`dataset` → `/dataset``system-config` → `/system-config``common.RouteRegister` 自动注册)
- 接口定义在 `model/dto/``g.Meta` 携带 path/method/summaryOpenAPI 文档自动生成(`/api.json`
- 主要接口清单:
| 路由 | 说明 |
|---|---|
| `POST /system-config/login` | **访问令牌登录**body: {token} → 返回 JWT |
| `GET/PUT /system-config` | 系统设置(默认模型、默认数据集) |
| `POST /system-config/regenerate-token` | 重新生成访问令牌(旧会话立即失效) |
| `GET/POST/PUT/DELETE /dataset` | 数据集 CRUD |
| `POST /document/upload`multipart`DELETE /document` `GET /document/list` | 文档管理 |
| `GET /document/{id}/chunks` `PUT /chunk` | 分块查看/编辑 |
| `POST /document/{id}/reembed` | 重新向量化 |
| `GET /parse-task/list` `POST /parse-task/{id}/retry` | 任务管理 |
| `GET/POST/DELETE /conversation` | 会话 CRUD |
| `GET /message/list` | 历史消息 |
| **`POST /message/chat`** | **RAG 问答(SSE 流式)** |
| `GET/POST/PUT/DELETE /model-config` | 模型配置 CRUD |
| `GET /workspace/*` | 源文件访问(main.go BindHandler 静态服务,鉴权放行 + 路径穿越防护) |
| `POST /model-config/{id}/test` | 连通性测试 |
---
## 7. RAG 问答设计
> 本层不再单独建目录:分块/Indexer 归入 `chunk_service.go`Retriever/组件工厂/工作流归入 `chat_service.go`,分词归入 `common/tokenizer.go`。
### 7.1 组件清单
| 组件 | 实现 | 归属 |
|---|---|---|
| ChatModel | 自研 `OpenAIChatModel` | `chat_service.go`OpenAI 兼容 `/chat/completions`Generate/Stream),baseURL 可配,兼容 Ollama/DeepSeek/OpenAI 等 |
| Embedding | 自研 `OpenAIEmbedder` | `chat_service.go`OpenAI 兼容 `/embeddings`;按库绑定的 embedding 配置创建,模型必须与索引端一致 |
| Indexer | `ChunkService.InsertAll` | `chunk_service.go`:批量向量化 + 写入 chunk + vec0 + FTS5(解析流水线非图节点,不单独实现 eino Indexer 接口) |
| Retriever | **自研 `HybridRetriever`** | `chat_service.go`:实现 `eino.Retriever` 接口,vec0 向量检索 + FTS5 关键词检索 + RRF 融合 |
| Splitter | 自研(标题感知分块) | `chunk_service.go`:标题感知 + 800字 + 100重叠 |
| Tokenizer | gse 中文分词 | `common/tokenizer.go`:写索引/检索共用 |
### 7.2 写入路径(`ChunkService.InsertAll`
```go
func (s *chunkService) InsertAll(ctx context.Context, datasetId, documentId int64,
chunks []string, embedder embedding.Embedder) error
```
流程:
1. 数据集绑定 embedding 配置时构建 `OpenAIEmbedder``parse_task_service` 解析流水线中完成,无配置降级仅写 FTS5)
2. 按 `EmbedBatchSize`16)批量调用 `Embedder.EmbedStrings` 生成向量
3. 逐 chunk 同一事务:`kb_chunk` 自增 id → `kb_chunk_vec(chunk_id, vec_f32(?))` → gse 分词后 `kb_chunk_fts(chunk_id, dataset_id, title, content_tokens)`
4. 更新 `kb_document.chunk_count`、`status=2`
> 解析流水线为轮询任务而非图节点,故不单独实现 eino Indexer 接口,`InsertAll` 承担等价职责。
### 7.3 自研 HybridRetriever(读取路径)
```go
type HybridRetriever struct {
datasetId int64
embedder embedding.Embedder
}
// 实现 eino Retriever 接口
func (h *HybridRetriever) Retrieve(ctx context.Context, query string, opts ...retriever.Option) ([]*schema.Document, error)
```
流程:
1. **向量检索**query → Embedding → `vec0` KNN 余弦 TopK(如 20
2. **关键词检索**query → gse 分词 → FTS5 MATCH TopK(如 20
3. **RRF 融合**`score = Σ 1/(60 + rank)`,取 TopK(如 10
4. 回表 `kb_chunk` 取原文与元数据,组装 `schema.Document``doc.WithScore(score)` 记录得分与引用信息
5. 支持 `retriever.WithTopK` / `WithScoreThreshold` 选项
```go
// RRF 融合
type hit struct{ chunkId int64; ranks []int; score float64 }
score = Σ(1 / (60 + rank))
```
### 7.4 问答工作流(chat_service.go 编排)
```
用户问题
┌─────────┐ ┌──────────────┐ ┌────────────┐ ┌───────────┐
│ Hybrid │──▶│ Prompt │──▶│ ChatModel │──▶│ 流式输出 │
│ Retriever│ │ (上下文+问题) │ │ (OpenAI兼容)│ │ (SSE) │
└─────────┘ └──────────────┘ └────────────┘ └───────────┘
```
- `chat_service.go` 中直接编排 eino 组件:`ChatService.Ask` = HybridRetriever.Retrieve → 组装引用列表 + 系统提示词 → OpenAIChatModel.Stream 流式生成(组件已实现 eino 接口,可随时迁入 graph/chain 拓扑;eino v0.9.13 的 graph 节点类型约束与"中间取引用"需求不匹配,故直接编排)
- `message_service.go`:会话解析/创建、用户消息落库、历史消息组装(最近 10 轮)、助手消息 + citations JSON 落库
- Prompt 模板:
```
你是一个本地知识库助手。请仅根据以下资料回答用户问题;若资料不足以回答,请明确说明。
回答引用资料时,在对应位置标注 [编号]。
【资料】
[1] 内容...
[2] 内容...
```
- SSE 事件顺序:`citations`(先推,含引用列表与 conversation_id)→ `delta`(增量文本)→ `done`;错误推 `error` 事件
- 引用列表编号 [1][2] 与提示词中资料编号一一对应,随助手消息以 JSON 落库(chat_message.citations
### 7.5 中文分词
- `github.com/go-ego/gse`(纯 Go,无 CGO),初始化标准中文词典(约 35 万词)
- 写索引:`gse.Cut(text)` → 过滤停用词/单字 → 空格 join → `content_tokens`
- 查询:同样流程;额外保留原 query 子串供 trigram 兜底(可选)
- 词典可随镜像内置 `dict/zh/dict.txt`
### 7.6 文档解析流水线
```
POST /document/upload
│ 保存文件到 workspace/{datasetId}/{yyyymmdd}/{uuid}.ext
│ 插入 kb_document(status=0) + kb_parse_task(status=0)
StartParsePollermain.go 启动,3 秒轮询)
│ 取 status=0 任务 → 置 status=1
│ 1. 按扩展名分发解析器(txt/md 直接读;pdf 用 pdfcpu 抽取文本;
│ docx 解 zip 读 word/document.xml 提取段落;html 用 goquery 取 body 文本)
│ 2. 分块:优先按标题/段落切(# 标题、空行、PDF 分页),
│ 超过 max_chunk_size(800字) 时按句号/换行回退切分,重叠 100 字
│ 3. 批量 Embedding → SqliteIndexer.Store
│ 4. 更新 document.status=2、chunk_count;任务 status=2
│ 失败 → status=3 + error_msg,前端可重试
```
### 7.7 知识图谱(LLM 抽取 + 图增强检索)
面向"数据集整体关系"类问题(如"谁与谁合作过""公司有哪些产品线"),在向量/关键词检索之外补充图谱能力。图谱**不是替代 RAG**,而是为问答注入结构化关系上下文。
**构建(LLM 抽取,挂在解析流水线第 3.5 步)**:
```
解析 → 分块 → 向量化 ──▶ 批量抽取(每 chunk 一次 LLM 调用,JSON 输出)
{entities:[{name, type}], relations:[{head, relation, tail}]}
upsert kg_entity(按 dataset_id+name 去重)→ 写 kg_relation(带来源 chunk_id
```
- 复用 M4 的 chat 组件工厂,抽取 Prompt 要求模型只输出 JSON
```json
{"entities": [{"name": "张明", "type": "person"}], "relations": [{"head": "张明", "relation": "任职于", "tail": "XX科技"}]}
```
- 实体按 `(dataset_id, name)` 去重(同一实体多 chunk 出现只建一次,UNIQUE 约束 + ON CONFLICT upsert),关系带来源 chunk_id;删除文档时按分块 id 级联清理
- 抽取失败不阻断流水线(记录错误,文档状态仍为完成);未配置默认对话模型时整体跳过
**使用(图增强检索,挂在 HybridRetriever 之后)**
```
用户问题
├─▶ 混合检索(向量+FTS5)──┐
├─▶ 实体链接:问题文本分词后与 kg_entity.name 精确/子串匹配,取 Top 3 命中实体
│ └─▶ 一跳邻居:取这些实体的关系三元组(头/尾任意一端命中即取,上限 20 条)
组装上下文:检索片段 + 三元组列表("【知识图谱】张明 -任职于-> XX科技")→ ChatModel
```
- 实体链接用 `common/tokenizer.go` 分词 + 名称匹配,零模型调用;打分规则:问题含完整实体名 +5,命中 token 按长度加权,Top3 后名称长者优先
- 三元组作为辅助上下文注入 prompt(排序在检索片段之后),增强模型对关系类问题的回答
- `kg_entity` / `kg_relation` 两表归属 business.db,各配独立 entity/dao/service/controller 文件(分层 10 文件对齐)
---
## 8. 鉴权设计(单用户 + 启动令牌)
面向边缘设备部署,不做用户体系,只有一个"门禁"级别的访问令牌:
### 8.1 令牌生命周期
```
首次启动
│ 读 system_config(access_token)
│ ┌─ 不存在 → 随机生成 32 位十六进制 token,写入 system_config,日志打印:
│ │ ============================================
│ │ 访问令牌(登录用): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4
│ │ 请在登录页输入上述令牌
│ │ ============================================
│ └─ 已存在 → 直接打印当前 token(便于找回,重启不重新生成)
登录页:输入 token → POST /system-config/login → 校验通过 → 签发 JWT
│ JWT claims: { role: "owner", token_fp: 指纹 },有效期 24h
后续请求:Authorization: Bearer <JWT>,中间件校验 token_fp 与当前令牌指纹一致
```
要点:
- **指纹校验**JWT 中携带 access_token 的 SHA-256 指纹(前 16 位 hex);鉴权中间件将 JWT 指纹与 `system_config` 中当前令牌指纹比对(带缓存),不一致即 401。因此**重新生成令牌后所有旧会话立即失效**
- **重新生成**:设置页 `POST /system-config/regenerate-token` → 生成新令牌覆盖并打印日志,前端强制跳转登录页
- **令牌存储**system_config 明文存储(本地单机、日志与设置页本就可见明文,且设置页需展示)
- **公开路径白名单**`/system-config/login`、`GET /`、`GET /assets/*`(hash 路由下 SPA 只请求这两类静态路径,无鉴权绕过);`GET /workspace/*` 前缀放行(同 video-factory `/workspace/*`:浏览器下载/预览请求不带 Authorization),仅做路径穿越防护;其余路径一律校验
- 无注册、无角色、无用户表;登录日志不落库(如需审计可在日志文件输出)
```go
// common/auth_middleware.go(伪码)
func Auth(r *ghttp.Request) {
if isPublicPath(r.URL.Path) { r.Middleware.Next(); return }
claims, err := ParseToken(bearer(r))
if err != nil || claims.TokenFp != currentTokenFingerprint() {
r.Response.WriteJson(401); r.Exit(); return
}
r.Middleware.Next()
}
```
---
## 9. 前端设计(Vue 3 + Vite + Element Plus
### 9.1 工程
- `ui-src/` 独立 Vite 工程:`vite.config.js` 设 `base: '/'`、`build.outDir: 'dist'`
- hash 路由(无需服务端 SPA fallback,与 video-factory 一致),路由守卫:无 JWT → 跳登录页
- 依赖:`vue@3`、`vue-router@4`、`pinia`、`element-plus`、`axios`
- 构建产物 `ui-src/dist`,本地开发 `npm run build` 后由 Go 统一端口托管;开发期可用 Vite dev server + proxy
### 9.2 页面
| 页面 | 功能 |
|---|---|
| 登录页 | 输入访问令牌登录(提示语:令牌见服务启动日志 / `docker compose logs`);无注册入口 |
| 数据集列表 | 卡片式列表、新建/删除/设置(绑定 embedding 模型) |
| 数据集详情 | 文档上传(拖拽 + 进度)、文档列表(解析状态/分块数/失败重试)、分块预览与编辑 |
| 问答页 | 左侧会话列表,右侧消息流;SSE 流式渲染,答案带引用折叠面板(点击定位到分块原文) |
| 设置页 | 模型配置 CRUDchat/embedding)、连通性测试;**访问令牌管理**(查看/复制/重新生成,重新生成后强制重新登录) |
### 9.3 API 封装
- `api/http.js`axios 实例(baseURL `/`、token 注入、401 跳转、错误 toast
- `api/xxx.js`:按后端模块组织,与 dto 对齐
- 流式:`fetch` + `ReadableStream` 解析 SSE`data: {"content":"..."}` 增量 / `data: {"citations":[...]}` 引用 / `data: [DONE]`
---
## 10. Docker 部署
### 10.1 Dockerfile(多阶段,参照 video-factory
```dockerfile
# ==================== 前端构建 ====================
FROM node:20-alpine AS ui-builder
RUN apk add --no-cache git
WORKDIR /build-ui
COPY rag-local/ui-src/package.json rag-local/ui-src/package-lock.json ./
RUN npm ci --registry=https://registry.npmmirror.com
COPY rag-local/ui-src/ ./
RUN npm run build
# ==================== 后端构建 ====================
FROM golang:alpine AS builder
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache git ca-certificates tzdata
ENV TZ=Asia/Shanghai GO111MODULE=on \
GOPROXY=https://goproxy.cn,direct \
CGO_ENABLED=0 GOTOOLCHAIN=auto
WORKDIR /build
COPY rag-local/ .
RUN go mod download && go build -ldflags="-s -w" -o main ./main.go
# ==================== 运行镜像 ====================
FROM alpine:3.19
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache ca-certificates tzdata
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
COPY --from=builder /build/main .
COPY --from=builder /build/config.yml .
COPY --from=ui-builder /build-ui/dist ./ui-src/dist
RUN mkdir -p /app/data /app/workspace \
&& printf '#!/bin/sh\nfor db in business.db system.db chat.db; do\n if [ -d /app/data/$db ]; then rm -rf /app/data/$db; fi\n touch /app/data/$db 2>/dev/null || true\ndone\nexec ./main\n' > /app/entrypoint.sh \
&& chmod +x /app/entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["/app/entrypoint.sh"]
```
### 10.2 docker-compose.yml
```yaml
services:
rag-local:
build:
context: ..
dockerfile: rag-local/Dockerfile
container_name: rag-local
ports:
- "8080:8080" # 统一端口:前后端一体
volumes:
- /data/rag-local/data:/app/data # 数据库(3 个 db 文件)
- /data/rag-local/workspace:/app/workspace # 上传的文档源文件
restart: unless-stopped
networks:
default:
name: rag-local-network
```
> 数据即文件:备份 = 打包 `data`3 个 db+ `workspace`(源文件)两个目录;迁移 = 拷贝到新机器即可。
> 首次启动后通过 `docker compose logs rag-local` 查看访问令牌。
---
## 11. 开发与构建命令
```bash
# 后端
cd rag-local
go mod tidy
go get modernc.org/sqlite@v1.47.0 # 关键:向量扩展依赖
go build ./... # 验证编译(见 §2.1 风险提示)
go run main.go # 启动日志中查看访问令牌
# 前端
cd rag-local/ui-src
npm install
npm run build # 产物 ui-src/distGo 统一端口托管
npm run dev # 开发期(Vite + proxy
# 部署
docker compose up -d --build
docker compose logs rag-local # 查看访问令牌
```
---
## 12. 实施步骤(里程碑)
| 阶段 | 内容 | 验收标准 |
|---|---|---|
| **M1 骨架** | go mod、config.yml 三库、common 层(http/auth/base_dao/cache)、consts、system_config(令牌生成/登录)与 model_config 全链路(entity/dao/service/controller/dto)、ui-src 空壳 SPA 由 Go 托管 | 8080 端口可打开页面,启动日志打印令牌,登录页输入令牌可进入,3 个 db 文件生成 |
| **M2 文档流水线** | dataset/document/parse_task 全链路、解析器(txt/md/pdf/docx/html)、分块、任务轮询 | 上传文档 → 解析 → 分块 → 状态流转,前端可看 chunk 列表 |
| **M3 向量 + 全文** | 升级 modernc v1.47、验证 vec0、chunk_vec/chunk_fts 建表、gse 分词、SqliteIndexer/向量检索/FTS5 检索、RRF 融合 | 库内文档可被语义检索与关键词检索命中 |
| **M4 RAG 问答** ✅ | model_config 全链路、chat/embedding 组件工厂、Eino Graph 工作流、SSE 流式、conversation/message 落库、引用展示 | 问答页流式对话,回答有引用来源,历史消息可回溯 |
| **M5 知识图谱** ✅ | kg_entity/kg_relation 全链路、LLM 抽取(挂在解析流水线)、实体链接 + 一跳邻居注入 prompt | 图谱页可看实体/关系,问答能回答关系类问题 |
| **M6 打磨与部署** ✅ | 分块编辑(改后自动重向量化)、文档重新向量化、令牌查看/重新生成、模型连通性测试、设置/数据集/详情三页填充、Dockerfile + docker-compose + README | docker compose up 一键启动,全功能可用 |
---
## 13. 依赖清单(go.mod
```
github.com/gogf/gf/v2 v2.10.x
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.x
modernc.org/sqlite v1.47.0+ # 显式升级,内置 sqlite-vec
github.com/cloudwego/eino # RAG 编排
github.com/cloudwego/eino-ext # chat/openai、embedding/openai
github.com/go-ego/gse # 中文分词(纯 Go)
github.com/pdfcpu/pdfcpu # PDF 文本抽取
github.com/PuerkitoBio/goquery # HTML 解析
github.com/golang-jwt/jwt/v5 # JWT
```
---
## 14. 风险与备选方案
### 14.1 向量扩展兼容性(主要风险)
**风险**glebarez/go-sqliteGoFrame 驱动链)锁定 modernc v1.23.1MVS 升级到 v1.47.0 后可能存在 API 编译不兼容。
**应对**:M3 阶段第一步即验证 `go build`。若失败,按序切换:
- **备选 A(推荐)**:自研平面扫描向量检索 —— `kb_chunk_vec` 改用普通表存向量 JSON,检索时过滤 + 内存余弦相似度排序(批量预取 + 并发分片)。10 万 chunk × 1024 维单次查询约 20–50ms,本地知识库规模完全够用。Indexer/Retriever 接口不变,仅换后端实现。
- **备选 B**`gosqlite.org` 模块(CGo-free,类型化 vec + FTS5 API),业务 SQL 走 GoFrame、向量/FTS 走独立连接,应用层 RRF 融合。
- **备选 C**LanceDB(文件型向量库,纯 Go SDK),向量独立目录存储。
### 14.2 中文检索质量
FTS5 召回依赖 gse 分词质量;专有名词(人名/产品名)可能切碎。缓解:检索时同时保留 trigram 兜底,或对命中率低的 query 降级为向量检索为主。
### 14.3 SQLite 写并发
解析任务轮询与用户操作可能并发写 business.db。缓解:WAL 模式 + 任务串行消费 + 写操作集中到 service 层(参照 video-factory 单机场景)。
### 14.4 切换 embedding 模型
不同模型的向量空间不可混用。方案:数据集绑定 embedding 配置,切换时强制"重新向量化"(`reembed` 任务重建全部向量与 FTS 索引),并清空缓存。
---
## 15. 与 video-factory 的差异点说明
| 项 | video-factory | rag-local |
|---|---|---|
| 数据库 | 3 个 SQLite(业务/系统/财务) | 3 个 SQLite(业务/系统/问答) |
| 向量/全文 | 无 | **SQLite 同库内嵌**vec0 + FTS5),无额外服务 |
| AI 层 | 自研 ReAct agentopenai 直连) | **Eino** 组件化编排(Indexer/Retriever/Graph |
| 鉴权 | 多用户 + 角色 + JWTuser/login_log 表) | **单用户访问令牌**:启动生成打印,登录换取 JWT,无用户表 |
| 前端 | HTML + 少量 Vue | 纯 Vue 3 SPA(构建产物 Go 托管) |
| 异步任务 | 视频生成轮询 | 文档解析/向量化轮询 |
| 其余 | 分层/路由/鉴权/缓存/部署模式 | 完全对齐 |