项目实训第六篇博客 —— RAG知识库构建与实践
一、前言
在前几篇博客中,我们完成了患者端住院系统的核心功能开发,包括挂号、住院、支付等业务流程。然而,随着医疗信息化的发展,用户对智能问诊的需求日益增长。如何让系统"理解"医学知识并准确回答用户问题,成为了我们下一步的探索方向。
本篇博客将详细介绍RAG(Retrieval-Augmented Generation)医学知识库的完整构建过程。
图片位置1:在"前言"与"正文"之间插入一张RAG架构的总体流程图,展示用户提问→规则过滤→检索→Rerank→LLM生成的完整流程。
二、系统架构总览
我们的RAG知识库采用多层检索架构:
┌─────────────────────────────────────────────────────────────────┐ │ 用户输入 │ │ "糖尿病诊断标准" │ └─────────────────────────┬───────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 规则过滤器 │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │ │ 非医学拦截 │ │ 医学得分 │ │ 科室分类识别 │ │ │ └─────────────┘ └─────────────┘ └─────────────────────┘ │ └─────────────────────────┬───────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 混合检索层 │ │ ┌───────────────────┐ ┌───────────────────┐ │ │ │ FAISS 向量检索 │ │ BM25 关键词 │ │ │ │ (权重 0.6) │ │ (权重 0.4) │ │ │ └───────────────────┘ └───────────────────┘ │ │ ↓ │ │ 初步召回 Top-20 │ └─────────────────────────┬───────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ Rerank 重排 │ │ BGE-Reranker-v2-m3 二次排序 │ │ ↓ │ │ 最终 Top-5 结果 │ └─────────────────────────┬───────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ LLM 生成回答 │ └─────────────────────────────────────────────────────────────────┘系统架构总览图
三、切块策略设计
3.1 切块配置文件
切块策略在chunk_config.yaml中配置:
# 切块配置chunking:# 子块配置(主检索块)child:max_chars:800# 最大中文字符min_chars:400# 最小中文字符overlap_chars:80# 重叠字符(10%)# 父块配置(上下文扩展)parent:max_chars:3000# 最大字符aggregation:"chapter"# 必须完整保留的元素preserve_completeness:-"table"# 表格-"list"# 有序/无序列表-"formula"# 公式块-"blockquote"# 引用块3.2 核心切块逻辑
核心参数说明:
| 参数 | 值 | 说明 |
|---|---|---|
| max_chars | 800 | 单个chunk最大字符数 |
| min_chars | 400 | 单个chunk最小字符数 |
| overlap_chars | 80 | 块与块之间的重叠字符 |
classDocumentChunker:"""文档切块器"""def__init__(self,config_path:str=None):# 从配置文件加载参数chunk_config=self.config.get('chunking',{})self.max_chars=self.child_config.get('max_chars',800)self.min_chars=self.child_config.get('min_chars',400)self.overlap_chars=self.child_config.get('overlap_chars',80)defcount_chars(self,text:str)->int:"""计算中文字符数"""returnlen(re.findall(r'[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef\u0020-\u007E]',text))defis_table(self,lines:List[str])->bool:"""判断是否为表格,表格完整保留不拆分"""table_lines=[lforlinlinesif'|'inlorre.match(r'\s{2,}',l)]returnlen(table_lines)>=23.3 切块策略特点
为什么要重叠?
重叠设计确保边界信息不丢失。例如:
- Chunk1 结尾讲了一半的"糖尿病"
- Chunk2 开头继续讲"糖尿病的诊断标准"
- 重叠区域保留了这个连续性
四、模型调用实现
4.1 向量化模型配置
# 向量化配置embedding:model:"BAAI/bge-m3"dimension:1024batch_size:32device:"cpu"4.2 向量化核心代码
classEmbeddingGenerator:"""向量生成器"""def__init__(self,config_path:str=None,device:str="cpu"):self.model_name=embed_config.get('model','BAAI/bge-m3')self.dimension=embed_config.get('dimension',1024)self.batch_size=embed_config.get('batch_size',32)self.device=device self.model=Nonedefload_model(self):"""加载向量化模型"""print(f"正在加载模型:{self.model_name}")print(f"设备:{self.device}")self.model=SentenceTransformer(self.model_name,device=self.device)print(f"模型加载成功! 维度:{self.dimension}")returnTruedefgenerate_embedding(self,texts:List[str])->np.ndarray:"""生成文本向量"""embeddings=self.model.encode(texts,batch_size=self.batch_size,show_progress_bar=False,convert_to_numpy=True,normalize_embeddings=True# L2 归一化)returnembeddings4.3 模型选型理由
| 模型 | 维度 | 特点 | 选择理由 |
|---|---|---|---|
| BGE-M3 | 1024 | 多语言支持 | ✅ 中文效果好,推理速度快 |
| text2vec | 768 | 中文专用 | 中文场景也不错 |
| paraphrase-multilingual | 768 | 多语言 paraphrase | 语义理解好 |
BGE-M3 优势:
- 多语言支持:同时支持中英文医学术语
- 效果好:在 MTEB 榜单上表现优异
- 速度快:CPU 推理效率高
五、规则过滤器与Rerank部署
5.1 规则过滤器核心代码
classRuleFilter:"""规则过滤器"""def__init__(self):# 非医学关键词(直接拦截)self.non_medical_keywords=['天气','新闻','股票','电影','音乐','游戏','足球','篮球','明星','娱乐','购物','外卖','打车','旅游','酒店','餐厅','今天','明天','后天','日期','时间','几点','星期几','你好','在吗','帮我','请问','叫什么','你是谁','介绍','笑话','故事','八卦','娱乐','做饭','做菜','减肥',]# 疾病名称(高分)self.disease_keywords=['糖尿病','高血压','冠心病','心肌梗死','心衰','心律失常','肺炎','支气管炎','哮喘','COPD','肺癌','肺结核','胃炎','胃溃疡','肠炎','肝炎','肝硬化','胃癌','肠癌','肾炎','肾病','尿毒症','肾结石','脑梗','脑出血','脑卒中','癫痫','帕金森','老年痴呆',# ... 更多疾病]# 症状关键词(中高分)self.symptom_keywords=['症状','表现','体征','临床表现','阳性体征','疼痛','发热','发烧','咳嗽','呕吐','腹泻','便秘','头痛','胸痛','腹痛','腰痛','关节痛','背痛','麻木','无力','疲劳','乏力','消瘦','体重下降',# ... 更多症状]# 科室关键词(中分)self.department_keywords={'心内科':['心血管','心脏','冠心病','心梗','心律失常','心衰','高血压'],'呼吸内科':['呼吸','肺','支气管','肺炎','哮喘','咳嗽','咯血'],'消化内科':['消化','胃肠','胃','肠','肝','胆','胰腺','腹泻','便秘'],'内分泌科':['内分泌','甲状腺','糖尿病','甲亢','甲减','激素'],'神经内科':['神经','脑','脑血管','癫痫','帕金森','头痛','头晕','麻木'],# ... 更多科室}deffilter(self,query:str):"""过滤用户输入"""query=query.strip()ifnotquery:return{'pass':False,'reason':'empty','type':'empty','department':None,'suggestion':'请输入您的问题','score':0}iflen(query)<2:return{'pass':False,'reason':'too_short','type':'invalid','department':None,'suggestion':'问题太短,请详细描述','score':0}# 检查非医学关键词forkeywordinself.non_medical_keywords:ifkeywordinquery:has_medical=any(kwinqueryforkwinself.symptom_keywords+self.disease_keywords)ifnothas_medical:return{'pass':False,'reason':'non_medical','type':'non_medical','department':None,'suggestion':'这是医学知识库,请问您有医学相关的问题吗?','score':0}# 计算得分score=self._calculate_score(query)ifscore>=2:department=self._identify_department(query)return{'pass':True,'reason':'medical','type':'medical','department':department,'suggestion':None,'score':score}else:return{'pass':False,'reason':'low_score','type':'unclear','department':None,'suggestion':'您的问题可能与医学无关,请描述具体的症状或疾病','score':score}def_calculate_score(self,query:str)->int:"""计算医学得分"""score=0forkeywordinself.disease_keywords:ifkeywordinquery:score+=3# 疾病名称 3分forkeywordinself.symptom_keywords:ifkeywordinquery:score+=2# 症状关键词 2分fordept,keywordsinself.department_keywords.items():forkeywordinkeywords:ifkeywordinquery:score+=1# 科室关键词 1分breakreturnscoredef_identify_department(self,query:str):"""识别科室"""scores={}fordept,keywordsinself.department_keywords.items():score=sum(1forkwinkeywordsifkwinquery)ifscore>0:scores[dept]=scorereturnmax(scores,key=scores.get)ifscoreselseNone得分机制:
| 关键词类型 | 得分 | 示例 |
|---|---|---|
| 疾病名称 | 3分 | 糖尿病、高血压、冠心病 |
| 症状关键词 | 2分 | 胸痛、发热、咳嗽 |
| 科室关键词 | 1分 | 心内科、呼吸内科 |
5.2 过滤流程图
用户输入 │ ├─→ 空? ──→ 拦截 "请输入您的问题" │ ├─→ 包含非医学词? ──→ 无医学词? ──→ 拦截 "这是医学知识库..." │ └─→ 计算医学得分 │ ├─→ 得分 >= 2 ──→ 通过 └─→ 得分 < 2 ──→ 拦截 "您的问题可能与医学无关..."
无关知识会被过滤
5.3 Rerank模型部署
classReranker:"""Rerank 重排器"""def__init__(self):self.model=Noneself.model_name='BAAI/bge-reranker-v2-m3'defload(self):"""加载 Rerank 模型"""print(f"加载 Rerank 模型:{self.model_name}")try:self.model=CrossEncoder(self.model_name,max_length=512,device='cpu')print("Rerank 模型加载成功!")returnTrueexceptExceptionase:print(f"Rerank 模型加载失败:{e}")self.model=NonereturnFalsedefrerank(self,query,chunks,top_k=5):"""对 chunks 进行重排"""ifnotself.modelornotchunks:returnchunks# 构建 query-document 对pairs=[(query,chunk.get('text',''))forchunkinchunks]# 计算相关性分数scores=self.model.predict(pairs)# 添加分数到 chunksfori,chunkinenumerate(chunks):chunk['rerank_score']=float(scores[i])# 按 rerank 分数排序reranked=sorted(chunks,key=lambdax:x['rerank_score'],reverse=True)returnreranked[:top_k]5.4 Rerank 原理
初步检索结果(按向量相似度) ┌─────────────────────────────────────┐ │ 1. 糖尿病的诊断标准 [0.92] │ │ 2. 血糖测定方法 [0.91] │ │ 3. 高血压诊断 [0.89] │ │ 4. 糖尿病治疗方案 [0.88] │ │ 5. 今天天气怎么样 [0.85] ← 误召 │ └─────────────────────────────────────┘ ↓ Rerank 重排 ┌─────────────────────────────────────┐ │ 1. 糖尿病的诊断标准 [0.98] ↑ │ │ 2. 糖尿病治疗方案 [0.95] ↑ │ │ 3. 血糖测定方法 [0.91] ↓ │ │ 4. 高血压诊断 [0.78] ↓ │ │ 5. 今天天气怎么样 [0.12] ↓ 被降 │ └─────────────────────────────────────┘六、检索策略实现
6.1 索引构建核心代码
classIndexBuilder:"""索引构建器"""def__init__(self,config_path:str=None):self.method=self.retrieval_config.get('method','hybrid')self.dense_weight=self.retrieval_config.get('dense_weight',0.6)self.bm25_weight=self.retrieval_config.get('bm25_weight',0.4)self.top_k=self.retrieval_config.get('top_k',20)defbuild_faiss_index(self,vectors:np.ndarray)->faiss.Index:"""构建 FAISS 向量索引"""dimension=vectors.shape[1]print(f"构建 FAISS 索引,维度:{dimension}")# 使用内积索引(需要归一化向量)index=faiss.IndexFlatIP(dimension)index.add(vectors.astype('float32'))print(f"FAISS 索引构建完成,向量数:{index.ntotal}")returnindexdefbuild_bm25_index(self,texts:List[str])->BM25Okapi:"""构建 BM25 索引"""# 中文分词(使用简单空格分词)tokenized_texts=[text.split()fortextintexts]bm25=BM25Okapi(tokenized_texts)returnbm256.2 检索配置
# 检索配置retrieval:method:"hybrid"# hybrid / dense / bm25dense_weight:0.6# 向量检索权重bm25_weight:0.4# BM25 权重top_k:20# 初步召回数量# Rerank 配置rerank:enabled:truemodel:"BAAI/bge-reranker-v2-m3"top_k:5# Rerank 后保留数量6.3 检索流程详解
classRecallTester:"""召回测试器"""defsearch(self,query,top_k=5,use_rerank=True):"""搜索相关 chunks"""results=[]# 1. 向量检索 (FAISS)ifself.modelandself.index:query_vector=self.model.encode([query],normalize_embeddings=True)distances,indices=self.index.search(query_vector.astype('float32'),top_k*4# 初步召回更多结果)foridx,distinzip(indices[0],distances[0]):ifidx>=0andidx<len(self.chunks):chunk=self.chunks[idx].copy()chunk['vector_score']=float(dist)results.append(chunk)# 2. Rerank 重排ifuse_rerankandresults:results=self.reranker.rerank(query,results,top_k=top_k)returnresults[:top_k]检索三步曲:
1. 向量检索 → 召回 Top-20 2. Rerank重排 → 精排 Top-5 3. 返回结果6.4 混合检索融合
``
`
七、完整调用流程
defmain():tester=RecallTester()# 加载知识库tester.load()# 用户输入query=input("请输入问题: ")# 规则过滤result=tester.filter.filter(query)ifnotresult['pass']:print(f"拦截:{result['suggestion']}")return# 知识库检索results=tester.search(query,top_k=5,use_rerank=True)# 打印结果tester.print_results(results)九、总结与展望
- 🔄 LLM 接入与 Prompt 优化
- 🔄 上下文窗口管理
- 🔄 知识库自我更新
- 🔄 对话历史管理(短期/长期记忆)