第2375篇:医疗知识RAG——在专业领域保持高准确性的工程实践
大约 7 分钟
第2375篇:医疗知识RAG——在专业领域保持高准确性的工程实践
适读人群:在医疗健康领域做AI应用的工程师 | 阅读时长:约20分钟 | 核心价值:掌握医疗知识库的质量管理、专业术语处理和安全边界设计的工程方案
去年参与过一个医院的AI问诊辅助项目,算是我做过最谨慎的一个项目。
产品经理最初的需求说得很轻巧:"就是个知识库问答,把常见病的诊疗指南放进去,让医生助手能快速查到参考信息。"
但第一次和临床医生开会,科主任说了一句话让我印象深刻:
"你们的AI给出错误诊断建议,不是用户体验差,是可能有人死的。"
从那以后,我对医疗RAG的设计思路完全变了。这不是一个把文档装进向量库的问题,而是一个如何保证在高风险专业领域不出错的工程问题。
医疗知识RAG的安全边界设计
在写任何一行代码之前,最重要的是定义清楚系统能做什么,不能做什么。
/**
* 医疗RAG的能力边界定义
*
* 允许的能力:
* 1. 检索和展示诊疗指南原文
* 2. 解释医学术语定义
* 3. 提供用药说明书内容
* 4. 列出某疾病的常见症状(来自教材/指南)
* 5. 汇总某疾病的治疗方案选项(告知有哪些,不做推荐)
*
* 明确禁止的能力:
* 1. 基于症状给出诊断结论
* 2. 推荐具体的用药方案("你应该用XX药")
* 3. 解读用户的检查报告
* 4. 对症状的严重程度做判断
* 5. 代替医生给出任何形式的治疗建议
*
* 灰色地带(需要临床医生确认后才能做):
* 1. 药物相互作用查询
* 2. 禁忌症查询
*/医学知识库的质量管理
@Service
public class MedicalKnowledgeQualityManager {
/**
* 医学知识文档的准入审核
*
* 关键:不是所有医学文档都能进知识库
* 必须有明确的来源权威性分级
*/
public KnowledgeQualityAssessment assess(MedicalDocument doc) {
KnowledgeQualityAssessment assessment = new KnowledgeQualityAssessment();
// 来源权威性评分(0-100)
int authorityScore = assessAuthority(doc.getSource());
assessment.setAuthorityScore(authorityScore);
// 时效性评分
int freshnessScore = assessFreshness(doc.getPublishDate(), doc.getDocType());
assessment.setFreshnessScore(freshnessScore);
// 内容完整性检查
List<String> missingElements = checkRequiredElements(doc);
assessment.setMissingElements(missingElements);
// 综合判断
if (authorityScore >= 80 && freshnessScore >= 70 && missingElements.isEmpty()) {
assessment.setDecision(QualityDecision.APPROVED);
} else if (authorityScore < 60) {
assessment.setDecision(QualityDecision.REJECTED);
assessment.setRejectionReason("来源权威性不足,不符合准入标准");
} else {
assessment.setDecision(QualityDecision.NEEDS_REVIEW);
assessment.setReviewNotes(buildReviewNotes(assessment));
}
return assessment;
}
/**
* 来源权威性评分
* 医学知识有明确的证据分级体系
*/
private int assessAuthority(DocumentSource source) {
return switch (source.getType()) {
// 最高权威:国家指南、WHO指南
case NATIONAL_GUIDELINE -> 100;
case WHO_GUIDELINE -> 95;
// 高权威:系统性综述、大型RCT
case SYSTEMATIC_REVIEW -> 90;
case LARGE_RCT -> 85;
// 中等权威:医学教材、专家共识
case MEDICAL_TEXTBOOK -> 80;
case EXPERT_CONSENSUS -> 75;
// 较低权威:案例报告、个人经验
case CASE_REPORT -> 50;
case PERSONAL_EXPERIENCE -> 20;
default -> 0;
};
}
/**
* 时效性评分
* 不同类型的医学知识,有效期不同
*/
private int assessFreshness(LocalDate publishDate, DocumentType docType) {
long monthsOld = ChronoUnit.MONTHS.between(publishDate, LocalDate.now());
// 传染病防控指南:1年以上就需要审查
if (docType == DocumentType.INFECTIOUS_DISEASE_GUIDELINE) {
if (monthsOld <= 12) return 100;
if (monthsOld <= 24) return 70;
return 40;
}
// 慢性病治疗指南:3-5年更新一次是正常的
if (docType == DocumentType.CHRONIC_DISEASE_GUIDELINE) {
if (monthsOld <= 36) return 100;
if (monthsOld <= 60) return 80;
return 50;
}
// 基础医学知识:相对稳定
if (monthsOld <= 60) return 100;
if (monthsOld <= 120) return 80;
return 60;
}
}医学术语的专项处理
医学领域有大量专业术语、缩写和同义词,普通向量检索很难处理好。
@Service
public class MedicalTermNormalizer {
// 医学同义词词典(需要预先构建)
private final Map<String, List<String>> synonymDict;
// 缩写展开字典
private final Map<String, String> abbreviationDict;
/**
* 医学查询标准化
*
* 例:
* "心梗" → "心肌梗死"(AMI)
* "糖尿病" → 包含 "DM"、"T2DM"、"2型糖尿病" 等变体
*/
public NormalizedQuery normalizeQuery(String query) {
NormalizedQuery result = new NormalizedQuery(query);
// 展开缩写
String expanded = expandAbbreviations(query);
result.setExpanded(expanded);
// 找到标准术语
String standardTerm = findStandardTerm(expanded);
result.setStandardTerm(standardTerm);
// 找到相关同义词(用于扩展检索)
List<String> synonyms = findSynonyms(standardTerm != null ? standardTerm : expanded);
result.setSynonyms(synonyms);
return result;
}
/**
* 使用ICD-10/SNOMED-CT等标准编码体系标准化诊断名称
*/
public String mapToStandardCode(String diagnosisName) {
// 先尝试精确匹配
String code = icd10Dict.get(diagnosisName);
if (code != null) return code;
// 再尝试同义词匹配
for (Map.Entry<String, String> entry : icd10Dict.entrySet()) {
if (synonymDict.getOrDefault(entry.getKey(), List.of()).contains(diagnosisName)) {
return entry.getValue();
}
}
return null;
}
/**
* 构建扩展检索查询
* 一个医学查询可能需要用多个变体来确保不遗漏
*/
public List<String> buildExpandedQueries(String query) {
NormalizedQuery normalized = normalizeQuery(query);
List<String> queries = new ArrayList<>();
queries.add(query); // 原始查询
if (!normalized.getExpanded().equals(query)) {
queries.add(normalized.getExpanded());
}
if (normalized.getStandardTerm() != null) {
queries.add(normalized.getStandardTerm());
}
// 加入同义词查询(控制数量,最多3个)
normalized.getSynonyms().stream()
.filter(s -> !s.equals(query))
.limit(3)
.forEach(queries::add);
return queries;
}
}安全边界的代码实现
光有设计原则不够,必须在代码层面强制执行。
@Service
public class MedicalSafetyGuard {
private final ChatClient chatClient;
/**
* 输入过滤:检测用户是否在寻求诊断建议
*/
public SafetyCheckResult checkInput(String userQuery) {
// 快速规则检查(低成本)
SafetyCheckResult ruleResult = ruleBasedInputCheck(userQuery);
if (ruleResult.isDefinitelyUnsafe()) {
return ruleResult;
}
// 模糊情况用LLM判断
if (ruleResult.isUncertain()) {
return llmBasedInputCheck(userQuery);
}
return SafetyCheckResult.safe();
}
private SafetyCheckResult ruleBasedInputCheck(String query) {
// 明确寻求诊断的模式
List<String> diagnosisSeekingPatterns = Arrays.asList(
"我得了什么病", "这是什么病", "能不能确诊",
"我是不是患了", "帮我诊断", "我的病严不严重"
);
for (String pattern : diagnosisSeekingPatterns) {
if (query.contains(pattern)) {
return SafetyCheckResult.unsafe(
SafetyViolationType.DIAGNOSIS_SEEKING,
"检测到诊断请求"
);
}
}
// 明确寻求用药建议的模式
List<String> medicationAdvicePatterns = Arrays.asList(
"我应该吃什么药", "用什么药好", "帮我开药",
"建议我用", "推荐哪种药"
);
for (String pattern : medicationAdvicePatterns) {
if (query.contains(pattern)) {
return SafetyCheckResult.unsafe(
SafetyViolationType.MEDICATION_ADVICE,
"检测到用药建议请求"
);
}
}
return SafetyCheckResult.uncertain();
}
/**
* 输出过滤:确保生成的回答不包含诊断结论
*/
public String sanitizeOutput(String rawAnswer, String userQuery) {
// 检测答案是否包含诊断性结论
if (containsDiagnosticConclusion(rawAnswer)) {
// 不是直接拒绝输出,而是触发重新生成
return regenerateSafeAnswer(userQuery, rawAnswer);
}
// 在每个涉及症状的回答末尾加上安全提示
if (containsSymptomInfo(rawAnswer)) {
return rawAnswer + "\n\n" + getSafetyDisclaimer();
}
return rawAnswer;
}
private String getSafetyDisclaimer() {
return "⚠️ 以上内容来源于医学指南,仅供参考。具体诊断和治疗方案请遵医嘱," +
"如有不适请及时就医。";
}
private String regenerateSafeAnswer(String query, String unsafeAnswer) {
String prompt = """
以下回答包含了可能被误解为诊断意见的表述,请修改:
原回答:%s
修改要求:
1. 删除所有诊断性结论("这是XX病"、"你可能患有XX"等)
2. 改为"根据指南记载,XX症状常见于..."的表述方式
3. 保留医学知识信息,只删除主观判断
4. 末尾加上建议就医的提示
修改后的回答:
""".formatted(unsafeAnswer);
return chatClient.prompt(prompt).call().content();
}
}检索结果的置信度标注
@Service
public class MedicalAnswerConfidenceAnnotator {
/**
* 为医疗回答标注置信度和来源
*
* 医疗回答必须让用户知道:
* 1. 这个信息来自哪里(什么指南、什么级别的证据)
* 2. 这个信息有多新(发布日期)
* 3. 对哪些人群适用(成人/儿童/孕妇/老年人)
*/
public AnnotatedAnswer annotate(String answer, List<MedicalDocument> sources) {
AnnotatedAnswer annotated = new AnnotatedAnswer();
annotated.setContent(answer);
// 来源信息
List<SourceCitation> citations = sources.stream()
.map(doc -> SourceCitation.builder()
.docName(doc.getName())
.authorityLevel(doc.getAuthorityLevel().getDisplayName())
.publishDate(doc.getPublishDate())
.issuer(doc.getIssuer())
.build())
.collect(Collectors.toList());
annotated.setCitations(citations);
// 适用人群标注
String applicablePopulation = extractApplicablePopulation(sources);
annotated.setApplicablePopulation(applicablePopulation);
// 整体置信度
double confidence = calculateOverallConfidence(sources);
annotated.setConfidenceScore(confidence);
annotated.setConfidenceLevel(mapToLevel(confidence));
return annotated;
}
}关于"不能做什么"比"能做什么"更重要
做医疗RAG,最核心的设计思路是:先把边界定清楚,再想能力的事情。
很多团队的问题是倒过来做——先把功能做出来,用户反馈说有安全问题了再加护栏。医疗场景里,这个顺序是不可接受的。
从立项开始,就应该和临床医生一起定义:哪些问题这个系统绝对不能回答,哪些场景下必须强制建议用户就医。这些边界要写进代码里,不是靠Prompt就够的。
