AI医疗分诊与健康咨询助手agent开发——(4-2)从零到一:我给AI医疗分诊助手加了个“知识库大脑“,终于搞懂了RAG是什么

前几篇我们搞定了三Agent编排,IntakeAgent问诊、TriageAgent分诊、ReportAgent出报告,链路跑通了,效果也还行。但跑着跑着我就发现一个问题:Agent再聪明,没有知识库也是巧妇难为无米之炊。用户问"高血压该挂什么科",AI只能凭"训练时学过的知识"回答,没法引用具体的医学文献;用户问"偏头痛怎么预防",AI给的建议很泛,没有权威来源支撑。

这一篇就解决这个痛点------给AI接一个"知识库大脑",让它回答问题时有据可查、有源可溯。这就是RAG(Retrieval Augmented Generation,检索增强生成)。


前篇回顾:

(3)从零到一,终于搞懂了 Agent 是什么?

(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的理由

  1. 一个库搞定三件事:本项目既要存关系数据(科室、症状、急症规则),又要存向量数据(疾病知识embedding),还要做模糊检索(症状名匹配)。pgvector一个库全包了,不用再引入Milvus或ES。
  2. 部署维护省事:Docker拉一个镜像就行,不用单独运维一个向量数据库。
  3. 规模够用:本项目知识库规模在数千到数十万条,pgvector完全扛得住。等真到了百万级再换Milvus也不迟。
  4. 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的图形界面来管理容器,直观又省事。下面是几个常用操作:

  1. 查看容器运行状态 :打开Docker Desktop,进入Containers 面板,可以看到medical-triage-db容器的运行状态(绿色表示运行中)、端口映射(5432:5432)和健康检查结果,比敲docker ps直观多了。

  2. 查看容器日志 :点击容器名进入详情,切换到Logs 标签页,可以直接看到PostgreSQL的启动日志。重点找/docker-entrypoint-initdb.d/下脚本的执行记录------如果看到01-schema.sql02-seed-data.sql执行无报错,说明表结构和种子数据初始化成功。

  3. 重启/停止容器 :在Containers面板里,每个容器右侧都有重启(↻)、停止(■)按钮。改了docker-compose.yml后,点重启即可生效,不用反复敲命令。

  4. 查看挂载的卷和数据 :进入Volumes 面板,找到pgdata卷,可以查看数据持久化的存储情况。如果想彻底重置数据库(比如改了schema要重新初始化),删掉这个卷再重启容器,初始化脚本会重新执行。

小贴士 :界面化操作最大的好处是------排查问题时不用记一堆docker logsdocker 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预留 subjectpredicateobject
emergency_rules 急症拦截规则,替代硬编码 keywords(TEXT\[\])、active(BOOLEAN)
knowledge_tasks 异步知识入库任务管理 task_typestatus

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.sqlseed-data.sql。虽然CREATE TABLE IF NOT EXISTSON 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.sqlVECTOR(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();
    }
}

为什么需要降级

  1. 开发调试:没装Ollama的机器上也能跑项目,验证业务逻辑
  2. CI/CD:自动化测试环境不一定有Ollama
  3. 故障兜底: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-starterspring-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模型会有两个问题:

  1. 模型有输入长度限制
  2. 长文档的向量会"稀释"语义,检索精度下降

解决方案是切片:把长文档切成小块,每块独立向量化。

本项目的切片策略是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 跑通。等知识库规模变大、文档类型变多后,固定长度切片会有几个明显问题:

  1. 主题被切断:一个医学概念可能被切到两块里,检索时只召回半段。
  2. 短文档过度切片 :像 disease_knowledge 这种本身就很短的记录,硬切 500 字反而把完整语义打散。
  3. 检索结果冗余:相邻 chunk 重叠部分语义重复,TopK 里可能经常出现大同小异的内容。

后续可以从下面几个方向逐步优化:

演进方向 核心思路 适用场景 预期收益
按文档类型分策略 短记录直接整段入库,长文档再切片 疾病知识、健康科普、科室信息等结构化数据 保留完整语义,减少无意义切片
按标题/段落切片 以 Markdown 标题、空行、段落为边界,不跨主题硬切 长科普文章、临床指南、PDF 文档 主题边界清晰,召回内容更完整
语义切片 计算相邻句子/段落的语义相似度,主题变化处切开 内容跳跃大的长文档、跨章节资料 chunk 内语义一致,检索精度提升
结构化 chunk + metadata 每个 chunk 额外带 doc_typedepartmentseveritysection 等元数据 全类型知识库 检索时可以先过滤再召回,减少无关结果
父子块策略 小块用于向量召回,父块用于回答时补充上下文 长文档、需要丰富上下文的场景 兼顾召回精度和最终回答完整性

推荐演进路线

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;
}

入库流程

  1. 切片:把长文档切成500字+50字重叠的小块
  2. 附加metadata :每个切片都带上titledoc_typedepartmentsource等元数据,方便后续过滤检索
  3. 写入VectorStorevectorStore.add(documents)会自动调用EmbeddingModel把每个切片向量化,然后写入pgvector

metadata的作用 :后面检索时可以按doc_typedepartment过滤,比如只检索"心内科"的文档,或者只检索"健康科普"类型的文档。

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();
    }
}

检索流程

  1. 构建SearchRequest:指定查询文本和topK
  2. 可选过滤 :按department过滤,只检索特定科室的文档
  3. 向量检索vectorStore.similaritySearch()会自动把查询文本向量化,然后在pgvector里找最相似的TopK文档
  4. 安全过滤 :对检索结果应用applySafetyFilter,过滤掉药品剂量、治疗方案等敏感内容
  5. 封装结果 :把检索结果封装成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();
        }
    }

集成逻辑

  1. 用症状摘要作为查询,检索Top3相关疾病知识
  2. 把检索到的知识拼接到提示词的【参考知识】部分
  3. 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();
        }
    }

集成逻辑

  1. 用"科室+分析原因"作为查询,检索Top3健康科普(doc_type=HEALTH_TIP
  2. 把健康科普内容追加到preparationAdvice列表
  3. 把知识来源收集到knowledgeSources列表,最终展示给用户
  4. 检索不到时兜底:添加"暂无可靠科普来源",不报错

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();
    }
}

设计要点

  1. @PostConstruct初始化:Bean创建后自动从数据库加载规则
  2. 数据库优先,硬编码兜底:数据库有规则用数据库的,数据库为空或异常用硬编码的
  3. 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 为什么需要安全边界

医疗场景的特殊性决定了安全边界必不可少:

  1. 药品剂量风险:AI给出"服用阿司匹林100mg每日一次"这种建议,用户照做可能出问题
  2. 治疗方案风险:AI建议"建议手术治疗",用户可能误以为是医生建议
  3. 急症延误风险:用户描述急症症状,AI慢慢分析可能延误救治
  4. 责任界定:AI给出的建议如果没有"请遵医嘱"的兜底,出问题责任不清

原则:AI只能提供就诊方向参考,不能替代医生诊断。所有输出必须带免责声明,所有用药建议必须转介医生。


十一、踩坑总结

阶段6踩的坑不少,按"问题→原因→解决方案"的结构整理如下。

11.1 SQL初始化模式踩坑(always vs never)

问题 :每次启动都重新执行SQL初始化,启动慢、日志乱、CREATE EXTENSION偶尔报错。

原因spring.sql.init.mode设成了always,每次启动都跑schema.sqlseed-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模型调不动,知识库初始化失败。

原因:用的云端模型有额度限制,额度用完就调不动了。

解决方案

  1. 切换本地模型:Ollama本地模型没有额度限制,切换到本地Embedding模型
  2. 启用Mock降级medical.embedding.type=mock,用随机向量跑通业务逻辑(仅开发调试)
  3. 模型备选方案:配置文件里列了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.dimensionsschema.sqlVECTOR(768)。切换模型时三处同步修改。


十二、下篇预告

阶段6的RAG知识库就讲到这里。回顾一下本篇做的事:

  1. 环境搭建:PostgreSQL + pgvector,Docker一键部署
  2. 数据库设计:6张核心表,覆盖症状、科室、疾病、急症规则、知识三元组、入库任务
  3. Embedding配置:Ollama nomic-embed-text-v2-moe + Mock降级方案
  4. VectorStore抽象:Spring AI统一抽象,底层pgvector可替换
  5. KnowledgeService:文档入库(切片+向量化)、向量检索(按科室/按类型过滤)、安全过滤
  6. 知识库初始化:启动时自动加载,幂等性设计避免重复加载
  7. Agent RAG集成:TriageAgent检索疾病知识辅助分诊,ReportAgent检索健康科普丰富报告
  8. 安全边界:三层防护(入库过滤、输出过滤、急症拦截)

下一篇:阶段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的输出,不通过就打回重做。敬请期待!

相关推荐
汪汪大队u2 小时前
【无标题】
运维·docker·kubernetes·prometheus
怎么比啊都是大神3 小时前
关于==null、isEmpty()和StrUtil.isBlank()
java
唐青枫3 小时前
Java Neo4j 实战指南:从图模型、Cypher 到 Spring Boot 关系查询
java
观远数据3 小时前
从离线开发到实时同步:DataFlow如何支撑企业级数据治理闭环
java·windows·microsoft·excel
sugar__salt3 小时前
Document 切割:RAG 知识库数据预处理实战
javascript·langchain·embedding·js·rag·cheerio
今天AI了吗4 小时前
Hermes Agent 搭建全流程:从本机试跑到可持续运行的个人 AI Agent
java·人工智能·python·学习·embedding
春卷同学4 小时前
032-关系型数据库在记账应用中的建模与查询优化
java·jvm·数据库
不平衡的叉叉树4 小时前
Springboot+Mockito简单使用单元测试
java·spring boot·单元测试
smartfish_liu4 小时前
LLM-笨笨的agent 闭环沙盒任务测试: 把{private}/LLM/story/I_am_back.md发表到博客网
agent