本文主要结合我在MediGraph项目中的实践,介绍一个RAG系统从文档进入知识库,到最终生成可追溯回答的完整流程。
一、为什么需要RAG
直接使用大模型回答业务问题时,经常会遇到三个问题:
- 大模型不了解企业内部或垂直领域的数据;
- 模型训练数据存在时间限制,无法获取最新内容;
- 模型可能生成听起来合理、实际上没有依据的答案。
例如,在医疗问答场景中,如果直接询问模型某种药物的禁忌证,模型可能依据训练数据作答,但我们无法确认答案来自哪份指南或药品说明书。
RAG(Retrieval-Augmented Generation,检索增强生成)的思路是:
先从知识库中检索与问题相关的资料,再让大模型基于这些资料生成答案。
它不是让模型“记住”文档,而是在每次回答前为模型寻找参考资料。
二、RAG的完整流程
一个基本的RAG系统可以分成两条链路。
1. 知识库构建链路
原始文档 → 文档解析 → 文本清洗 → 文档分块 → Embedding向量化 → 写入向量数据库2. 问答链路
用户问题 → 问题向量化 → 相似度检索 → 获取Top-K相关片段 → 组装Prompt → 大模型生成答案 → 返回答案和来源知识库构建通常是离线执行的,而问题检索与答案生成发生在用户提问时。
三、第一步:解析和清洗文档
知识库的原始数据可能来自多种文件:
- PDF;
- Word;
- Markdown;
- TXT;
- 网页;
- 数据库记录。
文档进入系统后,需要先提取正文,并清理页眉、页脚、重复换行和无意义字符。
以MediGraph为例,知识库中的数据主要包括:
- 医疗指南;
- 药品说明书;
- 临床案例;
- 疾病百科和医学实体关系。
不同来源的可信度并不相同。医疗指南和正式说明书可以作为高置信证据,而百科类资料更适合用于补充知识覆盖。
因此,写入知识库时不能只保存正文,还需要保存来源信息:
metadata = { "doc_id": "guideline-001", "title": "高血压临床诊疗指南", "source_type": "medical_guideline", "year": 2024, "department": "心血管内科", }这些元数据会在后续的来源展示、过滤检索和答案审计中发挥作用。
四、第二步:为什么必须进行文档分块
大模型和Embedding模型都不能无限接收长文档,所以需要把文档切分为较小的Chunk。
最简单的方式是按照固定字符数切分:
每500个字符切一段但这种方式容易破坏语义结构。
例如,一段药品说明书可能包含:
适应证 → 推荐剂量 → 禁忌证 → 不良反应 → 注意事项如果正好在“禁忌证”的中间切断,检索到的片段就可能缺少完整语义。
在MediGraph中,我采用了递进式分块策略:
优先按照空行划分段落 → 超长段落按照句子切分 → 将短段落合并到512字符左右 → 超长内容保留50字符重叠 → 使用MD5对重复片段去重其中:
CHUNK_SIZE = 512 CHUNK_OVERLAP = 50Overlap的作用是保留相邻片段之间的上下文。
例如:
Chunk 1:……华法林可能增加出血风险,需要监测INR。 Chunk 2:需要监测INR。与阿司匹林联用时应特别注意……如果完全没有重叠,“监测INR”和“联合用药风险”可能被切到两个互不关联的片段中。
需要注意的是,512 + 50并不是适用于所有项目的固定答案。分块大小需要根据文档类型、模型上下文长度和实际检索效果调整。
五、第三步:Embedding到底做了什么
Embedding模型会把一段文字转换成一组向量。
例如:
“华法林与阿司匹林能否同时使用?”经过Embedding模型后,会变成类似下面的数值:
[0.021, -0.137, 0.452, ...]语义越接近的文本,其向量在空间中的距离通常越近。
在MediGraph中,我使用BGE-M3生成文本向量。选择它的主要原因是:
- 支持中文语义检索;
- 适合处理中英文混合的医学内容;
- 可以在本地运行;
- 能够减少医疗文本发送到外部服务的风险。
需要明确的是,Embedding不是让模型理解并回答问题,它只负责把文本转换成便于计算相似度的向量。
六、第四步:向量数据库保存什么
Embedding生成后,需要将向量、原始文本和元数据一起写入向量数据库。
在项目中,我使用ChromaDB,并按照数据类型划分Collection:
medical_guidelines drug_labels clinical_cases一条向量记录大致包含:
{ "id": "guideline-001-chunk-03", "document": "与抗凝药物联合使用时,应评估出血风险……", "embedding": [0.021, -0.137, 0.452], "metadata": { "doc_id": "guideline-001", "title": "抗凝治疗指南", "source_type": "medical_guideline", "chunk_index": 3 } }向量数据库保存的并不只是向量。
如果只保存向量而不保存正文和来源,后续即使检索到了相关结果,也无法把原始证据交给大模型,更无法向用户展示答案来源。
七、第五步:用户提问时如何检索
用户提出问题后,系统会使用相同的Embedding模型对问题进行向量化:
query_vector = embedding_model.embed( "华法林和阿司匹林能否同时使用?" )然后在ChromaDB中计算问题向量与文档向量的相似度,并取回最相关的Top-K片段。
results = collection.query( query_embeddings=[query_vector], n_results=5 )这里的Top-5并不代表五条内容一定正确,只表示它们在当前向量空间中最相似。
实际开发中,我遇到过一个典型问题:
系统确实返回了五条内容,但其中没有真正支持答案的证据。
原因可能包括:
- 知识库本身没有相关内容;
- 文档分块破坏了完整语义;
- 查询中的药品名称没有正确识别;
- 向量检索只找到了语义相似但实体不同的内容;
- Top-K设置不合理。
所以,“检索到了五条内容”不等于“检索正确”。
八、第六步:把检索结果交给大模型
获取相关片段后,需要将它们组织成Prompt。
一个简化版本如下:
你是一名医疗辅助问答助手。 请仅根据以下参考资料回答问题。 如果资料不足,请明确说明未找到足够依据。 不要编造药品信息、剂量或指南内容。 参考资料: [来源1:抗凝治疗指南] …… [来源2:阿司匹林药品说明书] …… 用户问题: 华法林和阿司匹林能否同时使用?项目中还将生成温度设置得较低:
temperature = 0.2低温度可以减少模型在相同上下文下的随机发挥,但它不能从根本上消除幻觉。
真正重要的是:
- Prompt要求模型只能依据证据回答;
- 检索片段带有来源信息;
- 没有有效证据时允许模型拒答;
- 患者数值等事实由数据库工具提供,而不是交给模型猜测。
九、第七步:为什么答案必须携带来源
如果系统只返回一段答案,用户无法判断内容是否可靠。
因此,每个检索片段都应保留:
doc_id;collection;- 文档标题;
- 来源类型;
- 原始文本。
最终结果可以同时返回答案和来源:
{ "answer": "两种药物联用可能增加出血风险,需要评估患者情况并监测相关指标。", "sources": [ { "doc_id": "guideline-001", "collection": "medical_guidelines", "title": "抗凝治疗指南" } ] }前端再通过doc_id + collection查询原始片段。
这样,RAG系统提供的就不只是一段“看起来正确”的文字,而是一条能够回到原始资料的证据链。
十、实际开发中遇到的四个问题
1. 文档数量增加,但检索效果没有明显提升
原因通常不是文档越多越好,而是新增内容存在重复、来源不明或者分类不清。
我的处理方式是:
- 使用MD5对重复Chunk去重;
- 对不同知识来源分级;
- 保存年份、科室和来源类型;
- 避免低置信百科内容覆盖正式指南。
2. 向量检索找到了相似内容,却找错了实体
医学问题中,药品名称、疾病名称和相互作用关系非常重要。
单纯向量检索可能找到“语义相似”的资料,却不一定命中正确实体。因此,后续我又加入了Neo4j图谱检索,并使用RRF融合两路结果。
这部分将在下一篇文章中单独介绍。
3. 检索为空时,模型仍然尝试回答
如果没有对空结果进行判断,大模型可能依靠自身知识继续生成答案。
项目中增加了空结果检查:
检索结果 chunks == [] → 标记结果不完整 → 尝试调整实体或切换工具 → 达到最大轮次后说明证据不足4. 无法判断优化是否真正有效
不能只看生成答案是否“读起来不错”。
RAG至少需要分别评估:
- Evidence Hit@K:正确证据是否进入前K名;
- MRR:正确证据排得是否靠前;
- 关键事实正确率;
- 引用支持率;
- 无依据回答率;
- 正确拒答率。
这些指标将在后续的RAG评测文章中详细说明。
十一、一个最小化的RAG伪代码
将整个流程压缩后,大致如下:
def build_knowledge_base(documents): for document in documents: text = parse_document(document) chunks = split_text( text, chunk_size=512, overlap=50 ) for chunk in chunks: vector = embedding_model.embed(chunk.text) vector_db.add( document=chunk.text, embedding=vector, metadata=chunk.metadata ) def answer_question(question): query_vector = embedding_model.embed(question) contexts = vector_db.search( query_vector, top_k=5 ) if not contexts: return { "answer": "当前知识库未找到足够依据", "sources": [] } prompt = build_prompt( question=question, contexts=contexts ) answer = llm.generate( prompt, temperature=0.2 ) return { "answer": answer, "sources": extract_sources(contexts) }实际项目还需要处理鉴权、异常、重试、缓存、审计、流式输出和数据权限,但RAG的核心链路基本如此。
十二、总结
一个完整的RAG系统并不是简单调用一次向量数据库,而是一条连续的数据链路:
文档质量 → 分块质量 → Embedding效果 → 检索效果 → 上下文组织 → 生成约束 → 来源追溯 → 效果评测其中任何一个环节存在问题,都可能导致最终答案不准确。
我在MediGraph项目中的一个重要认识是:
RAG效果不好时,不应该首先修改Prompt,而应该先检查知识库是否包含答案、文档是否正确分块、检索是否命中证据,以及模型最终是否严格依据证据回答。