修复 3 CRITICAL + 4 HIGH 问题: - C1: 明确禁止 format! 拼接 SQL,所有 pgvector 查询参数化 - C2: 迁移 SQL 改用临时映射表精确关联,防数据重复/丢失 - C3: SSRF 防护细化(DNS rebinding + 超时 + 重定向校验) - H1: document_count/chunk_count 改为原子 SQL 增量 - H2: embedding 部分失败:NULL embedding 写入 + embedded_count 统计 - H3: chunks 表补充 updated_at/updated_by 审计字段 - H4: 新增 validator crate 依赖说明 - M1: 空知识库边界返回空 context 不报错 - M2: 向量索引改用 HNSW(无需预热)
53 KiB
AI 知识库 V2 — 统一知识管理平台设计规格
日期: 2026-05-26 | 状态: 草稿 | 作者: brainstorming session 替代: 旧三层模型(ai_knowledge_rules / references / guides)
目录
- 背景与目标 — 为什么要重做、解决什么问题
- 架构概览 — 整体架构、核心模块、数据流
- 数据模型 — 3 张新表、字段定义、索引策略
- 文档处理管线 — 文件解析、智能切片、embedding、异步处理
- 混合意图路由 — 三层漏斗:关键词 → 向量 → LLM 兜底
- 管理后台 UI — Dify 风格三页面:知识库列表 / 文档管理 / 命中测试
- AI 客服集成 — RAG 注入、System Prompt、引用溯源
- 旧数据迁移 — 旧 3 表 → 新模型迁移策略
- API 端点清单 — 所有新增/变更的 API
- 依赖与风险 — 新增 Rust 依赖、技术风险、缓解措施
设计决策记录
| # | 决策 | 选项 | 选定 | 理由 |
|---|---|---|---|---|
| 1 | 服务对象 | A:AI引擎 B:医护查询 C:两者兼顾 | C(当前优先 AI 客服) | 渐进交付 |
| 2 | 知识范围 | 机构信息/就医流程/健康科普 | 三类全覆盖 | 用户确认 |
| 3 | 录入方式 | 单一/混合 | 混合(上传+表单+URL) | 灵活性 |
| 4 | 架构方案 | A:MVP B:完整平台 C:渐进 | 方案 B | 一次做好更省时 |
| 5 | 意图路由 | A:LLM B:向量 C:混合 | 混合(关键词+向量+LLM兜底) | 平衡准确性和速度 |
| 6 | UI 风格 | A:Dify B:FastGPT C:简洁 | Dify 风格 | 用户确认 |
| 7 | 数据模型 | 保留旧模型/新建/替换 | 替换旧模型 | 统一管理 |
1. 背景与目标
1.1 现状问题
当前 AI 知识库基于三层模型(ai_knowledge_rules / ai_knowledge_references / ai_knowledge_guides),存在以下核心问题:
| 优先级 | 问题 | 影响 |
|---|---|---|
| P0 | 录入方式原始 — 仅支持手动文本输入到 textarea,无文件上传、无 PDF 解析、无 URL 导入 | 医护人员无法高效录入,200 页临床指南无法导入 |
| P1 | 知识无分类体系 — 仅靠 analysis_type 一个维度(6 个选项)过滤 |
检索效率低,不同类型知识混杂 |
| P1 | 无质量审核 — 任何人可直接发布,无版本控制 | 医疗知识错误影响患者安全 |
| P2 | 无使用效果追踪 — 不知道哪些知识被 AI 引用、embedding 质量如何 | 投入产出不可衡量 |
| P2 | 规则层无 UI — ai_knowledge_rules 无 CRUD API 和管理界面 |
规则只能通过数据库操作 |
| P2 | 引用不透明 — AI 分析结果不显示知识来源和置信度 | 用户无法验证 AI 推理依据 |
1.2 目标
核心目标: 构建 Dify 级的统一知识管理平台,作为 AI 客服的知识底座,让 AI 能准确回答机构相关问题。
阶段性交付:
- Phase 1(当前): AI 客服知识底座 — 机构信息、就医流程、健康科普
- Phase 2(未来): 医护直接查询 — 医学文献检索、临床决策支持
- Phase 3(未来): AI 分析引擎增强 — 化验解读、趋势分析的知识增强
1.3 成功标准
| 指标 | 目标值 |
|---|---|
| 知识录入效率 | 从手动粘贴 200 页文档 → 上传 1 个 PDF,5 分钟内自动处理完成 |
| 检索准确率 | 意图路由命中率 ≥ 90%(命中测试验证) |
| AI 回答质量 | 知识库覆盖范围内的问题,85%+ 能基于知识库准确回答 |
| 管理效率 | 非技术人员可在 10 分钟内完成一个知识库的创建和文档导入 |
2. 架构概览
2.1 核心架构图
┌─────────────────────────────────────────────────────────────────┐
│ 管理后台 (Web) │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
│ │ 知识库列表 │ │ 文档管理 │ │ 命中测试 + 切片预览 │ │
│ │ (卡片视图) │ │ (表格 + 上传) │ │ (检索验证) │ │
│ └──────┬───────┘ └──────┬───────┘ └───────────┬───────────┘ │
│ │ │ │ │
└─────────┼─────────────────┼───────────────────────┼──────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ API Layer (Axum) │
│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────┐ │
│ │ KB CRUD Handler │ │ Document Handler │ │ Search Handler │ │
│ │ (知识库管理) │ │ (上传/状态/重处理) │ │ (命中测试) │ │
│ └────────┬────────┘ └────────┬─────────┘ └───────┬────────┘ │
└───────────┼─────────────────────┼────────────────────┼───────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Service Layer (Rust) │
│ │
│ ┌──────────────────┐ ┌──────────────────────────────────────┐ │
│ │ KnowledgeService │ │ DocumentPipeline │ │
│ │ - CRUD 知识库 │ │ - 文件解析 (PDF/Word/Excel/URL/TXT) │ │
│ │ - CRUD 文档 │ │ - 智能切片 (按 KB 策略) │ │
│ │ - 切片管理 │ │ - Embedding (复用 EmbeddingService) │ │
│ └────────┬─────────┘ │ - 异步处理 (tokio::spawn) │ │
│ │ └──────────────┬───────────────────────┘ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐│
│ │ HybridKnowledgeRouter (实现 KnowledgeSource trait) ││
│ │ Layer 1: 关键词粗筛 → Layer 2: 向量检索 → Layer 3: LLM 兜底 ││
│ └──────────────────────────┬───────────────────────────────────┘│
│ │ │
└──────────────────────────────┼───────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ AI 客服集成 │
│ ┌──────────────┐ ┌─────────────────┐ ┌────────────────┐ │
│ │ chat_handler │───▶│ RAG Context 注入 │───▶│ LLM Provider │ │
│ │ (现有) │ │ (System Prompt) │ │ (Claude/Ollama)│ │
│ └──────────────┘ └─────────────────┘ └────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌────────────────┐ │
│ │ 前端 ChatPage │◀─── [ref:xxx] 引用 ◀────│ AI 回答 + 溯源 │ │
│ └──────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ PostgreSQL (pgvector) │
│ ┌──────────────────┐ ┌───────────────────┐ ┌──────────────┐ │
│ │ ai_knowledge_bases│ │ ai_knowledge_docs │ │ ai_chunks │ │
│ │ (知识库目录) │ │ (文档元数据) │ │ (切片+向量) │ │
│ └──────────────────┘ └───────────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
2.2 核心模块职责
| 模块 | 位置 | 职责 |
|---|---|---|
KnowledgeService |
erp-ai/src/service/knowledge.rs |
知识库/文档/切片 CRUD、统计更新 |
DocumentPipeline |
erp-ai/src/knowledge/pipeline.rs(新增) |
文件解析 → 切片 → embedding → 存储 |
DocumentParser |
erp-ai/src/knowledge/parser.rs(新增) |
PDF/Word/Excel/URL/Markdown/TXT 格式解析 |
ChunkStrategy |
erp-ai/src/knowledge/chunk.rs(新增) |
按 KB 配置策略执行切片 |
HybridKnowledgeRouter |
erp-ai/src/knowledge/hybrid_router.rs(新增) |
三层意图路由,实现 KnowledgeSource trait |
EmbeddingService |
erp-ai/src/service/embedding.rs(现有) |
复用,无需修改 |
KnowledgeSearchRepo |
erp-ai/src/knowledge/vector_search.rs(改造) |
pgvector 查询,适配新表结构 |
2.3 关键数据流
知识录入流:
用户上传文件 → POST /ai/knowledge/bases/{id}/documents
→ 创建 document 记录 (status=pending)
→ tokio::spawn 异步处理
→ DocumentPipeline::process(document_id)
→ DocumentParser::parse(file) → 提取纯文本
→ ChunkStrategy::chunk(text, strategy) → 生成切片
→ EmbeddingService::embed_batch(chunks) → 生成向量
→ 批量写入 ai_knowledge_chunks
→ 更新 document.status = completed
知识检索流(AI 客服):
用户提问 → chat_handler
→ HybridKnowledgeRouter::get_context(query)
→ Layer 1: keyword_match(query, kb.intent_keywords) → 匹配知识库列表
→ Layer 2: vector_search(query, kb_ids) → top 5, threshold 0.65
→ Layer 3: (仅 top < 0.75) llm_classify(query) → 重定向知识库 → 重新 vector_search
→ 组装 RAG context (≤4000 tokens)
→ 注入 System Prompt
→ LLM 生成回答 + [ref:xxx] 引用
→ 异步更新 chunk.hit_count
3. 数据模型
3.1 表设计概览
3 张新表替换旧 3 表(ai_knowledge_rules / ai_knowledge_references / ai_knowledge_guides):
ai_knowledge_bases (1) ──< ai_knowledge_documents (N) ──< ai_knowledge_chunks (N)
知识库目录 文档元数据 切片 + 向量
所有表包含标准审计字段:id(UUID v7) / tenant_id / created_at / updated_at / created_by / updated_by / deleted_at。version_lock 仅 bases 和 documents 表使用。
3.2 ai_knowledge_bases(知识库目录)
CREATE TABLE ai_knowledge_bases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(200) NOT NULL, -- 知识库名称
kb_type VARCHAR(50) NOT NULL, -- 类型枚举
description TEXT, -- 描述(帮助 AI 理解用途)
icon VARCHAR(50), -- 图标标识
chunk_strategy JSONB NOT NULL DEFAULT '{}', -- 切片策略
intent_keywords JSONB NOT NULL DEFAULT '[]', -- 意图关键词
embedding_model VARCHAR(100), -- embedding 模型(默认全局配置)
is_enabled BOOLEAN NOT NULL DEFAULT true,
document_count INT NOT NULL DEFAULT 0, -- 缓存:文档数
chunk_count INT NOT NULL DEFAULT 0, -- 缓存:切片总数
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID,
updated_by UUID,
deleted_at TIMESTAMPTZ,
version_lock INT NOT NULL DEFAULT 1
);
-- 索引
CREATE INDEX idx_kb_tenant ON ai_knowledge_bases(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_kb_type ON ai_knowledge_bases(tenant_id, kb_type) WHERE deleted_at IS NULL;
CREATE INDEX idx_kb_enabled ON ai_knowledge_bases(tenant_id, is_enabled) WHERE deleted_at IS NULL;
kb_type 枚举值:
| 值 | 中文名 | 用途 |
|---|---|---|
rule |
规则 | KDIGO 分期、危急值阈值、药物相互作用 |
reference |
参考资料 | ICD-10 映射、药品数据库、检验参考范围 |
clinical_guide |
临床指南 | 诊疗规范、科室专科方案 |
institution_info |
机构信息 | 科室介绍、医生排班、服务项目、价格套餐 |
medical_process |
就医流程 | 预约挂号流程、体检须知、报告领取方式 |
health_education |
健康科普 | 疾病知识、检查项目解读、生活方式建议 |
faq |
常见问答 | 用户高频问答对 |
chunk_strategy 默认值(按 kb_type):
// rule / reference
{ "max_tokens": 300, "overlap": 0, "separator": "\n\n", "strategy": "per_item" }
// faq
{ "max_tokens": 400, "overlap": 0, "separator": "\n\n", "strategy": "qa_pair" }
// institution_info / medical_process
{ "max_tokens": 500, "overlap": 50, "separator": "\n\n", "strategy": "paragraph" }
// health_education
{ "max_tokens": 600, "overlap": 80, "separator": "\n\n", "strategy": "paragraph" }
// clinical_guide
{ "max_tokens": 800, "overlap": 100, "separator": "\n##", "strategy": "section" }
3.3 ai_knowledge_documents(文档元数据)
CREATE TABLE ai_knowledge_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
knowledge_base_id UUID NOT NULL REFERENCES ai_knowledge_bases(id),
title VARCHAR(500) NOT NULL,
source_type VARCHAR(20) NOT NULL, -- upload / url / manual
file_path VARCHAR(500), -- 文件存储路径(upload)
file_type VARCHAR(20), -- pdf/docx/txt/md/xlsx/csv/html
file_size BIGINT, -- 字节
source_url TEXT, -- 原始 URL(url 类型)
raw_content TEXT, -- 解析后的完整纯文本(冗余存储)
status VARCHAR(20) NOT NULL DEFAULT 'pending',
chunk_count INT NOT NULL DEFAULT 0,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID,
updated_by UUID,
deleted_at TIMESTAMPTZ,
version_lock INT NOT NULL DEFAULT 1
);
CREATE INDEX idx_doc_kb ON ai_knowledge_documents(knowledge_base_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_doc_status ON ai_knowledge_documents(knowledge_base_id, status) WHERE deleted_at IS NULL;
CREATE INDEX idx_doc_tenant ON ai_knowledge_documents(tenant_id) WHERE deleted_at IS NULL;
status 状态机:
pending → processing → completed
↘ failed
pending: 刚创建,等待处理processing: 正在解析/切片/embeddingcompleted: 处理完成,切片可用failed: 处理失败,error_message记录原因
3.4 ai_knowledge_chunks(切片 + 向量)
CREATE TABLE ai_knowledge_chunks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
document_id UUID NOT NULL REFERENCES ai_knowledge_documents(id),
knowledge_base_id UUID NOT NULL REFERENCES ai_knowledge_bases(id), -- 冗余加速查询
chunk_index INT NOT NULL, -- 文档内序号 (0-based)
content TEXT NOT NULL, -- 切片原文
embedding vector(1536), -- pgvector 向量
token_count INT, -- token 数量
metadata JSONB DEFAULT '{}', -- 元数据: page, section, source_line
hit_count INT NOT NULL DEFAULT 0, -- 命中次数
last_hit_at TIMESTAMPTZ, -- 最近命中时间
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by UUID
);
-- 向量检索核心索引(HNSW,无需预热数据,pgvector >= 0.5.0)
CREATE INDEX idx_chunk_embedding ON ai_knowledge_chunks
USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
-- 按知识库查询
CREATE INDEX idx_chunk_kb ON ai_knowledge_chunks(knowledge_base_id);
-- 按文档查询
CREATE INDEX idx_chunk_doc ON ai_knowledge_chunks(document_id);
-- 按租户查询
CREATE INDEX idx_chunk_tenant ON ai_knowledge_chunks(tenant_id);
-- 命中统计排序
CREATE INDEX idx_chunk_hits ON ai_knowledge_chunks(knowledge_base_id, hit_count DESC);
metadata 字段示例:
// PDF 文档切片
{ "page": 3, "section": "第三章 血透适应症", "source_line": 45 }
// Excel 行切片
{ "row": 12, "columns": ["套餐名", "价格", "包含项目"] }
// URL 导入切片
{ "url": "https://xxx.com/about", "selector": ".main-content" }
// Markdown 切片
{ "heading": "## 体检前注意事项", "level": 2 }
4. 文档处理管线
4.1 管线架构
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────┐
│ 接收文件 │───▶│ 格式解析 │───▶│ 智能切片 │───▶│ Embedding │───▶│ 存储 │
│ upload/ │ │ Parser │ │ Chunker │ │ Service │ │ chunks │
│ url/manual│ │ │ │ │ │ (复用) │ │ table │
└──────────┘ └──────────┘ └──────────┘ └───────────┘ └──────────┘
│
▼
┌──────────────┐
│ 错误处理 │
│ failed + msg │
└──────────────┘
整个管线在 tokio::spawn 中异步执行,前端通过轮询 GET /documents/{id} 获取状态。
4.2 格式解析器(DocumentParser)
每种格式对应一个解析函数,统一输出 ParsedDocument { title, text, metadata }。
| 格式 | Rust 依赖 | 解析策略 | 特殊处理 |
|---|---|---|---|
pdf-extract 或 lopdf |
逐页提取文本 | 检测表格结构 → Markdown 表格;纯图片页标记跳过 | |
| Word (.docx) | docx-rs |
按段落提取,保留标题层级 | 标题作为 metadata.section |
| TXT | 标准库 | 直接读取,按空行分段 | 无特殊处理 |
| Markdown (.md) | 标准库 + 自定义 | 按标题层级分割 | #/## 作为 metadata.heading |
| Excel (.xlsx) | calamine |
逐行拼接为自然语言描述 | 表头 + 行数据 → "套餐A,价格599元,包含..." |
| CSV | 标准库 | 同 Excel 策略 | 自动检测分隔符 |
| URL (HTML) | reqwest + scraper |
HTTP GET → 提取 body 正文 | SSRF 防护(禁止内网地址);去广告/导航/页脚 |
URL 导入的正文提取逻辑:
fn extract_main_content(html: &str) -> String {
// 1. 用 scraper 解析 DOM
// 2. 移除 <nav>, <footer>, <header>, <aside>, <script>, <style>
// 3. 优先提取 <article> 或 <main> 标签内容
// 4. fallback 到 <body> 全文
// 5. 清理多余空白
}
Excel 行转自然语言:
fn row_to_text(headers: &[String], row: &[String]) -> String {
// 输入: headers=["套餐名","价格","包含项目"], row=["基础体检A","599","血常规,尿常规"]
// 输出: "基础体检A,价格599元,包含血常规、尿常规"
}
4.3 智能切片(ChunkStrategy)
切片策略由知识库的 chunk_strategy JSONB 字段配置,5 种内置策略:
| 策略 | 标识 | 适用类型 | 逻辑 |
|---|---|---|---|
| 逐条切分 | per_item |
rule, reference | 按分隔符切,每条独立 |
| QA 对 | qa_pair |
faq | 识别 Q: / A: 标记,一对一切片 |
| 按段落 | paragraph |
institution_info, health_education | 按空行分段,保留 overlap |
| 按章节 | section |
clinical_guide | 按标题层级切,大 overlap |
| 按行 | per_row |
Excel/CSV 自动选择 | 表头 + 每行一个切片 |
切片伪代码:
fn chunk_text(text: &str, strategy: &ChunkStrategy) -> Vec<Chunk> {
match strategy.strategy.as_str() {
"per_item" => split_by_separator(text, &strategy.separator),
"qa_pair" => split_by_qa_pattern(text),
"paragraph" => split_with_overlap(text, &strategy.separator, strategy.max_tokens, strategy.overlap),
"section" => split_by_headings(text, strategy.max_tokens, strategy.overlap),
"per_row" => split_by_rows(text), // 仅用于表格数据
_ => split_with_overlap(text, "\n\n", strategy.max_tokens, strategy.overlap),
}
// 每个切片附加上下文元数据:document_title + 前后文摘要
}
上下文增强: 每个切片在存储时追加文档标题作为前缀,提升向量检索准确度:
[文档: 体检套餐价目表.xlsx] 基础体检A,价格599元,包含血常规、尿常规、肝功能、肾功能检查...
4.4 Embedding 与存储
复用现有 EmbeddingService,批量调用:
async fn process_chunks(chunks: &[NewChunk]) -> Result<()> {
// 分批 embedding,单批失败不阻塞其他批次
let mut embedded_count = 0;
for batch in chunks.chunks(EMBEDDING_BATCH_SIZE) {
match embedding_service.embed_batch(&batch_texts).await {
Ok(embeddings) => {
batch_insert_chunks(batch, &embeddings).await?;
embedded_count += batch.len();
}
Err(e) => {
tracing::warn!("Embedding batch failed: {}, storing {} chunks with NULL embedding", e, batch.len());
// 切片仍然写入,embedding = NULL,后续可重新 embedding
batch_insert_chunks_without_embedding(batch).await?;
}
}
}
// 更新文档状态和计数(原子增量,防并发计数不一致)
let doc_id = chunks.first().unwrap().document_id;
let kb_id = chunks.first().unwrap().knowledge_base_id;
atomic_update_document_status(doc_id, "completed", chunks.len(), embedded_count).await?;
// 使用 SQL: UPDATE ... SET document_count = document_count + 1, chunk_count = chunk_count + N
atomic_increment_kb_counts(kb_id, 1, chunks.len()).await?;
}
错误容忍:
- embedding API 不可用时:切片写入但
embedding = NULL,标记文档为completed(降级为纯文本匹配) - 单个切片 embedding 失败:该切片以
embedding = NULL写入,其他切片正常处理 - 文档完成后记录
embedded_count / total_count,前端展示"42/50 切片已向量化" - 所有
embedding = NULL的切片可后续通过"重新 embedding"按钮批量处理 - 单个切片 embedding 失败:跳过该切片,不影响其他切片
- 整体失败:文档状态设为
failed+error_message
4.5 重新处理与增量更新
| 操作 | 触发 | 处理逻辑 |
|---|---|---|
| 重新处理文档 | 用户点击"重新处理"按钮 | 删除该文档所有 chunks → 重新走管线 |
| 更新文档 | 用户上传新版本文件 | 同"重新处理" |
| 删除文档 | 用户删除文档 | 软删除 document + 真删除对应 chunks |
| 禁用知识库 | 管理员操作 | 不删除数据,检索时跳过该 KB 的 chunks |
| 修改切片策略 | 修改 KB 配置 | 需手动触发该 KB 全量重新切片 |
5. 混合意图路由
5.1 三层漏斗设计
用户问题
│
▼
┌─────────────────────────────────────────┐
│ Layer 1: 关键词粗筛(毫秒级) │
│ 遍历所有启用知识库的 intent_keywords │
│ 命中 → 锁定知识库列表 candidate_kb_ids │
│ 未命中 → candidate_kb_ids = 全部启用 KB │
└──────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Layer 2: 向量检索(百毫秒级) │
│ 在 candidate_kb_ids 范围内 │
│ pgvector cosine similarity 检索 │
│ top_k = 5, threshold >= 0.65 │
│ 返回切片 + 相似度分数 │
└──────────────┬──────────────────────────┘
│
▼
top_score >= 0.75?
│ │
YES NO
│ │
▼ ▼
直接使用 ┌──────────────────────────────────┐
检索结果 │ Layer 3: LLM 兜底(秒级) │
│ 调用 LLM 做意图分类 │
│ "用户意图应查询哪类知识库?" │
│ 根据 LLM 回答重新锁定 KB │
│ 再次 Layer 2 向量检索 │
└──────────────────────────────────┘
5.2 Layer 1: 关键词粗筛
fn keyword_match(query: &str, all_kb_ids: &[KnowledgeBase]) -> Vec<Uuid> {
let mut matched = Vec::new();
for kb in all_kb_ids {
if kb.intent_keywords.iter().any(|kw| query.contains(kw)) {
matched.push(kb.id);
}
}
// 无匹配则返回全部启用的 KB(降级为全库搜索)
if matched.is_empty() {
all_kb_ids.iter().map(|kb| kb.id).collect()
} else {
matched
}
}
关键词配置示例:
| 知识库 | intent_keywords |
|---|---|
| 机构信息 | 价格, 费用, 多少钱, 套餐, 体检项目, 科室, 医生, 地址, 电话, 营业时间, 上班, 血透, 透析 |
| 就医流程 | 预约, 挂号, 流程, 怎么, 如何, 需要带, 准备, 注意, 报告, 取结果, 空腹 |
| 健康科普 | 正常吗, 什么意思, 偏高, 偏低, 指标, 血压, 血糖, 肝功能, 肾功能, 胆固醇 |
| FAQ | 能不能, 可以吗, 有没有, 是否, 支持, 医保, 报销 |
| 临床指南 | 指南, 规范, 诊疗, 方案, 适应症, 禁忌症 |
5.3 Layer 2: 向量检索
复用现有 pgvector 基础设施,改造 KnowledgeSearchRepository 适配新表:
-- 在指定知识库范围内检索(全部参数化绑定,禁止 format! 拼接)
SELECT c.id, c.content, c.metadata, c.document_id, c.knowledge_base_id,
kb.name AS kb_name, doc.title AS doc_title,
1 - (c.embedding <=> $1::vector) AS similarity
FROM ai_knowledge_chunks c
JOIN ai_knowledge_bases kb ON c.knowledge_base_id = kb.id
JOIN ai_knowledge_documents doc ON c.document_id = doc.id
WHERE c.knowledge_base_id = ANY($2::uuid[])
AND c.embedding IS NOT NULL
AND c.tenant_id = $3
ORDER BY c.embedding <=> $1::vector
LIMIT $4;
安全约束: 所有 pgvector 查询必须使用参数化绑定(
$N),禁止format!拼接任何用户输入。现有vector_search.rs中的format!("AND analysis_type = '{}'", ...)模式必须在此次重构中消除。如需按kb_type过滤,通过 JOINai_knowledge_bases实现,不直接拼接到 WHERE 子句。
检索参数:
| 参数 | 值 | 说明 |
|---|---|---|
| top_k | 5 | 返回最多 5 个切片 |
| similarity_threshold | 0.65 | 低于此阈值的切片丢弃 |
| max_context_tokens | 4000 | 总注入上下文 token 上限 |
| single_chunk_max_chars | 1500 | 单个切片最大字符数(超长截断) |
5.4 Layer 3: LLM 兜底
当 Layer 2 最高相似度 < 0.75 时触发,调用轻量 LLM 做意图分类:
async fn llm_classify_intent(query: &str, kb_types: &[(&str, &str)]) -> Result<Vec<Uuid>> {
let prompt = format!(
"用户问题:{}\n\n可用的知识库类型:\n{}\n\n\
请判断用户的问题应该查询哪个知识库类型。只返回类型标识,不要解释。",
query,
kb_types.iter().map(|(t, desc)| format!("- {}: {}", t, desc)).collect::<Vec<_>>().join("\n")
);
// 调用 LLM,解析返回的 kb_type
// 返回匹配的知识库 ID 列表
}
使用轻量模型: Layer 3 优先使用 Ollama 本地模型(如 qwen3:4b),避免额外的 API 开销。Ollama 不可用时降级使用配置的默认 Provider。
5.5 检索结果组装
将命中的切片组装为 RAG context,注入 System Prompt:
fn build_rag_context(results: &[SearchResult], tenant_name: &str) -> String {
let mut context = format!("## 参考知识\n");
context.push_str("以下是来自「{}」知识库的相关内容,请基于这些内容回答用户问题。\n", tenant_name);
context.push_str("如果参考知识不足以回答,请诚实告知。\n\n");
for r in results {
context.push_str(&format!("[来源: {} | {} | 相似度: {:.2}]\n",
r.kb_name, r.doc_title, r.similarity));
context.push_str(&r.content);
context.push_str("\n\n");
}
context.push_str("## 回答要求\n");
context.push_str("- 基于以上参考知识回答,不要编造信息\n");
context.push_str("- 引用知识时标注来源 [ref:知识库名]\n");
context.push_str("- 涉及医疗建议时提示\"仅供参考,请遵医嘱\"\n");
context
}
Context 截断策略: 按相似度从高到低累加,总 tokens 超过 max_context_tokens 时停止添加。
5.6 命中统计
每次检索完成后,异步更新命中切片的统计数据:
tokio::spawn(async move {
for chunk_id in hit_chunk_ids {
sqlx::query(
"UPDATE ai_knowledge_chunks SET hit_count = hit_count + 1, last_hit_at = now() WHERE id = $1"
)
.bind(chunk_id)
.execute(&pool)
.await
.ok(); // 非关键路径,忽略错误
}
});
6. 管理后台 UI
6.1 页面结构
/health/ai-knowledge → 知识库列表页(主入口)
/health/ai-knowledge/:kbId → 知识库详情页(文档管理)
/health/ai-knowledge/:kbId?tab=settings → 知识库设置
/health/ai-knowledge/:kbId?tab=test → 命中测试
6.2 Page 1: 知识库列表页
路由: /health/ai-knowledge
布局:
- 顶部:标题"知识库管理" + 搜索框 + "创建知识库"按钮
- 主体:卡片网格布局(每行 3-4 张卡片)
知识库卡片内容:
- 渐变色头部(按 kb_type 区分颜色)+ 图标 + 名称
- 类型标签
- 文档数 / 切片数统计
- 启用/禁用状态指示器
- 点击进入详情页
创建知识库弹窗:
- 名称(必填)
- 类型选择(7 种,点击选择,单选)
- 描述(选填)
- 意图关键词(TagInput,选填)
- 切片策略(根据类型自动推荐默认值,高级选项折叠展示)
- max_tokens 滑块
- overlap 滑块
- separator 输入框
6.3 Page 2: 知识库详情页
路由: /health/ai-knowledge/:kbId
布局:
- 面包屑导航:知识库 > {名称}
- 顶部操作栏:设置按钮 / 命中测试按钮 / 添加文档按钮
- Tab 切换:文档列表 | 设置 | 命中测试
文档列表 Tab:
| 列 | 内容 |
|---|---|
| 文档名称 | 图标 + 标题 |
| 来源类型 | 标签(上传/URL/手动) |
| 切片数 | 数字 |
| 状态 | ✅已完成 / ⏳处理中(进度) / ❌失败(原因) |
| 更新时间 | 日期 |
| 操作 | 重新处理 / 编辑 / 删除 |
添加文档入口(三种方式):
- 文件上传 — 拖拽或点击上传,支持 PDF/Word/TXT/MD/Excel/CSV,单文件最大 50MB
- URL 导入 — 输入 URL,系统抓取并解析
- 手动录入 — 富文本编辑器(Markdown),适合 FAQ 和简短内容
处理进度展示: 文档状态为 processing 时,轮询 GET /documents/{id}(每 2 秒),显示进度条和当前处理阶段。
6.4 Page 3: 命中测试 + 切片预览
路由: /health/ai-knowledge/:kbId?tab=test
布局:
- 顶部:输入框 + "测试"按钮
- 路由路径展示:
关键词命中 ["xxx"] → 锁定: 知识库名 → 向量检索 top 3 - 结果列表:按相似度排序的切片卡片
切片卡片内容:
- 切片序号 + 来源文档名 + 页码/行号
- 相似度分数(颜色区分:绿 ≥ 0.85 / 黄 ≥ 0.70 / 红 < 0.70)
- 切片原文(超长可展开)
- 编辑按钮(修改切片原文后自动重新 embedding)
6.5 知识库设置 Tab
路由: /health/ai-knowledge/:kbId?tab=settings
可编辑配置:
- 基本信息:名称、描述、图标
- 启用/禁用开关
- 意图关键词管理(增删改)
- 切片策略配置(修改后需手动触发全量重新切片)
- 删除知识库(软删除,含确认弹窗)
6.6 组件清单
| 组件 | 类型 | 说明 |
|---|---|---|
KnowledgeBaseList |
页面组件 | 知识库卡片网格 |
KnowledgeBaseCard |
卡片组件 | 单个知识库卡片 |
CreateKBModal |
弹窗组件 | 创建/编辑知识库表单 |
DocumentList |
页面组件 | 文档列表表格 |
UploadDocument |
上传组件 | 拖拽上传 + URL 输入 + 手动录入 |
DocumentProgress |
进度组件 | 处理进度轮询展示 |
HitTestPanel |
面板组件 | 命中测试输入 + 结果展示 |
ChunkCard |
卡片组件 | 单个切片预览卡片 |
TagInput |
输入组件 | 关键词标签输入(回车添加) |
所有组件使用 Ant Design 基础组件构建,遵循现有项目 UI 规范。
7. AI 客服集成
7.1 集成架构
在现有 chat_handler 的消息处理链中插入 RAG 检索步骤,改动最小化:
现有 chat_handler 流程:
用户消息 → 构建 messages → 调用 LLM → 返回回答
新增 RAG 链路(在"构建 messages"和"调用 LLM"之间插入):
用户消息 → 构建 messages
→ [NEW] HybridKnowledgeRouter::get_context(last_user_message)
→ [NEW] rag_context 注入到 system prompt
→ 调用 LLM → 返回回答
→ [NEW] 解析 [ref:xxx] 引用
→ [NEW] 异步更新 chunk.hit_count
7.2 KnowledgeSource trait 适配
保留现有 trait 接口,新增 HybridKnowledgeRouter 实现:
// 现有 trait(保持不变)
#[async_trait]
pub trait KnowledgeSource: Send + Sync {
async fn get_context(&self, query: &KnowledgeQuery) -> Result<KnowledgeContext>;
fn source_type(&self) -> &str;
async fn health_check(&self) -> Result<bool>;
}
// 新实现
pub struct HybridKnowledgeRouter {
pool: DatabaseConnection,
embedding_service: Arc<EmbeddingService>,
llm_provider: Arc<dyn LlmProvider>, // Layer 3 用
config: RouterConfig,
}
#[async_trait]
impl KnowledgeSource for HybridKnowledgeRouter {
async fn get_context(&self, query: &KnowledgeQuery) -> Result<KnowledgeContext> {
// Layer 0: 边界情况 — 无知识库或全部禁用时,返回空 context(不报错)
let all_kbs = self.load_enabled_kbs(&query.tenant_id).await?;
if all_kbs.is_empty() {
return Ok(KnowledgeContext {
source: "hybrid_router".into(),
context_text: String::new(),
references: vec![],
confidence: 0.0,
});
}
// Layer 1: 关键词粗筛
let candidate_kb_ids = self.keyword_match(&query.query_text, &all_kbs).await?;
// Layer 2: 向量检索
let results = self.vector_search(&query.query_text, &candidate_kb_ids).await?;
// Layer 3: LLM 兜底(仅 top < 0.75 时)
let final_results = if results.first().map(|r| r.similarity).unwrap_or(0.0) < 0.75 {
self.llm_fallback(&query.query_text, &results).await?
} else {
results
};
// 组装 context
let context_text = self.build_context(&final_results, &query.tenant_id).await?;
let references = final_results.iter().map(|r| Reference {
source: format!("{} > {}", r.kb_name, r.doc_title),
content: r.content.clone(),
confidence: r.similarity,
}).collect();
Ok(KnowledgeContext {
source: "hybrid_router".to_string(),
context_text,
references,
confidence: final_results.first().map(|r| r.similarity).unwrap_or(0.0),
})
}
}
旧的 StructuredKnowledgeSource 和 VectorKnowledgeSource 废弃,统一由 HybridKnowledgeRouter 替代。
7.3 System Prompt 模板
AI 客服的 System Prompt 由三部分拼接:
Part 1: 角色指令(租户可自定义,存储在 ai_tenant_config)
Part 2: RAG 知识上下文(由 HybridKnowledgeRouter 动态注入)
Part 3: 引用要求和安全约束(固定模板)
Part 1 — 角色指令模板:
你是「{tenant_name}」的健康管理助手。你的职责是基于知识库内容回答用户关于机构服务、就医流程、健康科普等问题。
## 行为准则
1. 优先使用参考知识回答,不要编造不存在的服务或价格
2. 引用知识时使用 [ref:来源名] 标注出处
3. 涉及医疗建议时,必须附加"仅供参考,请遵医嘱"
4. 知识库中找不到答案时,诚实告知并建议联系前台 {phone}
5. 保持友好、专业、简洁的语气
## 知识范围
- 机构信息(科室、医生、服务项目、价格)
- 就医流程(预约、检查、报告领取)
- 健康科普(常见疾病、指标解读)
- 常见问答(FAQ)
## 超出范围
以下问题请引导用户联系专业医护人员:
- 具体诊断和处方建议
- 药物用量调整
- 紧急医疗情况 → 拨打 120
Part 3 — 固定约束:
## 回答要求
- 基于参考知识回答,不要编造信息
- 引用知识时标注来源 [ref:知识库名]
- 涉及医疗建议时提示"仅供参考,请遵医嘱"
- 如果参考知识不足以回答,诚实告知并建议用户联系前台
7.4 前端引用渲染
AI 回答中的 [ref:xxx] 标记在前端解析为可交互的引用标签:
// 解析 AI 回答中的 [ref:xxx] 标记
function parseReferences(text: string): ParsedContent[] {
const refRegex = /\[ref:(.+?)\]/g;
// 将文本拆分为普通文本段和引用标记
// 引用标记渲染为可点击的蓝色标签
// 点击展开显示完整来源信息
}
// 引用来源折叠面板
<Collapse ghost size="small">
<Collapse.Panel header={`📎 引用来源 (${refs.length})`} key="refs">
{refs.map(ref => (
<div key={ref.id}>
<Text type="secondary">
📚 {ref.kbName} › {ref.docTitle} › 切片#{ref.chunkIndex}
</Text>
</div>
))}
</Collapse.Panel>
</Collapse>
7.5 Agent Tool 适配
现有 search_medical_knowledge Agent Tool 改造为使用新的统一知识库:
// 改造前
pub struct SearchMedicalKnowledgeTool { /* 直接用 vector_search */ }
// 改造后
pub struct SearchMedicalKnowledgeTool {
router: Arc<HybridKnowledgeRouter>,
}
impl AgentTool for SearchMedicalKnowledgeTool {
async fn execute(&self, params: &Value) -> Result<Value> {
let query = params["query"].as_str().ok_or(...)?;
let kb_type = params["knowledge_base_type"].as_str(); // 新增可选参数
let knowledge_query = KnowledgeQuery {
query_text: query.to_string(),
kb_type_filter: kb_type.map(String::from),
..Default::default()
};
let context = self.router.get_context(&knowledge_query).await?;
// 返回格式化的检索结果
}
}
新增可选参数 knowledge_base_type,Agent 可指定搜索特定类型知识库。
8. 旧数据迁移
8.1 迁移策略
采用一次性迁移脚本,在 SeaORM migration 中执行:
Phase 1: 创建新表(ai_knowledge_bases / ai_knowledge_documents / ai_knowledge_chunks)
Phase 2: 迁移旧表数据
Phase 3: 标记旧表 deprecated(不删除,保留备份)
8.2 数据映射
ai_knowledge_rules → 新模型:
| 旧字段 | 新映射 |
|---|---|
| rule_name | kb.name + doc.title |
| analysis_type | kb.kb_type = "rule" |
| condition_expr | chunk.metadata.condition |
| action_text | chunk.content |
| priority | chunk.metadata.priority |
每个 rule 创建为:1 个知识库(按 analysis_type 分组)→ 1 个文档 → 1 个切片。
ai_knowledge_references → 新模型:
| 旧字段 | 新映射 |
|---|---|
| title | doc.title |
| analysis_type | 归入对应知识库 |
| source_name | chunk.metadata.source_name |
| content_summary | chunk.content |
| embedding | chunk.embedding(直接迁移) |
| tags | chunk.metadata.tags |
ai_knowledge_guides → 新模型:
| 旧字段 | 新映射 |
|---|---|
| title | doc.title |
| analysis_type | 归入对应知识库 |
| content | chunk.content |
| category | chunk.metadata.category |
| embedding | chunk.embedding(直接迁移) |
8.3 迁移 SQL 示例
关键约束: 使用临时映射表确保旧数据精确关联到新知识库,避免 JOIN 模糊匹配导致数据重复或丢失。
-- Step 0: 创建临时映射表(tenant_id + analysis_type → new_kb_id)
CREATE TEMP TABLE kb_migration_map (
tenant_id UUID,
analysis_type VARCHAR(50),
old_table VARCHAR(20), -- 'references' / 'guides' / 'rules'
new_kb_id UUID
);
-- Step 1: 按 tenant_id + analysis_type 创建知识库,同时记录映射
INSERT INTO ai_knowledge_bases (id, tenant_id, name, kb_type, description, chunk_strategy, intent_keywords)
SELECT
gen_random_uuid(),
tenant_id,
'参考资料 - ' || analysis_type,
'reference',
'从旧表自动迁移',
'{"max_tokens": 400, "overlap": 0, "separator": "\n\n", "strategy": "per_item"}'::jsonb,
'[]'::jsonb
FROM ai_knowledge_references
WHERE deleted_at IS NULL
GROUP BY tenant_id, analysis_type
RETURNING id, tenant_id, name; -- PostgreSQL RETURNING 支持精确关联
-- Step 1b: 填充映射表(使用 name 匹配,因为 RETURNING 不能直接填充 temp table)
INSERT INTO kb_migration_map (tenant_id, analysis_type, old_table, new_kb_id)
SELECT
r.tenant_id,
r.analysis_type,
'references',
kb.id
FROM (SELECT DISTINCT tenant_id, analysis_type FROM ai_knowledge_references WHERE deleted_at IS NULL) r
JOIN ai_knowledge_bases kb ON kb.tenant_id = r.tenant_id
AND kb.name = '参考资料 - ' || r.analysis_type
AND kb.kb_type = 'reference';
-- Step 2: 为每个 reference 创建文档(通过映射表精确 JOIN)
INSERT INTO ai_knowledge_documents (id, tenant_id, knowledge_base_id, title, source_type, raw_content, status, chunk_count)
SELECT
r.id,
r.tenant_id,
m.new_kb_id,
r.title,
'manual',
r.content_summary,
'completed',
1
FROM ai_knowledge_references r
JOIN kb_migration_map m ON m.tenant_id = r.tenant_id
AND m.analysis_type = r.analysis_type
AND m.old_table = 'references'
WHERE r.deleted_at IS NULL;
-- Step 3: 迁移切片 + 向量(通过映射表精确 JOIN)
INSERT INTO ai_knowledge_chunks (id, tenant_id, document_id, knowledge_base_id, chunk_index, content, embedding, metadata)
SELECT
gen_random_uuid(),
r.tenant_id,
r.id,
m.new_kb_id,
0,
r.content_summary,
r.embedding::vector,
jsonb_build_object('source_name', r.source_name, 'tags', r.tags, 'migrated_from', 'ai_knowledge_references')
FROM ai_knowledge_references r
JOIN kb_migration_map m ON m.tenant_id = r.tenant_id
AND m.analysis_type = r.analysis_type
AND m.old_table = 'references'
WHERE r.deleted_at IS NULL AND r.embedding IS NOT NULL;
-- Step 4-6: 同理迁移 ai_knowledge_guides 和 ai_knowledge_rules
-- (guides 的 kb_type='clinical_guide',rules 的 kb_type='rule')
-- Step 7: 清理临时表
DROP TABLE kb_migration_map;
8.4 旧表处理
- 不删除旧表,添加注释
-- DEPRECATED: 迁移到 ai_knowledge_* 新模型 (2026-05-26) - 旧 Entity 文件标记
#[deprecated] - 旧 API 端点保留但返回 301 重定向到新端点(过渡期 1 个月)
- 过渡期结束后,下个版本移除旧表和旧 API
9. API 端点清单
9.1 知识库管理
| 方法 | 路径 | Handler | 权限 | 说明 |
|---|---|---|---|---|
| GET | /api/v1/ai/knowledge/bases |
list_bases |
ai.knowledge.list |
知识库列表(分页) |
| POST | /api/v1/ai/knowledge/bases |
create_base |
ai.knowledge.manage |
创建知识库 |
| GET | /api/v1/ai/knowledge/bases/{id} |
get_base |
ai.knowledge.list |
知识库详情 |
| PUT | /api/v1/ai/knowledge/bases/{id} |
update_base |
ai.knowledge.manage |
更新知识库配置 |
| DELETE | /api/v1/ai/knowledge/bases/{id} |
delete_base |
ai.knowledge.manage |
删除知识库(软删除) |
9.2 文档管理
| 方法 | 路径 | Handler | 权限 | 说明 |
|---|---|---|---|---|
| GET | /api/v1/ai/knowledge/bases/{kbId}/documents |
list_documents |
ai.knowledge.list |
文档列表 |
| POST | /api/v1/ai/knowledge/bases/{kbId}/documents/upload |
upload_document |
ai.knowledge.manage |
上传文件 |
| POST | /api/v1/ai/knowledge/bases/{kbId}/documents/url |
import_url |
ai.knowledge.manage |
URL 导入 |
| POST | /api/v1/ai/knowledge/bases/{kbId}/documents/manual |
create_manual |
ai.knowledge.manage |
手动录入 |
| GET | /api/v1/ai/knowledge/bases/{kbId}/documents/{docId} |
get_document |
ai.knowledge.list |
文档详情(含状态) |
| DELETE | /api/v1/ai/knowledge/bases/{kbId}/documents/{docId} |
delete_document |
ai.knowledge.manage |
删除文档 |
| POST | /api/v1/ai/knowledge/bases/{kbId}/documents/{docId}/reprocess |
reprocess_document |
ai.knowledge.manage |
重新处理 |
9.3 切片管理
| 方法 | 路径 | Handler | 权限 | 说明 |
|---|---|---|---|---|
| GET | /api/v1/ai/knowledge/bases/{kbId}/documents/{docId}/chunks |
list_chunks |
ai.knowledge.list |
切片列表 |
| PUT | /api/v1/ai/knowledge/chunks/{chunkId} |
update_chunk |
ai.knowledge.manage |
编辑切片内容 |
| POST | /api/v1/ai/knowledge/chunks/{chunkId}/re-embed |
re_embed_chunk |
ai.knowledge.manage |
重新 embedding |
9.4 检索与测试
| 方法 | 路径 | Handler | 权限 | 说明 |
|---|---|---|---|---|
| POST | /api/v1/ai/knowledge/bases/{kbId}/test |
hit_test |
ai.knowledge.list |
命中测试 |
| POST | /api/v1/ai/knowledge/search |
search |
ai.knowledge.list |
统一检索(AI 内部调用) |
9.5 废弃端点
以下旧端点保留 1 个月过渡期,返回 301:
| 旧路径 | 重定向到 |
|---|---|
/api/v1/ai/knowledge/references |
/api/v1/ai/knowledge/bases?kb_type=reference |
/api/v1/ai/knowledge/guides |
/api/v1/ai/knowledge/bases?kb_type=clinical_guide |
9.6 DTO 清单
CreateKnowledgeBaseReq:
pub struct CreateKnowledgeBaseReq {
pub name: String, // validate(length(min=1, max=200))
pub kb_type: String, // validate(custom = validate_kb_type)
pub description: Option<String>, // validate(length(max=2000))
pub icon: Option<String>, // validate(length(max=50))
pub intent_keywords: Option<Vec<String>>, // validate(length(max=50))
pub chunk_strategy: Option<ChunkStrategyConfig>, // 可选,默认按 kb_type
}
UploadDocumentReq:
- multipart/form-data:
file(必填) +title(选填)
ImportUrlReq:
pub struct ImportUrlReq {
pub url: String, // validate(url, custom = validate_no_ssrf)
pub title: Option<String>,
}
SSRF 防护要求(§9.6 安全约束):
validate_no_ssrf必须同时验证:① 仅允许http/https协议 ② 禁止localhost/127.0.0.1/10.x/172.16-31.x/192.168.x/0.0.0.0③ DNS 解析后检查实际 IP(防 DNS rebinding)④ 请求超时 10 秒 ⑤ 禁止重定向到内网地址(跟随重定向时重新校验)- 文件上传安全:MIME 类型白名单(
application/pdf,application/vnd.openxmlformats-officedocument.*,text/*)+ 大小上限 50MB + 文件名 sanitize(防路径穿越)
CreateManualReq:
pub struct CreateManualReq {
pub title: String, // validate(length(min=1, max=500))
pub content: String, // validate(length(min=1))
}
HitTestReq:
pub struct HitTestReq {
pub query: String, // validate(length(min=1, max=500))
pub top_k: Option<i32>, // 默认 5,最大 10
}
10. 依赖与风险
10.1 新增 Rust 依赖
| 依赖 | 用途 | 大小评估 | 备注 |
|---|---|---|---|
pdf-extract |
PDF 文本提取 | 中 | 纯 Rust,无系统依赖 |
docx-rs |
Word 文档解析 | 小 | 纯 Rust |
calamine |
Excel 文件读取 | 小 | 纯 Rust,支持 xlsx/xls/csv |
scraper |
HTML DOM 解析(URL 导入) | 小 | 已在项目间接依赖中 |
validator |
DTO 输入校验 | 小 | workspace 已有,需在 erp-ai 的 Cargo.toml 中启用 derive feature |
不新增的系统级依赖 — 所有解析库均为纯 Rust 实现,无需安装系统库。
10.2 技术风险
| 风险 | 概率 | 影响 | 缓解措施 |
|---|---|---|---|
| PDF 扫描件无法提取文本 | 高 | 中 | 检测纯图片 PDF → 标记 failed + 提示用户使用 OCR 版本 |
| Embedding API 不可用 | 中 | 中 | 降级为纯文本匹配(embedding=NULL 的切片不参与向量检索) |
| 大文件处理超时 | 中 | 低 | 设置单文件 50MB 上限;大文件按页分批处理 |
| pgvector 索引性能下降 | 低 | 中 | 切片数 < 100 万用 IVFFlat;超过后迁移到 HNSW |
| 旧数据迁移向量维度不匹配 | 低 | 高 | 迁移脚本预检查维度;不匹配则跳过 embedding,标记需重新处理 |
| LLM 兜底延迟影响响应时间 | 中 | 低 | Layer 3 仅在低置信度时触发(预估 < 20% 请求);使用本地 Ollama 模型 |
10.3 性能预估
| 指标 | 预估值 |
|---|---|
| 文档上传响应 | < 200ms(异步处理,立即返回) |
| PDF 解析速度 | ~100 页/10 秒 |
| Embedding 生成 | ~100 chunks/5 秒(OpenAI API) |
| 向量检索延迟 | < 100ms(< 10 万切片) |
| LLM 兜底延迟 | 1-3 秒(Ollama 本地) |
| 端到端 AI 客服响应 | 2-5 秒(含 RAG 检索 + LLM 生成) |
10.4 存储预估
| 资源 | 预估值 |
|---|---|
| 单个切片存储 | ~2KB(content + metadata + 1536 维 float vector ≈ 6KB) |
| 1000 文档 ≈ 10,000 切片 | ~60MB |
| 文件存储(uploads) | 取决于上传量,建议 10GB 起步 |
| pgvector 索引 | 约切片数据的 1.5 倍 |