前几篇我们搞定了三Agent编排,IntakeAgent问诊、TriageAgent分诊、ReportAgent出报告,链路跑通了,效果也还行。但跑着跑着我就发现一个问题:Agent再聪明,没有知识库也是巧妇难为无米之炊。用户问"高血压该挂什么科",AI只能凭"训练时学过的知识"回答,没法引用具体的医学文献;用户问"偏头痛怎么预防",AI给的建议很泛,没有权威来源支撑。
这一篇就解决这个痛点------给AI接一个"知识库大脑",让它回答问题时有据可查、有源可溯。这就是RAG(Retrieval Augmented Generation,检索增强生成)。
前篇回顾:
(4-1)RAG系统前置操作文档:PostgreSQL 数据库准备、RAG 数据库设计配置、知识库数据来源

一、先搞懂RAG是个啥(新手友好版)
1.1 用医生看病来类比
我一开始看RAG的英文全称"Retrieval Augmented Generation",检索增强生成,每个字都认识,连起来就懵了。后来用医生看病的场景一套,瞬间通透:
想象你去看病,遇到两种医生:
- 没RAG的医生:纯靠脑子里的记忆给你看病。他学过的东西很多,但有时候会记错,遇到新药新疗法也不一定知道,更尴尬的是,你问他"这个建议有依据吗?"他只能说"凭经验"。
- 有RAG的医生:除了脑子里的知识,他还会在看病时去翻医学文献、查诊疗指南。给你建议的时候,他能说"根据《中国高血压防治指南2024》,建议您......",每句话都有出处。
RAG就是给AI装一个"随时可查的医学图书馆"。AI回答问题前,先去图书馆里检索相关资料,然后结合资料和自己的能力,给出有依据的回答。
1.2 RAG = Retrieval + Augmented + Generation
把这三个词拆开看:
| 词 | 中文 | 在项目里干啥 |
|---|---|---|
| Retrieval | 检索 | 用户说"头痛",先去知识库里检索"头痛"相关的医学文档 |
| Augmented | 增强 | 把检索到的文档塞到AI的提示词里,让AI"看着资料回答" |
| Generation | 生成 | AI结合资料和自己的能力,生成最终回答 |
1.3 无RAG vs 有RAG,差别有多大
直接看对比表:
| 维度 | 无RAG | 有RAG |
|---|---|---|
| 知识来源 | 仅靠模型训练时的知识 | 知识库 + 模型能力 |
| 知识时效 | 训练截止日期前的知识 | 可随时更新知识库 |
| 专业深度 | 通用知识,深度有限 | 可接入专业医学文献 |
| 可追溯性 | 无法引用来源 | 可展示知识来源 |
| 幻觉风险 | 高,AI可能瞎编 | 低,有资料兜底 |
| 领域适配 | 需要微调模型 | 只需更新知识库 |
举个真实例子,用户问"偏头痛的预防建议":
- 无RAG:AI回答"保持规律作息、避免强光刺激、适度运动......"(泛泛而谈,没出处)
- 有RAG:AI回答"根据医学知识库:偏头痛的预防建议包括规律作息、避免已知诱发因素(如特定食物、强光、情绪波动)、适度运动、保持充足睡眠。(来源:医学知识库)"(有具体内容,有来源)
1.4 RAG的工作流程
整个RAG流程可以简化成这样:
typescript
用户输入"头痛伴恶心"
│
▼
┌──────────────────┐
│ 1. 文本向量化 │ 把"头痛伴恶心"转成768维向量
│ (Embedding) │
└────────┬─────────┘
▼
┌──────────────────┐
│ 2. 向量检索 │ 在pgvector里找最相似的Top3文档
│ (pgvector) │
└────────┬─────────┘
▼
┌──────────────────┐
│ 3. 上下文拼接 │ 把检索到的文档塞进AI的提示词
│ (Augmented) │
└────────┬─────────┘
▼
┌──────────────────┐
│ 4. AI生成回答 │ AI"看着资料"生成分诊建议
│ (Generation) │
└──────────────────┘
看起来不复杂,但每一步都有坑。下面我从环境搭建开始,一步步带大家跑通整个RAG链路。
二、环境搭建:PostgreSQL + pgvector
2.1 为什么选pgvector
RAG需要一个"向量数据库"来存储和检索文档向量。市面上向量数据库不少,我对比了一下:
| 向量数据库 | 类型 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| PostgreSQL + pgvector | 关系库扩展 | 一库多用,事务+向量+全文检索,部署维护省事 | 超大规模(亿级)性能不如专业向量库 | 需同时处理关系数据和向量检索的项目,中小规模 |
| Milvus | 专业向量库 | 性能强大,支持亿级向量,分布式架构 | 单独部署维护复杂,学习成本高 | 大规模向量检索场景 |
| Qdrant | 专业向量库 | Rust实现,性能高效,API友好,过滤功能强 | 独立组件,需额外集成 | 中大规模项目,需要高效过滤的场景 |
| Weaviate | 专业向量库 | 内置多种模块,功能丰富,开箱即用 | 部署复杂 | 复杂AI应用,需要丰富功能的项目 |
| Chroma | 轻量向量库 | Python生态完善,简单易用,原型开发快 | Java支持弱,性能一般 | Python项目原型,快速开发 |
| Elasticsearch | 搜索引擎 | 支持全文+向量混合检索,功能全面 | 运维成本高,资源消耗大 | 需要全文和向量混合检索的场景 |
| Redis Stack | 内存库 | 检索速度快,延迟低 | 内存成本高,数据持久化复杂 | 对速度要求极高,数据量不大的实时检索场景 |
| FAISS | 向量索引库 | Meta出品,纯索引,高性能,不带存储管理 | 存储管理需外部处理,功能相对单一 | 已有存储方案,需要高性能索引的场景 |
我选pgvector的理由:
- 一个库搞定三件事:本项目既要存关系数据(科室、症状、急症规则),又要存向量数据(疾病知识embedding),还要做模糊检索(症状名匹配)。pgvector一个库全包了,不用再引入Milvus或ES。
- 部署维护省事:Docker拉一个镜像就行,不用单独运维一个向量数据库。
- 规模够用:本项目知识库规模在数千到数十万条,pgvector完全扛得住。等真到了百万级再换Milvus也不迟。
- Spring AI原生支持 :Spring AI提供了
PgVectorStore,开箱即用。
一句话总结:杀鸡不用牛刀。中小规模RAG项目,pgvector是最务实的选择。
2.2 Docker部署PostgreSQL + pgvector
我用的镜像是pgvector/pgvector:pg18,基于PostgreSQL 18并内置pgvector扩展。
项目根目录下的docker-compose.yml:
yaml
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg18
container_name: medical-triage-db
environment:
POSTGRES_DB: medical_triage
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
# PostgreSQL 首次创建容器时自动执行 /docker-entrypoint-initdb.d/ 下的脚本
# 脚本按文件名排序执行:01-schema.sql 先建表,02-seed-data.sql 后插数据
# 数据持久化在 pgdata Volume 中,后续重启容器不会重复执行
- ./medical-ai-service/src/main/resources/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
- ./medical-ai-service/src/main/resources/seed-data.sql:/docker-entrypoint-initdb.d/02-seed-data.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
几个关键点解释一下:
pgvector/pgvector:pg18:这个镜像自带pgvector扩展,不用手动安装。如果用普通的postgres:18镜像,pgvector扩展是没有的,这是个坑。volumes挂载 :pgdata做数据持久化,防止容器删除后数据丢失。/docker-entrypoint-initdb.d/挂载的是初始化脚本,只在首次创建容器时执行,后续重启不会重复执行。healthcheck:健康检查,确保容器完全启动后才接受连接。Spring Boot连接时如果数据库还没准备好,会报连接失败。
启动命令:
bash
cd medical-triage-assistant
docker compose up -d
Docker Desktop界面化操作(小贴士)
虽然命令行够用,但日常开发中我更习惯用Docker Desktop的图形界面来管理容器,直观又省事。下面是几个常用操作:


-
查看容器运行状态 :打开Docker Desktop,进入Containers 面板,可以看到
medical-triage-db容器的运行状态(绿色表示运行中)、端口映射(5432:5432)和健康检查结果,比敲docker ps直观多了。 -
查看容器日志 :点击容器名进入详情,切换到Logs 标签页,可以直接看到PostgreSQL的启动日志。重点找
/docker-entrypoint-initdb.d/下脚本的执行记录------如果看到01-schema.sql和02-seed-data.sql执行无报错,说明表结构和种子数据初始化成功。 -
重启/停止容器 :在Containers面板里,每个容器右侧都有重启(↻)、停止(■)按钮。改了
docker-compose.yml后,点重启即可生效,不用反复敲命令。 -
查看挂载的卷和数据 :进入Volumes 面板,找到
pgdata卷,可以查看数据持久化的存储情况。如果想彻底重置数据库(比如改了schema要重新初始化),删掉这个卷再重启容器,初始化脚本会重新执行。
小贴士 :界面化操作最大的好处是------排查问题时不用记一堆
docker logs、docker volume inspect命令,点点鼠标就能定位问题。新手尤其推荐先用GUI熟悉容器状态,再过渡到命令行。
2.3 验证pgvector扩展可用
容器启动后,进入容器验证一下扩展是否可用:
bash
# 进入容器
docker exec -it medical-triage-db psql -U postgres -d medical_triage
# 验证pgvector扩展
SELECT * FROM pg_available_extensions WHERE name = 'vector';
# 预期输出:
# name | default_version | installed_version | comment
# -------+-----------------+-------------------+------------------------------
# vector| 0.7.x | | vector data type and ivfflat and hnsw access methods
# 验证pg_trgm扩展(用于模糊匹配)
SELECT * FROM pg_available_extensions WHERE name = 'pg_trgm';
# 退出
\q
如果vector扩展的default_version是0.7+,说明pgvector可用。pg_trgm是PostgreSQL自带的模糊匹配扩展,用于症状名的模糊搜索。
踩坑提醒 :一定要用
pgvector/pgvector镜像,不要用普通的postgres镜像。我一开始用了postgres:16,结果CREATE EXTENSION vector报错"extension not found",折腾了半天才发现镜像不对。

三、数据库设计:6张核心表

3.1 整体架构
整个RAG知识库的数据库架构长这样:
typescript
medical_triage (数据库)
├── Extensions
│ ├── vector --- 向量类型和相似度检索(pgvector)
│ ├── pg_trgm --- 三元组模糊匹配(全文检索辅助)
│ └── uuid-ossp --- UUID 生成函数
│
├── 业务表(6张核心表)
│ ├── symptom_dictionary --- 症状字典(分诊核心)
│ ├── department_info --- 科室信息
│ ├── disease_knowledge --- 疾病知识(含向量字段)
│ ├── knowledge_triples --- 知识图谱三元组(GraphRAG预留)
│ ├── emergency_rules --- 急症拦截规则
│ └── knowledge_tasks --- 知识入库任务
│
├── 向量存储表(Spring AI 自动创建)
│ └── vector_store --- PgVectorStore 自动管理
│
└── 索引
├── GIN 索引(数组/全文检索)
├── IVFFlat 索引(向量相似度)
└── B-tree 索引(常规查询)
3.2 六张表的作用
| 表名 | 作用 | 关键字段 |
|---|---|---|
symptom_dictionary |
症状标准化、同义词映射 | synonyms(TEXT\[\])、possible_departments(TEXT\[\]) |
department_info |
科室信息、就诊准备建议 | main_symptoms(TEXT\[\])、preparation_tips(TEXT) |
disease_knowledge |
疾病知识,RAG核心表 | embedding(VECTOR(768))、doc_type(VARCHAR) |
knowledge_triples |
知识图谱三元组,GraphRAG预留 | subject、predicate、object |
emergency_rules |
急症拦截规则,替代硬编码 | keywords(TEXT\[\])、active(BOOLEAN) |
knowledge_tasks |
异步知识入库任务管理 | task_type、status |
3.3 schema.sql 关键代码
(1)疾病知识表(RAG核心表)
这是RAG最核心的表,每条疾病知识都带一个768维向量:
sql
-- -----------------------------------------------------------
-- 3. 疾病知识表 (disease_knowledge)
-- -----------------------------------------------------------
-- 用途:疾病知识库核心表,存储疾病的结构化信息,用于 RAG 向量检索
-- 增强分诊报告中的健康科普内容,以及 GraphRAG 的推理依据。
-- 每条记录嵌入 768 维向量,支持语义相似度检索[预留字段]。
CREATE TABLE IF NOT EXISTS disease_knowledge (
-- 主键,自增,唯一标识每种疾病
id BIGSERIAL PRIMARY KEY,
-- 疾病标准名称,如"高血压"、"偏头痛"等
name VARCHAR(200) NOT NULL,
-- 疾病的 ICD 编码,用于医学标准化和国际兼容
icd_code VARCHAR(20),
-- 疾病别名数组,如{"原发性高血压","高血压病"}
aliases TEXT[],
-- 疾病的详细描述,包含病因、病理机制、典型表现等
description TEXT,
-- 该疾病的典型症状列表,与 symptom_dictionary 中的症状关联
symptoms TEXT[],
-- 疾病对应的推荐就诊科室
department VARCHAR(100),
-- 文档类型:KNOWLEDGE_NOTE(疾病知识)/HEALTH_TIP(健康科普)/GUIDELINE/QA/DIALOGUE
-- 用于区分疾病结构化知识与预防保健类科普内容,加载到向量存储时按类型分别检索
doc_type VARCHAR(20) DEFAULT 'KNOWLEDGE_NOTE',
CHECK (doc_type IN ('KNOWLEDGE_NOTE', 'HEALTH_TIP', 'GUIDELINE', 'QA', 'DIALOGUE')),
-- 疾病严重程度分级:轻微/一般/较重/严重/危急
severity VARCHAR(20) DEFAULT '一般',
CHECK (severity IN ('轻微', '一般', '较重', '严重', '危急')),
-- 常见治疗方式概述(仅用于科普,不作为诊疗建议)
common_treatments TEXT,
-- 疾病预防建议和健康指导,用于科普输出
prevention_tips TEXT,
-- 疾病风险因素列表,如{"吸烟","肥胖","家族史"}
risk_factors TEXT[],
-- 鉴别诊断信息,列出需要与该疾病区分的其他疾病
differential_dx TEXT[],
-- 常见检查项目列表,如{"血压监测","血生化检查"}
common_tests TEXT[],
-- 疾病文本的 768 维向量嵌入,用于语义相似度检索(预留字段)
embedding VECTOR(768),
-- 记录创建时间
created_at TIMESTAMP DEFAULT NOW(),
-- 记录最后更新时间
updated_at TIMESTAMP DEFAULT NOW()
);
-- 基于疾病名称的模糊检索索引
CREATE INDEX IF NOT EXISTS idx_disease_name_trgm ON disease_knowledge USING gin(name gin_trgm_ops);
-- 基于症状数组的 GIN 索引,加速按症状查找疾病
CREATE INDEX IF NOT EXISTS idx_disease_symptoms ON disease_knowledge USING gin(symptoms);
-- 基于文档类型的 B-tree 索引,加速按 doc_type 筛选(KNOWLEDGE_NOTE / HEALTH_TIP)
CREATE INDEX IF NOT EXISTS idx_disease_doc_type ON disease_knowledge (doc_type);
-- 基于向量嵌入的 IVFFlat 近似最近邻索引,用于高效的语义检索
CREATE INDEX IF NOT EXISTS idx_disease_embedding ON disease_knowledge USING ivfflat (embedding vector_cosine_ops) WITH (lists = 50);
-- 表级注释
COMMENT ON TABLE disease_knowledge IS '疾病知识库:存储疾病的结构化信息,用于 RAG 向量检索增强分诊报告和 GraphRAG 推理依据';
COMMENT ON COLUMN disease_knowledge.name IS '疾病标准名称,如"高血压"、"偏头痛"等';
COMMENT ON COLUMN disease_knowledge.icd_code IS '疾病的 ICD 编码,用于医学标准化和国际兼容';
COMMENT ON COLUMN disease_knowledge.aliases IS '疾病别名数组,如{"原发性高血压","高血压病"}';
COMMENT ON COLUMN disease_knowledge.description IS '疾病的详细描述,包含病因、病理机制、典型表现等';
COMMENT ON COLUMN disease_knowledge.symptoms IS '该疾病的典型症状列表,与 symptom_dictionary 中的症状关联';
COMMENT ON COLUMN disease_knowledge.department IS '疾病对应的推荐就诊科室';
COMMENT ON COLUMN disease_knowledge.doc_type IS '文档类型:KNOWLEDGE_NOTE(疾病知识)/HEALTH_TIP(健康科普),用于区分疾病知识与预防保健科普内容';
COMMENT ON COLUMN disease_knowledge.severity IS '疾病严重程度分级:轻微/一般/较重/严重/危急';
COMMENT ON COLUMN disease_knowledge.common_treatments IS '常见治疗方式概述(仅用于科普,不作为诊疗建议)';
COMMENT ON COLUMN disease_knowledge.prevention_tips IS '疾病预防建议和健康指导,用于科普输出';
COMMENT ON COLUMN disease_knowledge.risk_factors IS '疾病风险因素列表,如{"吸烟","肥胖","家族史"}';
COMMENT ON COLUMN disease_knowledge.differential_dx IS '鉴别诊断信息,列出需要与该疾病区分的其他疾病';
COMMENT ON COLUMN disease_knowledge.common_tests IS '常见检查项目列表,如{"血压监测","血生化检查"}';
COMMENT ON COLUMN disease_knowledge.embedding IS '疾病文本的 768 维向量嵌入,用于语义相似度检索';
几个设计要点:
doc_type字段:这是我加的一个关键字段,用来区分"疾病知识"(KNOWLEDGE_NOTE)和"健康科普"(HEALTH_TIP)。这样TriageAgent检索疾病知识,ReportAgent检索健康科普,互不干扰。这个设计后面会专门讲,是我踩坑后的产物。embedding VECTOR(768)(预留字段,现阶段没有用到 ):768维向量,与nomic-embed-text-v2-moe模型输出维度一致。这个维度必须和模型输出维度严格一致,不一致会报错。- IVFFlat索引 :
lists = 50表示把向量空间分成50个聚类,适合当前数百到数千条记录。数据量超过10万条时需要增大lists值。

(2)急症规则表(替代硬编码)
sql
-- -----------------------------------------------------------
-- 5. 急诊规则表 (emergency_rules)
-- -----------------------------------------------------------
-- 用途:急症拦截规则配置表。系统在用户输入阶段先扫描该表,
-- 如果匹配到关键词命中急症规则,则立即中断 AI 分诊流程,
-- 直接提示用户前往急诊就诊,保障医疗安全。
-- 这是医疗分诊系统的安全边界机制。
CREATE TABLE IF NOT EXISTS emergency_rules (
id BIGSERIAL PRIMARY KEY,
-- 急症分类,如"心血管急症"、"出血急症"、"神经系统急症"等
category VARCHAR(50) NOT NULL,
-- 关键词数组,用户输入中包含任一关键词即触发该规则
keywords TEXT[] NOT NULL,
-- 命中规则后展示给用户的安全提示消息,必须包含明确的急诊建议
message TEXT NOT NULL,
-- 命中后的处理动作:BLOCK(阻断分诊流程,默认)
action VARCHAR(20) DEFAULT 'BLOCK',
-- 规则优先级(1-10),数字越小优先级越高,用于多规则排序
priority SMALLINT DEFAULT 5,
-- 规则是否启用:FALSE 时该规则不参与匹配
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
-- 基于关键词数组的 GIN 索引,加速急诊规则的关键词匹配
CREATE INDEX IF NOT EXISTS idx_emergency_keywords ON emergency_rules USING gin(keywords);
-- 部分索引:仅对启用的规则建立索引,减少不必要的扫描
CREATE INDEX IF NOT EXISTS idx_emergency_active ON emergency_rules (active) WHERE active = TRUE;
-- 表级注释
COMMENT ON TABLE emergency_rules IS '急症拦截规则:命中后立即中断 AI 分诊流程,提示用户前往急诊,是医疗分诊系统的安全边界机制';
COMMENT ON COLUMN emergency_rules.category IS '急症分类,如"心血管急症"、"出血急症"、"神经系统急症"等';
COMMENT ON COLUMN emergency_rules.keywords IS '关键词数组,用户输入中包含任一关键词即触发该规则';
COMMENT ON COLUMN emergency_rules.message IS '命中规则后展示给用户的安全提示消息,必须包含明确的急诊建议';
COMMENT ON COLUMN emergency_rules.action IS '命中后的处理动作:BLOCK(阻断分诊流程,默认)';
COMMENT ON COLUMN emergency_rules.priority IS '规则优先级(1-10),数字越小优先级越高,用于多规则排序';
COMMENT ON COLUMN emergency_rules.active IS '规则是否启用:FALSE 时该规则不参与匹配';
之前急症规则是硬编码在Java代码里的(第2篇里讲过),现在终于数据库化了。好处是改规则不用改代码重启服务,运维友好。如果数据库中加载不到急症规则,才会使用硬编码做兜底(避免数据库空数据情况)

(3)症状词典表 (标准化症状名称)
sql
-- -----------------------------------------------------------
-- 1. 症状词典表 (symptom_dictionary)
-- -----------------------------------------------------------
-- 用途:标准化症状名称、同义词、关联科室及严重程度,是智能分诊
-- 系统中症状识别与科室推荐的核心基础数据表。
-- 该表承载了从用户口语化输入到医学标准化术语的映射关系。
CREATE TABLE IF NOT EXISTS symptom_dictionary (
-- 主键,自增,唯一标识每条症状记录
id BIGSERIAL PRIMARY KEY,
-- 症状的标准医学名称,如"头痛"、"胸痛"等
name VARCHAR(200) NOT NULL,
-- 症状的标准化名称(与 name 保持一致,用于未来多语言或多版本兼容)
standard_name VARCHAR(200),
-- 口语化同义词数组,用于模糊匹配用户输入,如:{"头疼","脑袋疼","头昏"}
synonyms TEXT[],
-- 症状的详细医学描述,包含典型表现、常见原因等
description TEXT,
-- 可能对应的科室列表,用于分诊推荐,如:{"神经内科","心内科"}
possible_departments TEXT[],
-- 症状严重程度评分(1-10),10为最严重,用于评估紧急程度
severity_level SMALLINT CHECK (severity_level BETWEEN 1 AND 10),
-- 是否可能为急症标志:TRUE 时需触发特别关注或急诊拦截流程
emergency_flag BOOLEAN DEFAULT FALSE,
-- 症状常见持续时间描述,如"数分钟"、"持续数小时"等
common_duration VARCHAR(100),
-- 常见伴随症状列表,用于多症状综合分诊推理
associated_symptoms TEXT[],
-- 症状对应的身体部位,如"头部"、"胸部"、"腹部"等
body_part VARCHAR(100),
-- 症状对应的 ICD(国际疾病分类)编码数组,用于医学标准化
icd_codes VARCHAR(20)[],
-- 记录创建时间
created_at TIMESTAMP DEFAULT NOW(),
-- 记录最后更新时间
updated_at TIMESTAMP DEFAULT NOW()
);
-- 基于症状名称的模糊检索索引(trgm 相似度匹配)
CREATE INDEX IF NOT EXISTS idx_symptom_name_trgm ON symptom_dictionary USING gin(name gin_trgm_ops);
-- 基于同义词数组的 GIN 索引,加速同义词匹配查询
CREATE INDEX IF NOT EXISTS idx_symptom_synonyms ON symptom_dictionary USING gin(synonyms);
-- 基于关联科室数组的 GIN 索引,加速按科室筛选症状
CREATE INDEX IF NOT EXISTS idx_symptom_departments ON symptom_dictionary USING gin(possible_departments);
-- 部分索引:仅对急症症状建立索引,加速急诊规则扫描
CREATE INDEX IF NOT EXISTS idx_symptom_emergency ON symptom_dictionary (emergency_flag) WHERE emergency_flag = TRUE;
-- 表级注释
COMMENT ON TABLE symptom_dictionary IS '症状词典:标准化症状名称、同义词、关联科室、严重程度,是智能分诊的核心基础数据表';
COMMENT ON COLUMN symptom_dictionary.name IS '症状的标准医学名称,如"头痛"、"胸痛"等';
COMMENT ON COLUMN symptom_dictionary.standard_name IS '症状的标准化名称,与 name 保持一致,用于未来多语言或多版本兼容';
COMMENT ON COLUMN symptom_dictionary.synonyms IS '口语化同义词数组,用于模糊匹配用户输入,如:{"头疼","脑袋疼","头昏"}';
COMMENT ON COLUMN symptom_dictionary.description IS '症状的详细医学描述,包含典型表现、常见原因等';
COMMENT ON COLUMN symptom_dictionary.possible_departments IS '可能对应的科室列表,用于分诊推荐,如:{"神经内科","心内科"}';
COMMENT ON COLUMN symptom_dictionary.severity_level IS '症状严重程度评分(1-10),10为最严重,用于评估紧急程度';
COMMENT ON COLUMN symptom_dictionary.emergency_flag IS '是否可能为急症标志:TRUE 时需触发特别关注或急诊拦截流程';
COMMENT ON COLUMN symptom_dictionary.common_duration IS '症状常见持续时间描述,如"数分钟"、"持续数小时"等';
COMMENT ON COLUMN symptom_dictionary.associated_symptoms IS '常见伴随症状列表,用于多症状综合分诊推理';
COMMENT ON COLUMN symptom_dictionary.body_part IS '症状对应的身体部位,如"头部"、"胸部"、"腹部"等';
COMMENT ON COLUMN symptom_dictionary.icd_codes IS '症状对应的 ICD(国际疾病分类)编码数组,用于医学标准化';

(4)科室信息表
sql
-- -----------------------------------------------------------
-- 2. 科室信息表 (department_info)
-- -----------------------------------------------------------
-- 用途:存储医院各科室的基本信息、主要接诊症状、就诊准备建议等。
-- 为 TriageAgent 提供科室推荐的基础数据,也用于前端展示
-- 就诊指南和科室介绍。
CREATE TABLE IF NOT EXISTS department_info (
-- 主键,自增,唯一标识每个科室
id BIGSERIAL PRIMARY KEY,
-- 科室标准名称,如"心内科"、"神经内科"等,全局唯一
name VARCHAR(100) NOT NULL UNIQUE,
-- 科室别名数组,如{"心脏内科","心血管内科"},用于用户模糊查询匹配
alias TEXT[],
-- 科室简介,描述该科室的主要诊疗范围和特色
description TEXT,
-- 该科室主要接诊的症状列表,如{"胸痛","心悸","胸闷"}
main_symptoms TEXT[],
-- 该科室常见诊治的疾病列表,如{"高血压","冠心病","心律失常"}
common_diseases TEXT[],
-- 医院类型:综合/专科/中医,用于区分不同层级的医疗机构
hospital_type VARCHAR(20) DEFAULT '综合',
CHECK (hospital_type IN ('综合', '专科', '中医')),
-- 该科室常见检查项目列表,如{"心电图","心脏彩超"}
typical_tests TEXT[],
-- 就诊准备建议,如携带资料、注意事项等,直接展示给用户
preparation_tips TEXT,
-- 科室排序号,用于前端科室列表的展示顺序
sort_order INT DEFAULT 0,
-- 科室是否启用:FALSE 时该科室不纳入分诊推荐范围
active BOOLEAN DEFAULT TRUE,
-- 记录创建时间
created_at TIMESTAMP DEFAULT NOW()
);
-- 基于科室名称的模糊检索索引
CREATE INDEX IF NOT EXISTS idx_dept_name_trgm ON department_info USING gin(name gin_trgm_ops);
-- 基于主要症状数组的 GIN 索引,加速按症状查找科室
CREATE INDEX IF NOT EXISTS idx_dept_symptoms ON department_info USING gin(main_symptoms);
-- 表级注释
COMMENT ON TABLE department_info IS '科室信息:存储医院各科室的基本信息、主要接诊症状、就诊准备建议,为分诊推荐提供基础数据';
COMMENT ON COLUMN department_info.name IS '科室标准名称,如"心内科"、"神经内科"等,全局唯一';
COMMENT ON COLUMN department_info.alias IS '科室别名数组,如{"心脏内科","心血管内科"},用于用户模糊查询匹配';
COMMENT ON COLUMN department_info.description IS '科室简介,描述该科室的主要诊疗范围和特色';
COMMENT ON COLUMN department_info.main_symptoms IS '该科室主要接诊的症状列表,如{"胸痛","心悸","胸闷"}';
COMMENT ON COLUMN department_info.common_diseases IS '该科室常见诊治的疾病列表,如{"高血压","冠心病","心律失常"}';
COMMENT ON COLUMN department_info.hospital_type IS '医院类型:综合/专科/中医,用于区分不同层级的医疗机构';
COMMENT ON COLUMN department_info.typical_tests IS '该科室常见检查项目列表,如{"心电图","心脏彩超"}';
COMMENT ON COLUMN department_info.preparation_tips IS '就诊准备建议,如携带资料、注意事项等,直接展示给用户';
COMMENT ON COLUMN department_info.sort_order IS '科室排序号,用于前端科室列表的展示顺序';
COMMENT ON COLUMN department_info.active IS '科室是否启用:FALSE 时该科室不纳入分诊推荐范围';

(5)知识三元组表(预留存储知识图谱的三元组关系)
sql
-- 4. 知识三元组表 (knowledge_triples)
-- -----------------------------------------------------------
-- 用途:存储知识图谱的三元组关系(subject-predicate-object),
-- 是 GraphRAG 关系推理的核心数据。支持症状-疾病-科室的
-- 推理路径,如:(头痛, 相关疾病, 高血压) -> (高血压, 就诊科室, 心内科)。
CREATE TABLE IF NOT EXISTS knowledge_triples (
-- 主键,自增,唯一标识每个三元组
id BIGSERIAL PRIMARY KEY,
-- 三元组主体(主语),如"头痛"、"高血压"等
subject VARCHAR(200) NOT NULL,
-- 三元组谓词(关系),如"相关疾病"、"就诊科室"、"伴随症状"等
predicate VARCHAR(100) NOT NULL,
-- 三元组客体(宾语),如"心内科"、"偏头痛"等
object VARCHAR(200) NOT NULL,
-- 主体实体类型,如"症状"、"疾病"、"科室"等
subject_type VARCHAR(50),
-- 客体实体类型,如"症状"、"疾病"、"科室"等
object_type VARCHAR(50),
-- 三元组关系的置信度(0.00-1.00),用于推理时的权重计算
confidence DECIMAL(3,2) DEFAULT 1.0,
-- 数据来源,如"CMeKG"、"人工录入"等,用于溯源和可信度评估
source VARCHAR(200),
-- 记录创建时间
created_at TIMESTAMP DEFAULT NOW()
);
-- 基于主体的 B-tree 索引
CREATE INDEX IF NOT EXISTS idx_triple_subject ON knowledge_triples (subject);
-- 基于客体的 B-tree 索引
CREATE INDEX IF NOT EXISTS idx_triple_object ON knowledge_triples (object);
-- 基于谓词的 B-tree 索引
CREATE INDEX IF NOT EXISTS idx_triple_predicate ON knowledge_triples (predicate);
-- 复合索引:(主体, 谓词),用于 GraphRAG 推理中最常见的查询模式
CREATE INDEX IF NOT EXISTS idx_triple_subject_pred ON knowledge_triples (subject, predicate);
-- 表级注释
COMMENT ON TABLE knowledge_triples IS '知识图谱三元组:存储 subject-predicate-object 关系,用于 GraphRAG 的关系推理(来源:CMeKG 等医学知识图谱)';
COMMENT ON COLUMN knowledge_triples.subject IS '三元组主体(主语),如"头痛"、"高血压"等';
COMMENT ON COLUMN knowledge_triples.predicate IS '三元组谓词(关系),如"相关疾病"、"就诊科室"、"伴随症状"等';
COMMENT ON COLUMN knowledge_triples.object IS '三元组客体(宾语),如"心内科"、"偏头痛"等';
COMMENT ON COLUMN knowledge_triples.subject_type IS '主体实体类型,如"症状"、"疾病"、"科室"等';
COMMENT ON COLUMN knowledge_triples.object_type IS '客体实体类型,如"症状"、"疾病"、"科室"等';
COMMENT ON COLUMN knowledge_triples.confidence IS '三元组关系的置信度(0.00-1.00),用于推理时的权重计算';
COMMENT ON COLUMN knowledge_triples.source IS '数据来源,如"CMeKG"、"人工录入"等,用于溯源和可信度评估';

(6)知识任务表(异步处理大规模知识数据的批量导入)
sql
-- -----------------------------------------------------------
-- 6. 知识任务表 (knowledge_tasks)
-- -----------------------------------------------------------
-- 用途:知识入库任务管理表,用于异步处理大规模知识数据的批量导入。
-- 支持 PDF 文档解析、CSV 批量导入、QA 对批量导入、三元组导入等
-- 任务类型。任务状态跟踪支持监控入库进度和失败重试。
CREATE TABLE IF NOT EXISTS knowledge_tasks (
-- 主键,自增,唯一标识每个知识入库任务
id BIGSERIAL PRIMARY KEY,
-- 任务类型:PDF_INGEST(PDF解析)、CSV_IMPORT(CSV导入)、
-- QA_BATCH(QA批量导入)、TRIPLE_IMPORT(三元组导入)
task_type VARCHAR(50) NOT NULL,
CHECK (task_type IN ('PDF_INGEST', 'CSV_IMPORT', 'QA_BATCH', 'TRIPLE_IMPORT')),
-- 待处理文件的路径或存储标识符
file_path TEXT,
-- 任务状态:PENDING(待处理)/PROCESSING(处理中)/COMPLETED(已完成)/FAILED(失败)
status VARCHAR(20) DEFAULT 'PENDING',
CHECK (status IN ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED')),
-- 待处理记录总数
total_count INT DEFAULT 0,
-- 已成功处理的记录数
processed INT DEFAULT 0,
-- 处理失败的记录数
failed INT DEFAULT 0,
-- 失败原因或错误信息,用于问题排查
error_msg TEXT,
-- 任务创建时间
created_at TIMESTAMP DEFAULT NOW(),
-- 任务最后更新时间
updated_at TIMESTAMP DEFAULT NOW()
);
-- 表级注释
COMMENT ON TABLE knowledge_tasks IS '知识入库任务:异步处理 PDF/CSV/QA/三元组的批量导入,支持任务状态跟踪和失败重试';
COMMENT ON COLUMN knowledge_tasks.task_type IS '任务类型:PDF_INGEST(PDF解析)、CSV_IMPORT(CSV导入)、QA_BATCH(QA批量导入)、TRIPLE_IMPORT(三元组导入)';
COMMENT ON COLUMN knowledge_tasks.file_path IS '待处理文件的路径或存储标识符';
COMMENT ON COLUMN knowledge_tasks.status IS '任务状态:PENDING(待处理)/PROCESSING(处理中)/COMPLETED(已完成)/FAILED(失败)';
COMMENT ON COLUMN knowledge_tasks.total_count IS '待处理记录总数';
COMMENT ON COLUMN knowledge_tasks.processed IS '已成功处理的记录数';
COMMENT ON COLUMN knowledge_tasks.failed IS '处理失败的记录数';
COMMENT ON COLUMN knowledge_tasks.error_msg IS '失败原因或错误信息,用于问题排查';
3.4 种子数据说明
种子数据放在seed-data.sql里,应用启动时自动加载。规模如下:
| 数据类型 | 数量 | 说明 |
|---|---|---|
| 急症规则 | 5类 | 心血管、出血、神经、过敏、高热 |
| 科室信息 | 8个 | 心内科、神经内科、消化内科、呼吸内科、皮肤科、骨科、耳鼻喉科、眼科 |
| 症状词典 | 8条 | 头痛、胸痛、咳嗽、发热、腹痛、头晕、恶心、失眠 |
| 疾病知识 | 20条 | 覆盖8个科室的20种常见疾病 |
| 健康科普 | 若干 | HEALTH_TIP类型,用于ReportAgent |
| 知识三元组 | 39条 | 为后续GraphRAG预留 |
种子数据全部使用ON CONFLICT DO NOTHING保证幂等:
sql
-- 急症规则种子数据示例
INSERT INTO emergency_rules (category, keywords, message, priority) VALUES
('心血管急症', '{"胸痛","呼吸困难","心脏骤停","心悸","胸口压榨"}',
'您的描述中可能涉及心血管急症,请立即拨打120急救电话或前往最近的急诊科就诊。', 1),
('出血急症', '{"大出血","呕血","便血","咯血","出血不止"}',
'您的描述中可能涉及出血急症,请立即拨打120急救电话或前往最近的急诊科就诊。', 1)
ON CONFLICT DO NOTHING;
3.5 SQL初始化策略与踩坑经验
这是阶段6最大的一个坑,必须重点说。
坑1:SQL初始化模式踩坑(always vs never)

Spring Boot提供了spring.sql.init.mode配置项,有三个值:
always:每次启动都执行SQL初始化脚本embedded:仅对嵌入式数据库执行(如H2)never:从不执行SQL初始化脚本
我一开始按文档设成always,结果每次启动都重新执行schema.sql和seed-data.sql。虽然CREATE TABLE IF NOT EXISTS和ON CONFLICT DO NOTHING保证了幂等,但每次启动都跑一遍SQL,启动速度慢,日志也乱。而且seed-data.sql每次都会初始化,导致数据库中初始化数据重复。
更严重的问题是:schema.sql里的CREATE EXTENSION vector在每次启动时都会执行,如果数据库已经创建过扩展,重复执行会报错 (虽然IF NOT EXISTS能避免,但有些SQL语句不支持IF NOT EXISTS)。
最终方案 :用Docker的/docker-entrypoint-initdb.d/机制做初始化,Spring Boot的sql.init.mode设为never。
yaml
spring:
sql:
init:
# mode: never --- 生产默认值,不自动执行SQL(推荐)
# mode: always --- 开发调试时临时启用,每次启动都执行schema+data
# 开发时可通过环境变量覆盖:SPRING_SQL_INIT_MODE=always
mode: ${SPRING_SQL_INIT_MODE:never}
schema-locations: classpath:schema.sql
data-locations: classpath:seed-data.sql
continue-on-error: false
Docker的/docker-entrypoint-initdb.d/机制只在首次创建容器时执行挂载的SQL脚本,后续重启容器不会重复执行。这样既保证了初始化,又避免了重复执行。
踩坑总结 :Docker挂载初始化和Spring Boot的
sql.init二选一,不要同时启用。推荐Docker挂载方式,更可控。
docker-compose.yml
yaml
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg18
container_name: medical-triage-db
environment:
POSTGRES_DB: medical_triage
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
# PostgreSQL 首次创建容器时自动执行 /docker-entrypoint-initdb.d/ 下的脚本
# 脚本按文件名排序执行:01-schema.sql 先建表,02-seed-data.sql 后插数据
# 数据持久化在 pgdata Volume 中,后续重启容器不会重复执行
- ./medical-ai-service/src/main/resources/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
- ./medical-ai-service/src/main/resources/seed-data.sql:/docker-entrypoint-initdb.d/02-seed-data.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
坑2:Docker挂载路径踩坑

docker-compose.yml里挂载SQL文件时,路径要写对:
yaml
volumes:
- ./medical-ai-service/src/main/resources/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
- ./medical-ai-service/src/main/resources/seed-data.sql:/docker-entrypoint-initdb.d/02-seed-data.sql:ro
注意几点:
- 文件名前缀
01-、02-是为了保证执行顺序(按文件名排序执行) :ro表示只读挂载,防止容器内修改宿主机文件- 路径是相对于
docker-compose.yml所在目录的
四、Embedding配置与降级
4.1 Embedding是什么(用类比解释)
RAG的第一步是把文本变成向量 ,这个过程叫Embedding(嵌入)。
类比:想象你要把所有书按内容分类放到图书馆书架上。怎么判断两本书内容相似?你可能会看它们的主题、关键词、作者......Embedding模型就是干这个的------它把一段文本"读"一遍,输出一个768维的向量(一串768个数字),这个向量代表了文本的"语义坐标"。
两段文本的向量越接近,说明它们的语义越相似。比如"头痛"和"脑袋疼"的向量会非常接近,因为它们表达的是同一个意思。
4.2 Ollama nomic-embed-text-v2-moe 模型配置

本项目用的Embedding模型是nomic-embed-text-v2-moe,Ollama本地运行,零成本。
application.yml里的配置:
yaml
spring:
ai:
ollama:
base-url: ${OLLAMA_BASE_URL:http://localhost:11434}
embedding:
# ── Embedding模型 (用于RAG向量检索) ──
# 排名1: nomic-embed-text-v2-moe --- 768d,最新MoE架构,质量最高,957MB
# 排名2: nomic-embed-text --- 768d,经典稳定,最轻量274MB
# 排名3: embeddinggemma:300m --- 768d,Google出品,通用性好,621MB
# 排名4: mxbai-embed-large --- 1024d,质量好但维度不同,需改pgvector dimensions为1024
# ⚠️ 维度约束: 模型输出维度必须与pgvector dimensions + VECTOR()字段一致
model: ${OLLAMA_EMBEDDING_MODEL:nomic-embed-text-v2-moe}
vectorstore:
pgvector:
index-type: IVFFlat
distance-type: COSINE_DISTANCE
dimensions: 768
initialize-schema: true
几个关键配置:
| 配置项 | 值 | 说明 |
|---|---|---|
spring.ai.ollama.embedding.model |
nomic-embed-text-v2-moe |
Embedding模型名 |
spring.ai.vectorstore.pgvector.index-type |
IVFFlat |
向量索引类型,适合中等数据量 |
spring.ai.vectorstore.pgvector.distance-type |
COSINE_DISTANCE |
余弦相似度,适合语义检索 |
spring.ai.vectorstore.pgvector.dimensions |
768 |
必须与模型输出维度一致 |
spring.ai.vectorstore.pgvector.initialize-schema |
true |
自动创建vector_store表 |
启动前要先拉模型:
bash
ollama pull nomic-embed-text-v2-moe
关键约束 :
dimensions必须与Embedding模型输出维度完全一致。nomic-embed-text-v2-moe输出768维,所以dimensions是768,schema.sql里VECTOR(768)也是768。如果换成mxbai-embed-large(1024维),这三处都要同步改成1024,否则会报维度不匹配错误。
4.3 MockEmbeddingModel降级方案
不是所有时候Ollama都在跑,比如方便测试、Ollama服务挂了......这时候需要一个降级方案,让项目能继续跑(虽然检索结果是随机的)。
MockEmbeddingModel.java:
java
/**
* Mock Embedding模型
* 在Ollama不可用时提供降级方案,返回768维随机向量
*/
public class MockEmbeddingModel implements EmbeddingModel {
private static final int DIMENSIONS = 768;
private final Random random = new Random(42); // 固定种子保证可复现
@Override
public EmbeddingResponse call(EmbeddingRequest request) {
List<Embedding> embeddings = new ArrayList<>();
for (int i = 0; i < request.getInstructions().size(); i++) {
float[] vector = generateRandomVector();
embeddings.add(new Embedding(vector, i));
}
return new EmbeddingResponse(embeddings);
}
@Override
public float[] embed(Document document) {
return generateRandomVector();
}
@Override
public float[] embed(String text) {
return generateRandomVector();
}
@Override
public int dimensions() {
return DIMENSIONS;
}
private float[] generateRandomVector() {
float[] vector = new float[DIMENSIONS];
float sum = 0;
for (int i = 0; i < DIMENSIONS; i++) {
vector[i] = random.nextFloat() * 2 - 1; // -1 to 1
sum += vector[i] * vector[i];
}
// 归一化
float norm = (float) Math.sqrt(sum);
if (norm > 0) {
for (int i = 0; i < DIMENSIONS; i++) {
vector[i] /= norm;
}
}
return vector;
}
}
通过配置切换:
yaml
medical:
embedding:
# type: ollama (使用Ollama Embedding模型)
# type: mock (降级模式,返回随机向量,仅用于开发调试)
type: ${EMBEDDING_TYPE:ollama}
EmbeddingConfig.java:
java
/**
* Embedding模型配置
* 根据 medical.embedding.type 完全控制使用 Ollama 还是 Mock
*/
@Configuration
public class EmbeddingConfig {
private static final Logger log = LoggerFactory.getLogger(EmbeddingConfig.class);
@Bean
@ConditionalOnProperty(name = "medical.embedding.type", havingValue = "ollama", matchIfMissing = true)
public EmbeddingModel ollamaEmbeddingModel(
OllamaApi ollamaApi,
@Value("${spring.ai.ollama.embedding.model:nomic-embed-text-v2-moe}") String modelName) {
log.info("使用 OllamaEmbeddingModel,模型: {}", modelName);
return OllamaEmbeddingModel.builder()
.ollamaApi(ollamaApi)
.defaultOptions(OllamaOptions.builder().model(modelName).build())
.build();
}
@Bean
@ConditionalOnProperty(name = "medical.embedding.type", havingValue = "mock")
public EmbeddingModel mockEmbeddingModel() {
log.info("使用 MockEmbeddingModel(随机向量)");
return new MockEmbeddingModel();
}
}
为什么需要降级:
- 开发调试:没装Ollama的机器上也能跑项目,验证业务逻辑
- CI/CD:自动化测试环境不一定有Ollama
- 故障兜底:Ollama服务挂了,系统不至于完全不可用
注意 :Mock模式下向量检索结果是随机的,仅用于开发调试,绝对不能用于生产环境。
4.4 768维向量的含义
为什么是768维?这是模型决定的。nomic-embed-text-v2-moe模型把任意长度的文本压缩成768个浮点数,每个浮点数代表文本在某个"语义维度"上的特征。
768维够用吗?对于中小规模知识库(数千到数万条)完全够用。维度越高,语义表达越精细,但计算和存储成本也越高。768是当前主流Embedding模型的"甜点维度"------精度够、速度快、存储省。
五、Spring AI VectorStore抽象
5.1 VectorStore的设计理念
Spring AI提供了一个VectorStore接口,统一抽象了向量存储的操作。底层可以是pgvector、Milvus、Chroma......业务代码不用关心。
java
public interface VectorStore {
void add(List<Document> documents); // 添加文档
void delete(List<String> idList); // 删除文档
List<Document> similaritySearch(SearchRequest request); // 相似度检索
}
设计理念 :业务代码只跟VectorStore接口打交道,底层实现可替换。今天用pgvector,明天换Milvus,业务代码一行不用改。
5.2 PgVectorStore自动配置
引入spring-ai-ollama-spring-boot-starter和spring-ai-pgvector-store-spring-boot-starter依赖后,Spring Boot会自动配置PgVectorStore:
yaml
spring:
ai:
vectorstore:
pgvector:
index-type: IVFFlat
distance-type: COSINE_DISTANCE
dimensions: 768
initialize-schema: true # 自动创建vector_store表
initialize-schema: true会在首次启动时自动创建vector_store表,表结构由Spring AI管理,我们不用操心。
| 配置项 | 值 | 含义 |
|---|---|---|
vectorstore |
--- | Spring AI 的向量存储抽象层配置节点,用于管理文本向量的存储和检索 |
pgvector |
--- | 指定使用 PostgreSQL + pgvector 扩展 作为底层向量数据库。pgvector 是 Postgres 的官方向量相似度搜索扩展 |
index-type: IVFFlat |
IVFFlat | 向量索引类型。IVFFlat(Inverted File with Flat)是一种经典的近似最近邻(ANN)索引,通过聚类将向量空间分区,平衡了查询速度和召回率。适用于中等规模数据集 |
distance-type: COSINE_DISTANCE |
余弦距离 | 向量相似度计算方式。COSINE_DISTANCE 衡量两个向量夹角的余弦值,对向量长度不敏感,非常适合文本语义相似度比较(如 Embedding 向量的语义搜索) |
dimensions: 768 |
768 | 向量维度数。必须与上方配置的 Embedding 模型输出维度 严格一致。当前使用的是 nomic-embed-text-v2-moe,输出 768 维向量 |
initialize-schema: true |
true | 应用启动时自动初始化数据库 Schema(如创建向量表、索引等)。开发环境方便,生产环境建议关闭 |
5.3 文档切片策略
RAG有个关键问题:文档太长怎么办?
一篇医学文献可能几千字,直接塞给Embedding模型会有两个问题:
- 模型有输入长度限制
- 长文档的向量会"稀释"语义,检索精度下降
解决方案是切片:把长文档切成小块,每块独立向量化。
本项目的切片策略是500字+50字重叠:
java
/**
* 文本切片
*
* @param text 原始文本
* @param chunkSize 切片大小
* @param overlap 重叠大小
* @return 切片列表
*/
private List<String> splitText(String text, int chunkSize, int overlap) {
if (text == null || text.isBlank()) {
return List.of();
}
List<String> chunks = new ArrayList<>();
int start = 0;
while (start < text.length()) {
int end = Math.min(start + chunkSize, text.length());
chunks.add(text.substring(start, end));
start = end - overlap;
if (start >= text.length()) break;
// 防止无限循环
if (end == text.length()) break;
}
return chunks;
}
为什么需要重叠?
类比一下:你读一本书,每页500字。如果一页末尾是"高血压的预防包括",下一页开头是"低盐饮食、规律运动",你就不知道"低盐饮食"是"高血压预防"的内容。重叠50字就是让相邻两块有50字的重叠区域,避免关键信息被切断。
ps:这里先跑起来,后续再做优化:
- 按文档类型分切片策略
- 按语义/标题切片
- 按结构切片,不要跨主题硬切
typescript
原文:[==========500字==========][==========500字==========][==========500字==========]
切片:[==========500字==========]
[==========500字==========] ← 往后移450字,与上一块重叠50字
[==========500字==========] ← 同理
切片策略演进方向
当前阶段先用 500字 + 50字重叠 的固定长度切片,先把 RAG 跑通。等知识库规模变大、文档类型变多后,固定长度切片会有几个明显问题:
- 主题被切断:一个医学概念可能被切到两块里,检索时只召回半段。
- 短文档过度切片 :像
disease_knowledge这种本身就很短的记录,硬切 500 字反而把完整语义打散。 - 检索结果冗余:相邻 chunk 重叠部分语义重复,TopK 里可能经常出现大同小异的内容。
后续可以从下面几个方向逐步优化:
| 演进方向 | 核心思路 | 适用场景 | 预期收益 |
|---|---|---|---|
| 按文档类型分策略 | 短记录直接整段入库,长文档再切片 | 疾病知识、健康科普、科室信息等结构化数据 | 保留完整语义,减少无意义切片 |
| 按标题/段落切片 | 以 Markdown 标题、空行、段落为边界,不跨主题硬切 | 长科普文章、临床指南、PDF 文档 | 主题边界清晰,召回内容更完整 |
| 语义切片 | 计算相邻句子/段落的语义相似度,主题变化处切开 | 内容跳跃大的长文档、跨章节资料 | chunk 内语义一致,检索精度提升 |
| 结构化 chunk + metadata | 每个 chunk 额外带 doc_type、department、severity、section 等元数据 |
全类型知识库 | 检索时可以先过滤再召回,减少无关结果 |
| 父子块策略 | 小块用于向量召回,父块用于回答时补充上下文 | 长文档、需要丰富上下文的场景 | 兼顾召回精度和最终回答完整性 |
推荐演进路线:
text
阶段1(当前):固定长度 500+50,先把系统跑通
↓
阶段2:按文档类型分策略,短记录整段入库
↓
阶段3:长文档按标题/段落边界切片
↓
阶段4:引入语义切片和结构化 metadata
↓
阶段5:父子块 + 重排,进入生产级 RAG
先不急着改代码,当前
500+50作为基线够用。后续根据实际检索效果(是否切断了关键信息、TopK 是否重复)再决定走哪条路线。
六、KnowledgeService实现
KnowledgeService是RAG的核心服务,提供文档入库和向量检索功能。
6.1 addDocument方法(文档入库)
java
/**
* 添加文档到知识库
*
* @param title 文档标题
* @param content 文档内容
* @param docType 文档类型(GUIDELINE/QA/HEALTH_TIP/DIALOGUE/KNOWLEDGE_NOTE)
* @param department 关联科室
* @param source 来源
* @return 文档ID
*/
public String addDocument(String title, String content, String docType,
String department, String source) {
log.info("添加知识文档: title={}, docType={}, department={}", title, docType, department);
// 切片:每500字符一块,重叠50字符
List<String> chunks = splitText(content, 500, 50);
List<Document> documents = new ArrayList<>();
for (int i = 0; i < chunks.size(); i++) {
Map<String, Object> metadata = new HashMap<>();
metadata.put("title", title);
metadata.put("doc_type", docType);
metadata.put("department", department != null ? department : "");
metadata.put("source", source != null ? source : "");
metadata.put("chunk_index", i);
metadata.put("total_chunks", chunks.size());
Document doc = new Document(chunks.get(i), metadata);
documents.add(doc);
}
// 写入VectorStore(自动调用EmbeddingModel生成向量)
vectorStore.add(documents);
log.info("知识文档添加完成: title={}, 切片数={}", title, documents.size());
return title;
}
入库流程:
- 切片:把长文档切成500字+50字重叠的小块
- 附加metadata :每个切片都带上
title、doc_type、department、source等元数据,方便后续过滤检索 - 写入VectorStore :
vectorStore.add(documents)会自动调用EmbeddingModel把每个切片向量化,然后写入pgvector
metadata的作用 :后面检索时可以按doc_type或department过滤,比如只检索"心内科"的文档,或者只检索"健康科普"类型的文档。
6.2 search方法(向量检索)
java
/**
* 按科室过滤检索知识文档
*
* @param query 查询文本
* @param topK 返回结果数
* @param department 科室过滤(可选)
* @return 检索结果列表
*/
public List<KnowledgeSearchResult> search(String query, int topK, String department) {
log.debug("知识检索: query={}, topK={}, department={}", query, topK, department);
try {
SearchRequest.Builder requestBuilder = SearchRequest.builder()
.query(query)
.topK(topK);
// 按科室过滤
if (department != null && !department.isBlank()) {
FilterExpressionBuilder filterBuilder = new FilterExpressionBuilder();
requestBuilder.filterExpression(
filterBuilder.eq("department", department).build()
);
}
List<Document> results = vectorStore.similaritySearch(requestBuilder.build());
List<KnowledgeSearchResult> searchResults = results.stream()
.map(doc -> {
// 应用安全过滤:移除药品剂量和治疗方案相关内容
String content = applySafetyFilter(doc.getText());
String source = (String) doc.getMetadata().get("source");
String docType = (String) doc.getMetadata().get("doc_type");
String dept = (String) doc.getMetadata().get("department");
Double score = doc.getMetadata().get("distance") != null
? ((Number) doc.getMetadata().get("distance")).doubleValue()
: null;
return KnowledgeSearchResult.builder()
.content(content)
.source(source != null ? source : "")
.docType(docType != null ? docType : "")
.department(dept != null ? dept : "")
.score(score)
.build();
})
.collect(Collectors.toList());
log.debug("知识检索完成: 结果数={}", searchResults.size());
return searchResults;
} catch (Exception e) {
log.error("知识检索异常: {}", e.getMessage(), e);
return List.of();
}
}
检索流程:
- 构建SearchRequest:指定查询文本和topK
- 可选过滤 :按
department过滤,只检索特定科室的文档 - 向量检索 :
vectorStore.similaritySearch()会自动把查询文本向量化,然后在pgvector里找最相似的TopK文档 - 安全过滤 :对检索结果应用
applySafetyFilter,过滤掉药品剂量、治疗方案等敏感内容 - 封装结果 :把检索结果封装成
KnowledgeSearchResult返回
6.3 searchByDocType方法(按文档类型检索)
这个方法是踩坑后的产物,后面会专门讲。先看代码:
java
/**
* 按文档类型过滤检索知识文档
*
* @param query 查询文本
* @param topK 返回结果数
* @param docType 文档类型过滤(GUIDELINE/QA/HEALTH_TIP/DIALOGUE/KNOWLEDGE_NOTE)
* @return 检索结果列表
*/
public List<KnowledgeSearchResult> searchByDocType(String query, int topK, String docType) {
log.debug("知识检索(按文档类型): query={}, topK={}, docType={}", query, topK, docType);
try {
SearchRequest.Builder requestBuilder = SearchRequest.builder()
.query(query)
.topK(topK);
// 按文档类型过滤
if (docType != null && !docType.isBlank()) {
FilterExpressionBuilder filterBuilder = new FilterExpressionBuilder();
requestBuilder.filterExpression(
filterBuilder.eq("doc_type", docType).build()
);
}
List<Document> results = vectorStore.similaritySearch(requestBuilder.build());
List<KnowledgeSearchResult> searchResults = results.stream()
.map(doc -> {
String content = applySafetyFilter(doc.getText());
String source = (String) doc.getMetadata().get("source");
String docTypeMeta = (String) doc.getMetadata().get("doc_type");
String dept = (String) doc.getMetadata().get("department");
Double score = doc.getMetadata().get("distance") != null
? ((Number) doc.getMetadata().get("distance")).doubleValue()
: null;
return KnowledgeSearchResult.builder()
.content(content)
.source(source != null ? source : "")
.docType(docTypeMeta != null ? docTypeMeta : "")
.department(dept != null ? dept : "")
.score(score)
.build();
})
.collect(Collectors.toList());
log.debug("知识检索(按文档类型)完成: 结果数={}", searchResults.size());
return searchResults;
} catch (Exception e) {
log.error("知识检索(按文档类型)异常: {}", e.getMessage(), e);
return List.of();
}
}
6.4 知识安全过滤applySafetyFilter
医疗场景下,知识库的内容也要过滤。比如疾病知识里可能包含"常见治疗方式",但我们要避免直接给用户输出药品剂量和治疗方案。【ps:这里后期也需要优化,目前采用硬编码的形式,先实现功能!!!】
java
/** 安全过滤关键词 - 药品剂量 */
private static final List<String> DRUG_DOSAGE_PATTERNS = List.of(
"剂量", "用量", "mg", "ml", "每日", "每天", "服药", "服用方法"
);
/** 安全过滤关键词 - 治疗方案 */
private static final List<String> TREATMENT_PATTERNS = List.of(
"治疗方案", "手术方案", "化疗方案", "放疗方案", "处方"
);
/**
* 安全过滤:移除药品剂量和治疗方案相关内容
*/
private String applySafetyFilter(String content) {
if (content == null) return "";
String filtered = content;
// 过滤药品剂量
for (String pattern : DRUG_DOSAGE_PATTERNS) {
filtered = filtered.replaceAll(pattern + "[^。!?\\n]{0,50}", "(具体用药请遵医嘱)");
}
// 过滤治疗方案
for (String pattern : TREATMENT_PATTERNS) {
filtered = filtered.replaceAll(pattern + "[^。!?\\n]{0,50}", "(请咨询医生制定治疗方案)");
}
return filtered;
}
过滤规则:
- 药品剂量相关内容(如"剂量5mg每日三次")→ 替换为"(具体用药请遵医嘱)"
- 治疗方案相关内容(如"治疗方案为......")→ 替换为"(请咨询医生制定治疗方案)"
这样既保留了知识库的科普价值,又避免了直接给出用药建议。
6.5 KnowledgeController REST API
java
/**
* 知识管理API
*/
@RestController
@RequestMapping("/api/v1/knowledge")
@CrossOrigin(origins = "*")
public class KnowledgeController {
private final KnowledgeService knowledgeService;
public KnowledgeController(KnowledgeService knowledgeService) {
this.knowledgeService = knowledgeService;
}
/**
* 新增知识文档
* POST /api/v1/knowledge/docs
*/
@PostMapping("/docs")
public ResponseEntity<Map<String, Object>> addDocument(
@Valid @RequestBody KnowledgeDocRequest request) {
log.info("收到知识文档入库请求: title={}", request.getTitle());
String docId = knowledgeService.addDocument(
request.getTitle(),
request.getContent(),
request.getDocType(),
request.getDepartment(),
request.getSource()
);
return ResponseEntity.ok(Map.of(
"id", docId,
"status", "success",
"message", "文档入库成功"
));
}
/**
* 检索知识文档
* GET /api/v1/knowledge/search?query=xxx&topK=3&department=心内科
*/
@GetMapping("/search")
public ResponseEntity<List<KnowledgeService.KnowledgeSearchResult>> search(
@RequestParam @NotBlank String query,
@RequestParam(defaultValue = "3") int topK,
@RequestParam(required = false) String department) {
log.info("收到知识检索请求: query={}, topK={}, department={}", query, topK, department);
List<KnowledgeService.KnowledgeSearchResult> results = knowledgeService.search(query, topK, department);
return ResponseEntity.ok(results);
}
}
两个接口:
POST /api/v1/knowledge/docs:新增知识文档GET /api/v1/knowledge/search?query=xxx&topK=3&department=心内科:检索知识文档
七、知识库初始化:KnowledgeInitRunner

7.1 启动时自动加载知识到VectorStore
光把疾病知识存到disease_knowledge表还不够,还要把它们向量化后写入vector_store表,AI才能检索。这个过程由KnowledgeInitRunner在应用启动时自动完成。
java
/**
* 知识库初始化配置
* 应用启动时检查VectorStore是否已有KNOWLEDGE_NOTE和HEALTH_TIP文档,
* 若无则从数据库分别加载疾病知识和健康科普到向量存储,避免每次启动重复加载。
*/
@Configuration
public class KnowledgeInitRunner {
private static final Logger log = LoggerFactory.getLogger(KnowledgeInitRunner.class);
/**
* CommandLineRunner --- Spring Boot 特性,所有 @Bean 的 CommandLineRunner 会在应用启动完成后自动执行
args -> { ... } --- Lambda 表达式,实现 CommandLineRunner 的 run() 方法
* @param knowledgeService 知识服务
* @param diseaseKnowledgeRepository 疾病知识仓库
* @param vectorStore 向量存储
* @return
*/
@Bean
public CommandLineRunner initKnowledgeBase(
KnowledgeService knowledgeService,
DiseaseKnowledgeRepository diseaseKnowledgeRepository,
VectorStore vectorStore) {
return args -> {
log.info("检查知识库初始化状态...");
try {
// Step 1: 检查并加载 KNOWLEDGE_NOTE 类型疾病知识
loadKnowledgeNotes(knowledgeService, diseaseKnowledgeRepository, vectorStore);
// Step 2: 检查并加载 HEALTH_TIP 类型健康科普
loadHealthTips(knowledgeService, diseaseKnowledgeRepository, vectorStore);
} catch (Exception e) {
log.error("知识库初始化失败: {}", e.getMessage(), e);
// 不阻止应用启动
}
};
}
/**
* 加载KNOWLEDGE_NOTE类型疾病知识到VectorStore
* 幂等性检查:若VectorStore中已存在KNOWLEDGE_NOTE类型文档则跳过。
*/
private void loadKnowledgeNotes(KnowledgeService knowledgeService,
DiseaseKnowledgeRepository diseaseKnowledgeRepository,
VectorStore vectorStore) {
// 检查VectorStore是否已存在KNOWLEDGE_NOTE类型文档
// 若已存在,说明之前已经初始化过,直接跳过,避免重复加载
List<Document> existing = vectorStore.similaritySearch(
SearchRequest.builder().query("疾病知识").topK(5).build()
);
boolean alreadyInitialized = existing.stream()
.anyMatch(doc -> "KNOWLEDGE_NOTE".equals(doc.getMetadata().get("doc_type")));
if (alreadyInitialized) {
log.info("VectorStore中已存在KNOWLEDGE_NOTE类型文档,跳过重复初始化");
return;
}
// 从disease_knowledge表加载疾病知识(仅KNOWLEDGE_NOTE类型,排除HEALTH_TIP)到VectorStore
List<DiseaseKnowledge> diseases = diseaseKnowledgeRepository.findAll().stream()
.filter(d -> d.getDocType() == null || "KNOWLEDGE_NOTE".equals(d.getDocType()))
.toList();
if (diseases.isEmpty()) {
log.warn("disease_knowledge表中无KNOWLEDGE_NOTE类型记录,跳过知识库初始化");
return;
}
int loaded = 0;
for (DiseaseKnowledge disease : diseases) {
// 构建文档内容:疾病名称 + 描述 + 症状 + 预防建议
StringBuilder content = new StringBuilder();
content.append("疾病名称:").append(disease.getName()).append("\n");
if (disease.getDescription() != null && !disease.getDescription().isBlank()) {
content.append("描述:").append(disease.getDescription()).append("\n");
}
if (disease.getSymptoms() != null && disease.getSymptoms().length > 0) {
content.append("常见症状:").append(String.join("、", disease.getSymptoms())).append("\n");
}
if (disease.getDepartment() != null && !disease.getDepartment().isBlank()) {
content.append("就诊科室:").append(disease.getDepartment()).append("\n");
}
if (disease.getPreventionTips() != null && !disease.getPreventionTips().isBlank()) {
content.append("预防建议:").append(disease.getPreventionTips()).append("\n");
}
if (disease.getSeverity() != null && !disease.getSeverity().isBlank()) {
content.append("严重程度:").append(disease.getSeverity()).append("\n");
}
// 添加到VectorStore
knowledgeService.addDocument(
disease.getName(),
content.toString(),
"KNOWLEDGE_NOTE",
disease.getDepartment(),
"医学知识库"
);
loaded++;
}
log.info("知识库初始化完成,共加载{}条疾病知识到向量存储", loaded);
}
/**
* 加载HEALTH_TIP类型健康科普到VectorStore
* 幂等性检查:若VectorStore中已存在HEALTH_TIP类型文档则跳过。
*/
private void loadHealthTips(KnowledgeService knowledgeService,
DiseaseKnowledgeRepository diseaseKnowledgeRepository,
VectorStore vectorStore) {
// 检查VectorStore是否已存在HEALTH_TIP类型文档
List<Document> existing = vectorStore.similaritySearch(
SearchRequest.builder().query("健康科普").topK(5).build()
);
boolean alreadyInitialized = existing.stream()
.anyMatch(doc -> "HEALTH_TIP".equals(doc.getMetadata().get("doc_type")));
if (alreadyInitialized) {
log.info("VectorStore中已存在HEALTH_TIP类型文档,跳过重复初始化");
return;
}
// 从disease_knowledge表加载HEALTH_TIP类型健康科普到VectorStore
List<DiseaseKnowledge> tips = diseaseKnowledgeRepository.findByDocType("HEALTH_TIP");
if (tips.isEmpty()) {
log.warn("disease_knowledge表中无HEALTH_TIP类型记录,跳过健康科普初始化");
return;
}
int loaded = 0;
for (DiseaseKnowledge tip : tips) {
// 构建文档内容:科普标题 + 科普内容 + 适用科室 + 预防建议
// 注意:外层 ReportAgent 展示时会再加 "健康科普:" 前缀,这里不要重复添加
StringBuilder content = new StringBuilder();
content.append(tip.getName()).append("\n");
if (tip.getDescription() != null && !tip.getDescription().isBlank()) {
content.append("科普内容:").append(tip.getDescription()).append("\n");
}
if (tip.getDepartment() != null && !tip.getDepartment().isBlank()) {
content.append("适用科室:").append(tip.getDepartment()).append("\n");
}
if (tip.getPreventionTips() != null && !tip.getPreventionTips().isBlank()) {
content.append("预防建议:").append(tip.getPreventionTips()).append("\n");
}
if (tip.getSeverity() != null && !tip.getSeverity().isBlank()) {
content.append("严重程度:").append(tip.getSeverity()).append("\n");
}
// 添加到VectorStore
knowledgeService.addDocument(
tip.getName(),
content.toString(),
"HEALTH_TIP",
tip.getDepartment(),
"健康科普库"
);
loaded++;
}
log.info("健康科普初始化完成,共加载{}条HEALTH_TIP文档到向量存储", loaded);
}
}
7.2 幂等性设计(避免重复加载)
这是踩坑后的关键设计。
我一开始没做幂等性检查,每次启动都从数据库加载所有疾病知识到VectorStore。结果VectorStore里的数据越来越多------同一条疾病知识被重复加载了N次,检索时同一个结果出现N遍。
解决方案:加载前先检查VectorStore里是否已经有该类型的文档:
java
// 检查VectorStore是否已存在KNOWLEDGE_NOTE类型文档
List<Document> existing = vectorStore.similaritySearch(
SearchRequest.builder().query("疾病知识").topK(5).build()
);
boolean alreadyInitialized = existing.stream()
.anyMatch(doc -> "KNOWLEDGE_NOTE".equals(doc.getMetadata().get("doc_type")));
if (alreadyInitialized) {
log.info("VectorStore中已存在KNOWLEDGE_NOTE类型文档,跳过重复初始化");
return;
}
// 检查VectorStore是否已存在HEALTH_TIP类型文档
List<Document> existing = vectorStore.similaritySearch(
SearchRequest.builder().query("健康科普").topK(5).build()
);
boolean alreadyInitialized = existing.stream()
.anyMatch(doc -> "HEALTH_TIP".equals(doc.getMetadata().get("doc_type")));
if (alreadyInitialized) {
log.info("VectorStore中已存在HEALTH_TIP类型文档,跳过重复初始化");
return;
}
逻辑很简单:用"疾病知识"作为查询,检索Top5文档,如果其中有doc_type=KNOWLEDGE_NOTE的文档,说明已经初始化过,直接跳过。
| 代码 | 作用 |
|---|---|
vectorStore.similaritySearch(...) |
向量相似度检索:用"疾病知识"作为查询文本,搜索向量库中最相似的5条文档 |
SearchRequest builder() |
构建搜索请求的建造者模式 |
.query("疾病知识") |
设置查询文本,Spring AI 会自动将其转为向量 (embedding) |
.topK(5) |
只返回最相似的5条结果 |
.build() |
构建最终的 SearchRequest 对象 |
existing.stream().anyMatch(...) |
遍历结果,检查是否有一条文档的 doc_type 是 "KNOWLEDGE swith" |
底层SQL等价于:
sql
-- 生成 768 个零值组成的向量(仅用于测试)
SELECT id, content, metadata
FROM vector_store
ORDER BY embedding <=> array_fill(0.0, ARRAY[768])::vector
LIMIT 5;

7.3 踩坑经验:每次启动都重复加载的问题
问题现象:每次启动应用,日志都显示"知识库初始化完成,共加载20条疾病知识到向量存储"。多次启动后,VectorStore里同一条疾病知识有N份副本,检索结果重复。
原因分析 :KnowledgeInitRunner没有幂等性检查,每次启动都执行加载逻辑。vectorStore.add()是追加写入,不会去重。
解决方案:加载前检查VectorStore是否已有该类型文档(见上面代码)。
进一步思考 :如果知识库内容更新了怎么办?目前的方案是手动清空VectorStore再重启。后续可以加一个force参数,强制重新加载。
八、Agent RAG集成
知识库建好了,接下来要把RAG集成到Agent里。TriageAgent和ReportAgent都集成了RAG,但用途不同。
8.1 TriageAgent RAG集成
TriageAgent用RAG检索疾病知识,辅助分诊决策:
java
/**
* 构建分诊上下文
*/
private String buildTriageContext(ConsultationSession session, String symptomSummary) {
StringBuilder context = new StringBuilder();
context.append("【症状摘要】\n").append(symptomSummary).append("\n\n");
// RAG: 检索知识库
try {
List<KnowledgeService.KnowledgeSearchResult> knowledgeResults =
knowledgeService.search(symptomSummary, 3);
if (!knowledgeResults.isEmpty()) {
context.append("【参考知识】\n");
for (int i = 0; i < knowledgeResults.size(); i++) {
KnowledgeService.KnowledgeSearchResult result = knowledgeResults.get(i);
context.append(i + 1).append(". ").append(result.getContent());
if (result.getSource() != null && !result.getSource().isBlank()) {
context.append("(来源:").append(result.getSource()).append(")");
}
context.append("\n");
}
context.append("\n");
log.info("[TriageAgent] 检索到{}条相关知识", knowledgeResults.size());
} else {
log.debug("[TriageAgent] 未检索到相关知识,使用AI通用知识");
}
} catch (Exception e) {
log.warn("[TriageAgent] 知识检索异常,使用AI通用知识: {}", e.getMessage());
}
if (session != null && session.getMessages() != null && !session.getMessages().isEmpty()) {
context.append("【对话历史】\n");
var recentMessages = session.getLastMessages(6);
for (var msg : recentMessages) {
String role = "USER".equals(msg.getRole()) ? "患者" : "助手";
context.append(role).append(": ").append(msg.getContent()).append("\n");
}
}
return context.toString();
}
java
/**
* 按科室过滤检索知识文档
*
* @param query 查询文本
* @param topK 返回结果数
* @param department 科室过滤(可选)
* @return 检索结果列表
*/
public List<KnowledgeSearchResult> search(String query, int topK, String department) {
log.debug("知识检索: query={}, topK={}, department={}", query, topK, department);
try {
SearchRequest.Builder requestBuilder = SearchRequest.builder()
.query(query)
.topK(topK);
// 按科室过滤
if (department != null && !department.isBlank()) {
FilterExpressionBuilder filterBuilder = new FilterExpressionBuilder();
requestBuilder.filterExpression(
filterBuilder.eq("department", department).build()
);
}
List<Document> results = vectorStore.similaritySearch(requestBuilder.build());
List<KnowledgeSearchResult> searchResults = results.stream()
.map(doc -> {
// 应用安全过滤:移除药品剂量和治疗方案相关内容
String content = applySafetyFilter(doc.getText());
String source = (String) doc.getMetadata().get("source");
String docType = (String) doc.getMetadata().get("doc_type");
String dept = (String) doc.getMetadata().get("department");
Double score = doc.getMetadata().get("distance") != null
? ((Number) doc.getMetadata().get("distance")).doubleValue()
: null;
return KnowledgeSearchResult.builder()
.content(content)
.source(source != null ? source : "")
.docType(docType != null ? docType : "")
.department(dept != null ? dept : "")
.score(score)
.build();
})
.collect(Collectors.toList());
log.debug("知识检索完成: 结果数={}", searchResults.size());
return searchResults;
} catch (Exception e) {
log.error("知识检索异常: {}", e.getMessage(), e);
return List.of();
}
}
集成逻辑:
- 用症状摘要作为查询,检索Top3相关疾病知识
- 把检索到的知识拼接到提示词的
【参考知识】部分 - AI"看着"这些知识做分诊决策
容错设计:知识检索失败不影响主流程,catch住异常后继续用AI通用知识。RAG是增强,不是必需。


8.2 ReportAgent RAG集成
ReportAgent用RAG检索健康科普,丰富报告内容,并展示知识来源:
java
// RAG: 检索健康科普
try {
String searchQuery = response.getPrimaryDepartment() + " " +
(response.getReasoningSummary() != null ? response.getReasoningSummary() : "");
List<KnowledgeService.KnowledgeSearchResult> healthTips =
knowledgeService.searchByDocType(searchQuery, 3, "HEALTH_TIP");
if (!healthTips.isEmpty()) {
// Add health tips to preparation advice
List<String> currentAdvice = response.getPreparationAdvice();
if (currentAdvice == null) {
currentAdvice = new ArrayList<>();
} else {
currentAdvice = new ArrayList<>(currentAdvice);
}
for (var tip : healthTips) {
if (tip.getContent() != null && !tip.getContent().isBlank()) {
currentAdvice.add("健康科普:" + tip.getContent());
}
if (tip.getSource() != null && !tip.getSource().isBlank()) {
knowledgeSources.add(tip.getSource());
}
}
response.setPreparationAdvice(currentAdvice);
log.info("[ReportAgent] 检索到{}条健康科普", healthTips.size());
} else {
List<String> fallbackAdvice = response.getPreparationAdvice();
if (fallbackAdvice == null) {
fallbackAdvice = new ArrayList<>();
} else {
fallbackAdvice = new ArrayList<>(fallbackAdvice);
}
fallbackAdvice.add("暂无可靠科普来源");
response.setPreparationAdvice(fallbackAdvice);
log.debug("[ReportAgent] 未检索到健康科普");
}
} catch (Exception e) {
log.warn("[ReportAgent] 健康科普检索异常: {}", e.getMessage());
// 异常时也要兜底
}
// Set knowledge sources on response
response.setKnowledgeSources(knowledgeSources);
java
/**
* 按文档类型过滤检索知识文档
*
* @param query 查询文本
* @param topK 返回结果数
* @param docType 文档类型过滤(GUIDELINE/QA/HEALTH_TIP/DIALOGUE/KNOWLEDGE_NOTE)
* @return 检索结果列表
*/
public List<KnowledgeSearchResult> searchByDocType(String query, int topK, String docType) {
log.debug("知识检索(按文档类型): query={}, topK={}, docType={}", query, topK, docType);
try {
SearchRequest.Builder requestBuilder = SearchRequest.builder()
.query(query)
.topK(topK);
// 按文档类型过滤
if (docType != null && !docType.isBlank()) {
FilterExpressionBuilder filterBuilder = new FilterExpressionBuilder();
requestBuilder.filterExpression(
filterBuilder.eq("doc_type", docType).build()
);
}
List<Document> results = vectorStore.similaritySearch(requestBuilder.build());
List<KnowledgeSearchResult> searchResults = results.stream()
.map(doc -> {
String content = applySafetyFilter(doc.getText());
String source = (String) doc.getMetadata().get("source");
String docTypeMeta = (String) doc.getMetadata().get("doc_type");
String dept = (String) doc.getMetadata().get("department");
Double score = doc.getMetadata().get("distance") != null
? ((Number) doc.getMetadata().get("distance")).doubleValue()
: null;
return KnowledgeSearchResult.builder()
.content(content)
.source(source != null ? source : "")
.docType(docTypeMeta != null ? docTypeMeta : "")
.department(dept != null ? dept : "")
.score(score)
.build();
})
.collect(Collectors.toList());
log.debug("知识检索(按文档类型)完成: 结果数={}", searchResults.size());
return searchResults;
} catch (Exception e) {
log.error("知识检索(按文档类型)异常: {}", e.getMessage(), e);
return List.of();
}
}
集成逻辑:
- 用"科室+分析原因"作为查询,检索Top3健康科普(
doc_type=HEALTH_TIP) - 把健康科普内容追加到
preparationAdvice列表 - 把知识来源收集到
knowledgeSources列表,最终展示给用户 - 检索不到时兜底:添加"暂无可靠科普来源",不报错



8.3 踩坑经验:ReportAgent检索参数Bug
这是searchByDocType方法的诞生故事。
问题现象:ReportAgent检索健康科普时,经常检索到疾病知识(KNOWLEDGE_NOTE),而不是健康科普(HEALTH_TIP)。导致报告里展示的"健康科普"内容其实是疾病描述,文不对题。
原因分析 :我一开始用的是search方法,没有按doc_type过滤。VectorStore里既有KNOWLEDGE_NOTE也有HEALTH_TIP,检索时混在一起返回了。
解决方案 :新增searchByDocType方法,支持按doc_type过滤检索:
java
public List<KnowledgeSearchResult> searchByDocType(String query, int topK, String docType) {
SearchRequest.Builder requestBuilder = SearchRequest.builder()
.query(query)
.topK(topK);
// 按文档类型过滤
if (docType != null && !docType.isBlank()) {
FilterExpressionBuilder filterBuilder = new FilterExpressionBuilder();
requestBuilder.filterExpression(
filterBuilder.eq("doc_type", docType).build()
);
}
List<Document> results = vectorStore.similaritySearch(requestBuilder.build());
// ...封装结果
}
ReportAgent调用时指定docType="HEALTH_TIP",只检索健康科普类型的文档:
java
List<KnowledgeService.KnowledgeSearchResult> healthTips =
knowledgeService.searchByDocType(searchQuery, 3, "HEALTH_TIP");
经验总结 :metadata的过滤功能很重要。不同类型的文档混在一个VectorStore里时,一定要用filterExpression按类型过滤,否则检索结果会"串味"。
8.4 EmergencyRuleService数据库化
之前急症规则是硬编码在Java代码里的,现在终于数据库化了。EmergencyRuleService改为从数据库加载规则,硬编码规则作为兜底:
java
@Service
public class EmergencyRuleService {
private final EmergencyRuleRepository emergencyRuleRepository;
// 当前使用的规则(可能来自数据库或硬编码)
private List<List<String>> activeEmergencyRules;
private List<String> activeSingleKeywordEmergencies;
/**
* 硬编码急症规则模式(兜底)
*/
private static final List<List<String>> HARDCODED_EMERGENCY_RULES = List.of(
List.of("胸痛", "呼吸困难"),
List.of("意识不清"),
// ...其他规则
);
@PostConstruct
public void init() {
loadRulesFromDatabase();
}
/**
* 从数据库加载急症规则
* 如果数据库为空或不可用,则回退到硬编码规则
*/
private void loadRulesFromDatabase() {
try {
List<EmergencyRule> dbRules = emergencyRuleRepository.findByActiveTrueOrderByPriority();
if (dbRules != null && !dbRules.isEmpty()) {
List<List<String>> dbRuleLists = new ArrayList<>();
List<String> dbSingleKeywords = new ArrayList<>();
for (EmergencyRule rule : dbRules) {
String[] keywords = rule.getKeywords();
if (keywords != null && keywords.length > 0) {
dbRuleLists.add(List.of(keywords));
if (keywords.length == 1) {
dbSingleKeywords.add(keywords[0]);
}
}
}
this.activeEmergencyRules = dbRuleLists;
this.activeSingleKeywordEmergencies = dbSingleKeywords;
log.info("从数据库加载了{}条急症规则", dbRules.size());
} else {
fallbackToHardcoded("数据库中无急症规则");
}
} catch (Exception e) {
fallbackToHardcoded("数据库加载急症规则失败: " + e.getMessage());
}
}
/**
* 回退到硬编码规则
*/
private void fallbackToHardcoded(String reason) {
log.warn("{},使用硬编码规则", reason);
this.activeEmergencyRules = HARDCODED_EMERGENCY_RULES;
this.activeSingleKeywordEmergencies = HARDCODED_SINGLE_KEYWORD_EMERGENCIES;
}
/**
* 刷新规则(重新从数据库加载)
*/
public void refreshRules() {
loadRulesFromDatabase();
}
}
设计要点:
@PostConstruct初始化:Bean创建后自动从数据库加载规则- 数据库优先,硬编码兜底:数据库有规则用数据库的,数据库为空或异常用硬编码的
refreshRules()方法:支持运行时刷新规则,改了数据库后调用一下就生效,不用重启服务
九、效果演示
9.1 知识库检索API调用示例
启动应用后,先测试知识库检索接口:
bash
# 检索"头痛"相关知识
curl "http://localhost:8083/api/v1/knowledge/search?query=头痛&topK=3"
返回结果示例:
json
[
{
"content": "疾病名称:偏头痛\n描述:一种常见的慢性神经血管性疾病,表现为反复发作的偏侧搏动性头痛...\n常见症状:偏侧头痛、搏动性头痛、恶心、呕吐、畏光、畏声\n就诊科室:神经内科\n预防建议:规律作息,避免已知诱发因素...\n",
"source": "医学知识库",
"docType": "KNOWLEDGE_NOTE",
"department": "神经内科",
"score": 0.85
},
{
"content": "疾病名称:高血压\n描述:一种以动脉血压持续升高为特征的慢性疾病...\n常见症状:头痛、头晕、心悸、耳鸣、颈部僵硬\n就诊科室:心内科\n预防建议:低盐低脂饮食,每周至少150分钟中等强度运动...\n",
"source": "医学知识库",
"docType": "KNOWLEDGE_NOTE",
"department": "心内科",
"score": 0.78
},
{
"content": "疾病名称:颈椎病\n描述:因颈椎退行性变引起颈椎管或椎间孔变形、狭窄...\n常见症状:颈部疼痛、上肢麻木、头晕、头痛、肩背酸痛\n就诊科室:骨科\n预防建议:避免长时间低头,保持正确坐姿...\n",
"source": "医学知识库",
"docType": "KNOWLEDGE_NOTE",
"department": "骨科",
"score": 0.72
}
]
检索结果按相似度排序,score越高越相关。可以看到"头痛"检索到了偏头痛、高血压、颈椎病------这三种疾病都有头痛症状,检索结果很合理。
9.2 分诊API调用示例(展示知识来源)
bash
# 分诊请求
curl -X POST http://localhost:8083/api/v1/triage \
-H "Content-Type: application/json" \
-d '{"message": "我最近一直头痛,伴恶心,持续3天了"}'
返回结果(简化版):
json
{
"needEmergency": false,
"urgencyLevel": "OUTPATIENT",
"primaryDepartment": "神经内科",
"alternativeDepartments": ["心内科", "骨科"],
"confidenceScore": 0.82,
"reasoningSummary": "患者头痛伴恶心持续3天,结合知识库参考,偏头痛可能性较大...",
"preparationAdvice": [
"记录头痛发作的时间、部位和持续时间",
"就诊前避免强光刺激",
"健康科普:偏头痛的预防包括规律作息、避免已知诱发因素(如特定食物、强光、情绪波动)..."
],
"warningSigns": ["突发剧烈头痛", "伴随呕吐、意识改变"],
"knowledgeSources": ["医学知识库", "健康科普库"],
"disclaimer": "本系统仅提供就诊方向参考,不构成医疗诊断。..."
}
注意几个RAG带来的变化:
reasoningSummary里有"结合知识库参考",说明AI参考了检索到的知识preparationAdvice里有"健康科普:...",这是从HEALTH_TIP类型文档检索来的knowledgeSources字段展示了知识来源,用户可追溯
9.3 有RAG vs 无RAG的分诊结果差异
| 维度 | 无RAG | 有RAG |
|---|---|---|
| 分诊依据 | AI通用知识 | 知识库+AI通用知识 |
| 报告内容 | 泛泛建议 | 有具体科普内容 |
| 知识来源 | 无 | knowledgeSources字段展示 |
| 置信度 | 0.7左右 | 0.8+(有知识支撑) |
| 可追溯性 | 无法追溯 | 可展示来源 |
十、知识安全边界
医疗场景下,安全是第一位的。RAG引入知识库后,安全边界更复杂了,我设计了三层防护。
10.1 三层防护说明
| 防护层 | 位置 | 作用 |
|---|---|---|
| 入库过滤 | KnowledgeService.addDocument |
文档入库时过滤敏感内容 |
| 输出过滤 | KnowledgeService.applySafetyFilter |
检索结果输出时过滤药品剂量、治疗方案 |
| 急症拦截 | EmergencyRuleService |
急症关键词命中后直接拦截,不走AI |
10.2 入库过滤
文档入库时,addDocument方法会调用applySafetyFilter对内容做预处理。但更彻底的做法是在数据源头就审核------种子数据是手工整理的,确保不含药品剂量和治疗方案。
10.3 输出过滤
检索结果返回前,applySafetyFilter会过滤掉药品剂量和治疗方案相关内容:
java
// 过滤药品剂量
for (String pattern : DRUG_DOSAGE_PATTERNS) {
filtered = filtered.replaceAll(pattern + "[^。!?\\n]{0,50}", "(具体用药请遵医嘱)");
}
// 过滤治疗方案
for (String pattern : TREATMENT_PATTERNS) {
filtered = filtered.replaceAll(pattern + "[^。!?\\n]{0,50}", "(请咨询医生制定治疗方案)");
}
10.4 急症拦截
急症拦截是最后一道防线。即使用户输入触发了知识库检索,如果包含急症关键词,EmergencyRuleService会直接拦截,不走AI分诊流程。
10.5 为什么需要安全边界
医疗场景的特殊性决定了安全边界必不可少:
- 药品剂量风险:AI给出"服用阿司匹林100mg每日一次"这种建议,用户照做可能出问题
- 治疗方案风险:AI建议"建议手术治疗",用户可能误以为是医生建议
- 急症延误风险:用户描述急症症状,AI慢慢分析可能延误救治
- 责任界定:AI给出的建议如果没有"请遵医嘱"的兜底,出问题责任不清
原则:AI只能提供就诊方向参考,不能替代医生诊断。所有输出必须带免责声明,所有用药建议必须转介医生。
十一、踩坑总结
阶段6踩的坑不少,按"问题→原因→解决方案"的结构整理如下。
11.1 SQL初始化模式踩坑(always vs never)
问题 :每次启动都重新执行SQL初始化,启动慢、日志乱、CREATE EXTENSION偶尔报错。
原因 :spring.sql.init.mode设成了always,每次启动都跑schema.sql和seed-data.sql。
解决方案 :用Docker的/docker-entrypoint-initdb.d/机制做初始化(只在首次创建容器时执行),Spring Boot的sql.init.mode设为never。开发时需要重新初始化,通过环境变量SPRING_SQL_INIT_MODE=always临时启用。
11.2 VectorStore重复加载问题
问题:每次启动都从数据库加载疾病知识到VectorStore,导致VectorStore里同一条知识有N份副本,检索结果重复。
原因 :KnowledgeInitRunner没有幂等性检查,每次启动都执行加载逻辑。vectorStore.add()是追加写入,不会去重。
解决方案 :加载前检查VectorStore是否已有该类型文档(用similaritySearch检索Top5,检查metadata里是否有对应doc_type),已有则跳过。
11.3 模型额度用完的应对
问题:Ollama云端模型额度用完,Embedding模型调不动,知识库初始化失败。
原因:用的云端模型有额度限制,额度用完就调不动了。
解决方案:
- 切换本地模型:Ollama本地模型没有额度限制,切换到本地Embedding模型
- 启用Mock降级 :
medical.embedding.type=mock,用随机向量跑通业务逻辑(仅开发调试) - 模型备选方案:配置文件里列了4个Embedding模型排名,主模型不可用就换备选
11.4 ReportAgent检索参数Bug
问题:ReportAgent检索健康科普时,经常检索到疾病知识(KNOWLEDGE_NOTE),文不对题。
原因 :用的是search方法,没有按doc_type过滤。VectorStore里既有KNOWLEDGE_NOTE也有HEALTH_TIP,检索时混在一起返回了。
解决方案 :新增searchByDocType方法,支持按doc_type过滤检索。ReportAgent调用时指定docType="HEALTH_TIP",只检索健康科普类型的文档。
11.5 pgvector镜像选错
问题 :CREATE EXTENSION vector报错"extension not found"。
原因 :用了普通的postgres:16镜像,没有pgvector扩展。
解决方案 :改用pgvector/pgvector:pg18镜像,自带pgvector扩展。
11.6 维度不匹配
问题 :Embedding模型输出维度与pgvector dimensions配置不一致,报维度不匹配错误。
原因 :nomic-embed-text-v2-moe输出768维,但pgvector dimensions配成了1024。
解决方案 :三处维度必须一致------Embedding模型输出维度、spring.ai.vectorstore.pgvector.dimensions、schema.sql里VECTOR(768)。切换模型时三处同步修改。
十二、下篇预告
阶段6的RAG知识库就讲到这里。回顾一下本篇做的事:
- 环境搭建:PostgreSQL + pgvector,Docker一键部署
- 数据库设计:6张核心表,覆盖症状、科室、疾病、急症规则、知识三元组、入库任务
- Embedding配置:Ollama nomic-embed-text-v2-moe + Mock降级方案
- VectorStore抽象:Spring AI统一抽象,底层pgvector可替换
- KnowledgeService:文档入库(切片+向量化)、向量检索(按科室/按类型过滤)、安全过滤
- 知识库初始化:启动时自动加载,幂等性设计避免重复加载
- Agent RAG集成:TriageAgent检索疾病知识辅助分诊,ReportAgent检索健康科普丰富报告
- 安全边界:三层防护(入库过滤、输出过滤、急症拦截)
下一篇:阶段7------八Agent与质控回环。
阶段7要做的事:
- 扩展到8个Agent:在现有IntakeAgent、TriageAgent、ReportAgent基础上,新增SymptomExtractAgent(症状标准化)、ClarificationAgent(追问)、KnowledgeRetrievalAgent(知识检索)、QCAgent(质控)、FollowUpAgent(后续建议)
- StateGraph编排:用Spring AI Alibaba的StateGraph做流程编排,替代当前的AgentOrchestrator(自定义的手动编排)
- 质控回环:QCAgent审查分诊结果,不通过则打回TriageAgent重新决策
- GraphRAG引入 :基于
knowledge_triples表实现知识图谱推理
到时候Agent数量翻倍,编排复杂度也翻倍,质控回环更是个有意思的设计------AI审查AI的输出,不通过就打回重做。敬请期待!