引言:从“博闻强记”到“博学多才”
在人工智能的发展历程中,大语言模型(LLM)已经展现了惊人的“博闻强记”能力——它们能写诗、编码、解答常识问题,甚至模拟人类对话。然而,当面对专业领域知识或实时更新的信息时,这些模型往往会暴露其局限性:要么“一本正经地胡说八道”,要么无奈承认“我不知道”,此时RAG就要闪亮登场
一、RAG概述
检索增强生成(Retrieval-Augmented Generation, RAG)是当前AI领域最前沿的技术范式之一,它通过结合信息检索与生成模型的优势,有效解决传统大语言模型(LLM)的三大痛点:知识更新滞后、事实准确性不足和领域适应能力有限
1.1 流程概述
1、文档预处理 & 向量化存储
将生产数据加工为文档(Documents),通过嵌入模型(Embeddings Model)将文档内容转换为向量(Vector Embeddings),向量数据存入向量数据库(Vector Database)向量化后的数据能保留语义信息,便于后续相似性检索,常见嵌入模型:OpenAI text-embedding, BERT, Sentence-BERT等
2、用户查询向量化
用户自然语言查询(User Query)使用相同的Embeddings Model将查询文本转换为向量,注意必须与文档处理使用同一模型,确保向量空间一致性
3、语义检索相关文档
计算用户查询向量与向量库中所有文档向量的相似度(如余弦相似度)返回相似度最高的Top K文档作为Relevant docs
4、生成增强的LLM输入
将User Query和检索到的Relevant docs合并按预设模板格式化为LLM的输入提示(Prompt)
5、大模型生成输出
大模型(LLM)接收组合后的输入基于检索到的文档上下文生成回答(Output),相比纯生成模型,减少幻觉(Hallucination)API对LLM的原始输出进行后处理(如JSON格式化),返回结构化的 Response 给用户
1.2 名词解释
上述流程中涉及到一些名词稍做解释
文档(Documents):由生产数据(如数据库记录、PDF/Word文件、网页内容等)经过清洗提取、结构化处理后生成的文本集合,示例:企业内部的FAQ、产品手册、技术文档等
嵌入模型(Embeddings Model):将文本(用户查询或文档)转换为数值向量的深度学习模型(如BERT、OpenAI Embeddings)
向量(Vector):文本通过嵌入模型转换后的数值数组,示例:句子"机器学习" → [0.12, -0.45, …, 0.78](具体数值由模型决定);相似文本的向量余弦相似度接近1(如"猫"和"犬科动物"),支持向量加减(如"国王-男+女≈女王")
相似度(Similarity):量化两个向量之间关联程度的数学指标,常用算法:余弦相似度(语义相似性)、欧氏距离(向量空间距离)
向量数据库(Vector Database):是一种专门用于存储、索引和检索向量数据(Vector Embeddings)的数据库系统。与传统数据库(如MySQL、MongoDB)不同,它针对高维向量的相似性搜索进行了优化,能够快速找到与查询向量最接近的数据,通常每个向量有数百至数千个维度,同时可关联原始文本(Documents)及其元数据(如来源、时间),支持近似最近邻搜索(ANN, Approximate Nearest Neighbor),即使在海量数据中也能毫秒级返回相似结果
1.3 示例
使用SimpleVectorStore
开启一个简单示例,SimpleVectorStore
是轻量级VectorStore
接口实现,适用于原型开发或小规模向量存储场景,提供基础的相似性搜索能力,方便快速验证RAG流程
1.3.1 构造向量数据
该步骤流程为将用户文件加工为Documents,然后将Documents写入VectorStore
生产数据: 在网络上找了一份"菜鸟驿站站点合作协议.txt"的文档模拟为真实生成数据,放入resources/doc目录
代码处理:
- 通过
EmbeddingModel
构造SimpleVectorStore
,(EmbeddingModel
会自动注入(在源码浅析章节中就提到过自动装配),使用的是OpenAI的模型(DeepSeek不支持) - 将文件通过
TextReader
处理为List<Document>
,保存到SimpleVectorStore
中
@Slf4j
@Configuration
public class SimpleVectorStoreLoader {@Value("classpath:/doc/菜鸟驿站站点合作协议.txt")private Resource faq;@Beanpublic SimpleVectorStore mySimpleVectorStore(EmbeddingModel embeddingModel) throws IOException {SimpleVectorStore simpleVectorStore = SimpleVectorStore.builder(embeddingModel).build();File dbFile = getSimpleVectorStoreFile("simple_db_file.json");if (dbFile.exists()) {//文件存在,直接加载数据即可simpleVectorStore.load(dbFile);return simpleVectorStore;}Files.createFile(dbFile.toPath());//将文件转换为DocumentsTextReader textReader = new TextReader(faq);textReader.getCustomMetadata().put("fileName", "菜鸟驿站站点合作协议.txt");List<Document> documentList = textReader.get();TokenTextSplitter tokenTextSplitter = new TokenTextSplitter();List<Document> splitDocuments = tokenTextSplitter.apply(documentList);//写入dbsimpleVectorStore.add(splitDocuments);simpleVectorStore.save(dbFile);return simpleVectorStore;}/*** 构建DB文件,由于第一次执行时,文件不存在,因此做路径拼接** @return*/private File getSimpleVectorStoreFile(String dbFileName) {Path path = Paths.get("src", "main", "resources", "data");String absolutePath = path.toAbsolutePath() + File.separator + dbFileName;return new File(absolutePath);}}
1.3.2 RAG查询
信息检索:
- 通过用户输入与DB中的数据做相似性检索
- topK(3): 返回最相似的3个结果
private List<String> findSimilarDocuments(String message) {List<Document> similarDocuments = mySimpleVectorStore.similaritySearch(SearchRequest.builder().query(message).topK(3).build());return similarDocuments.stream().map(Document::getText).collect(Collectors.toList());}
例如输入 “和菜鸟合作驿站会有哪些权利”,结果如下:
组合查询:
- 提示词中documents为占位符,将相似性检索的结果作为数据填充完善提示词内容,然后想LLM发起调用,大模型会利用documents的内容做总结返回
@GetMapping("/rag")public String rag(@RequestParam(value = "input", defaultValue = "和菜鸟合作驿站会有哪些权利") String input) {PromptTemplate promptTemplate = new PromptTemplate("""你是一个驿站站点合作协议答疑助手,请结合文档提供的内容回答用户的问题,如果不知道请直接回答不知道用户输入的问题:{input}文档:{documents}""");String relevantDocs = String.join("\n", findSimilarDocuments(input));log.info("用户问题={},查询结果={}", input, relevantDocs);Map<String, Object> params = new HashMap<>();params.put("input", input);params.put("documents", relevantDocs);return chatClient.prompt(new Prompt(promptTemplate.render(params))).call().content();}
输出:
根据文档内容,与菜鸟合作驿站所享有的权利包括:
在协议合法签署并生效后,您有权要求菜鸟通过技术手段将您添加至驿站系统(6.2)。
您有权在协议内使用菜鸟的公司商标、标识等资料;同时,菜鸟不得未经您书面许可,将这些知识产权资料用于与服务站项目无关的其他用途(6.3)。
您可以参与菜鸟系统发布的其他业务活动,如扫码购、业务激励赛、品牌落地推广、拼团等(3.3其他业务合作流程)。
以上,已展示一个RAG的示例,其中重要的一环是将生产数据转换为Documents后存储到向量数据库,通常称为ETL过程,接下来会详细介绍Spring AI中ETL的能力
二、ETL
ETL(提取、转换、加载) 框架是数据处理的核心,负责将原始数据转化为适合 AI 模型检索的优化格式。Spring AI的ETL(Extract-Transform-Load)框架为开发者提供了标准化数据处理的完整工具链,能够将PDF、HTML、Markdown等异构数据源转换为适合大模型检索的向量数据
- 提取(Extract):从多种数据源(PDF、HTML、数据库等)获取原始内容
- 转换(Transform):清洗、分块、增强元数据,优化检索效果
- 加载(Load):存储到向量数据库(如 Pinecone、ChromaDB)
Spring AI的ETL框架遵循经典数据处理范式,核心接口定义如下:
// 数据抽取
public interface DocumentReader extends Supplier<List<Document>> {default List<Document> read() { return get(); }
}// 数据转换
public interface DocumentTransformer extends Function<List<Document>, List<Document>> {default List<Document> transform(List<Document> docs) { return apply(docs); }
}// 数据加载
public interface DocumentWriter extends Consumer<List<Document>> {default void write(List<Document> docs) { accept(docs); }
}
2.1 DocumentReader(提取)
2.1.1 JsonReader
JSON读取器(JsonReader
)能够处理JSON文档,并将其转换为文档对象(Document)列表
student.json
[{"id": 1001,"name": "张三","age": 20,"sex": "男","desc": "计算机科学专业,擅长Java开发"},{"id": 1002,"name": "李四","age": 21,"sex": "女","desc": "数据科学方向,数学建模竞赛获奖者"},{"id": 1003,"name": "王五","age": 19,"sex": "男","desc": "外语学院,精通英日双语"}
]
解析代码:
- JsonReader提供三个构造方法:
- JsonReader(Resource resource) : 指向JSON文件的Spring Resource对象(用于指定数据源位置)
- JsonReader(Resource resource, String… jsonKeysToUse) : JSON中需提取为文本内容的键名数组(决定Document对象的正文内容来源)
- JsonReader(Resource resource, JsonMetadataGenerator jsonMetadataGenerator, String… jsonKeysToUse):自定义元数据生成器(为每个Document对象添加结构化元数据)
@Component
class MyJsonReader {private final Resource resource;MyJsonReader(@Value("classpath:student.json") Resource resource) {this.resource = resource;}public List<Document> loadJsonAsDocuments() {JsonReader jsonReader = new JsonReader(this.resource);return jsonReader.get();}@Beanpublic CommandLineRunner jsonReaderCommandLineRunner() {return args -> {List<Document> documentList = this.loadJsonAsDocuments();log.info("MyJsonReader {} documents loaded", documentList.size());for (Document document : documentList) {log.info("MyJsonReader document: {}", document);}};}
}
输出:
MyJsonReader 3 documents loaded
MyJsonReader document: Document{id=‘66766c60-ddcc-4eaa-8c23-72a4f446d5fe’, text=‘{id=1001, name=张三, age=20, sex=男, desc=计算机科学专业,擅长Java开发}’, media=‘null’, metadata={}, score=null}
MyJsonReader document: Document{id=‘f3e4734a-e197-40ba-bea1-aa52aa06074a’, text=‘{id=1002, name=李四, age=21, sex=女, desc=数据科学方向,数学建模竞赛获奖者}’, media=‘null’, metadata={}, score=null}
MyJsonReader document: Document{id=‘36be55f6-256c-4a4c-bff1-524e646cd1ad’, text=‘{id=1003, name=王五, age=19, sex=男, desc=外语学院,精通英日双语}’, media=‘null’, metadata={}, score=null}
2.1.2 TextReader
文本读取器(TextReader
) 能够处理纯文本文档,并将其转换为文档对象(Document)列表。
student.txt
张三,计算机科学专业,擅长Java开发;李四,数据科学方向,数学建模竞赛获奖者;王五,外语学院,精通英日双语
解析代码:
- TextReader提供两个构造方法:
- TextReader(String resourceUrl)
- TextReader(Resource resource)
@Slf4j
@Component
class MyJsonReader {private final Resource resource;MyJsonReader(@Value("classpath:student.json") Resource resource) {this.resource = resource;}public List<Document> loadJsonAsDocuments() {JsonReader jsonReader = new JsonReader(this.resource);return jsonReader.get();}@Beanpublic CommandLineRunner commandLineRunner() {return args -> {List<Document> documentList = this.loadJsonAsDocuments();log.info("{} documents loaded", documentList.size());for (Document document : documentList) {log.info("document: {}", document);}};}
}
输出:
MyTextReader 1 documents loaded
MyTextReader document: Document{id=‘c61d9072-62a7-4d62-92f4-01ef3941aa60’, text=‘张三,计算机科学专业,擅长Java开发;李四,数据科学方向,数学建模竞赛获奖者;王五,外语学院,精通英日双语’, media=‘null’, metadata={charset=UTF-8, source=student.txt}, score=null}
大文件读取:
文本读取器(TextReader)采用内存处理机制,采用全量内存加载模式,一次性读取整个文件内容,不适用于超大规模文件(建议单文件<1GB),大文件可以考虑使用TokenTextSplitter分块处理方案
// 原始文档读取
List<Document> documents = textReader.get();
// 智能分块(默认800token/块)
List<Document> splitDocuments = new TokenTextSplitter().apply(this.documents);
2.1.3 HTML (JSoup)
JsoupDocumentReader
基于JSoup库解析HTML文档,将其转换为结构化Document对象列表。
student.html:
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><title>自定义网页</title><meta name="description" content="A sample web page for Spring AI"><meta name="keywords" content="spring, ai, html, example"><meta name="author" content="liaokailin"><meta name="date" content="2020-04-02"><link rel="stylesheet" href="style.css"></head><body><header><h1>欢迎</h1></header><nav><ul><li><a href="/">Home</a></li><li><a href="/about">About</a></li></ul></nav><article><h2>页面内容</h2><p>我是一个标题</p><p>测试spring AI HTML</p><a href="https://www.cainao.com">点击链接</a></article><footer><p>© 2025 liaokailin</p></footer></body>
</html>
解析代码:
- JsoupDocumentReaderConfig配置参数说明
参数 | 类型 | 默认值 | 说明 |
---|---|---|---|
charset | String | “UTF-8” | HTML文档字符编码 |
selector | String | “body” | CSS选择器指定提取区域 |
separator | String | “\n” | 多元素文本连接符 |
allElements | boolean | false | 是否提取整个内容 |
groupByElement | boolean | false | 是否为每个匹配元素创建独立Document |
includeLinkUrls | boolean | false | 是否提取链接URL到元数据 |
metadataTags | List | [“description”,“keywords”] | 需要提取的meta标签名 |
additionalMetadata | Map<String,String> | - | 自定义元数据键值对 |
@Component
class MyHtmlReader {private final Resource resource;MyHtmlReader(@Value("classpath:/student.html") Resource resource) {this.resource = resource;}public List<Document> loadHtml() {// 构建配置参数JsoupDocumentReaderConfig config = JsoupDocumentReaderConfig.builder().selector("article p") // 提取<article>标签内的段落.charset("UTF-8") // 指定字符编码.includeLinkUrls(true) // 在元数据中包含链接URL.metadataTags(List.of("author", "date")) // 提取作者和日期meta标签.additionalMetadata("source", "student.html") // 添加自定义元数据.build();JsoupDocumentReader reader = new JsoupDocumentReader(this.resource, config);return reader.get();}@Beanpublic CommandLineRunner htmlReaderCommandLineRunner() {return args -> {List<Document> documentList = this.loadHtml();log.info("MyHtmlReader {} documents loaded", documentList.size());for (Document document : documentList) {log.info("MyHtmlReader document: {}", document);}};}
}
输出:
MyHtmlReader 1 documents loaded
MyHtmlReader document: Document{id=‘7de4373f-210f-4162-a24d-2064e60f2338’, text='我是一个标题
测试spring AI HTML’, media=‘null’, metadata={date=2020-04-02, linkUrls=[, , https://www.cainao.com], source=student.html, title=自定义网页, author=liaokailin}, score=null}
2.1.4 Markdown
MarkdownDocumentReader
处理Markdown,将其转换为结构化Document对象列表。
code.md:
This is a Java sample application:```java
package com.example.demo;import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication
public class DemoApplication {public static void main(String[] args) {SpringApplication.run(DemoApplication.class, args);}
}
Markdown also provides the possibility to use inline code formatting throughout
the entire sentence.
Another possibility is to set block code without specific highlighting:
./mvnw spring-javaformat:apply
**解析代码:**MarkdownDocumentReaderConfig配置类用于定制Markdown文档的解析行为,主要参数如下:+ horizontalRuleCreateDocument:当设置为true时,Markdown中的水平分隔符(---或***)会触发创建新Document对象;应用场景:适合将长文档按章节自动拆分
+ includeCodeBlock:控制代码块的处理方式,true:代码块与上下文合并到同一Document,false:为每个代码块生成独立Document,默认值:false(建议技术文档设为true保持上下文关联)
+ includeBlockquote:管理引用块的处理,true:引用内容与正文合并,false:为每个引用块生成独立Document
+ additionalMetadata:向所有生成的Document注入自定义元数据```java@Slf4j
@Component
class MyMarkdownReader {private final Resource resource;MyMarkdownReader(@Value("classpath:code.md") Resource resource) {this.resource = resource;}List<Document> loadMarkdown() {MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder().withHorizontalRuleCreateDocument(true).withIncludeCodeBlock(false).withIncludeBlockquote(false).withAdditionalMetadata("filename", "code.md").build();MarkdownDocumentReader reader = new MarkdownDocumentReader(this.resource, config);return reader.get();}@Beanpublic CommandLineRunner markdownReaderCommandLineRunner() {return args -> {List<Document> documentList = this.loadMarkdown();log.info("MyMarkdownReader {} documents loaded", documentList.size());for (Document document : documentList) {log.info("MyMarkdownReader document: {}", document);}};}}
**输出: **
MyMarkdownReader 5 documents loaded
MyMarkdownReader document: Document{id=‘d2109b75-bd2a-4b6e-aa85-94399c5c6d03’, text=‘This is a Java sample application:’, media=‘null’, metadata={filename=code.md}, score=null}
MyMarkdownReader document: Document{id=‘5606fd60-b576-4327-8b30-2921b3120f6d’, text='package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {SpringApplication.run(DemoApplication.class, args);}
}
', media=‘null’, metadata={filename=code.md, category=code_block, lang=java}, score=null}
MyMarkdownReader document: Document{id=‘08398b41-012b-4aab-a551-fee11983725c’, text=‘Markdown also provides the possibility to use inline code formatting throughout the entire sentence.’, media=‘null’, metadata={category=code_inline, filename=code.md}, score=null}
MyMarkdownReader document: Document{id=‘c29e3e6d-0b3e-409a-8120-6f1474238c09’, text=‘Another possibility is to set block code without specific highlighting:’, media=‘null’, metadata={filename=code.md}, score=null}
MyMarkdownReader document: Document{id=‘e6c5395c-560a-4420-bafe-0aff17c392bc’, text='./mvnw spring-javaformat:apply
2.1.5 PDF
PagePdfDocumentReader
解析器基于Apache PdfBox库实现,按物理页面解析PDF文档(每页生成独立Document对象),保留原始文本布局和顺序,自动提取基础元数据(页数、文档标题等)
文件内容:
student.pdf
解析代码:
@Slf4j
@Component
public class MyPagePdfDocumentReader {List<Document> getDocsFromPdf() {PagePdfDocumentReader pdfReader = new PagePdfDocumentReader("classpath:/student.pdf",PdfDocumentReaderConfig.builder().withPageTopMargin(0).withPageExtractedTextFormatter(ExtractedTextFormatter.builder().withNumberOfTopTextLinesToDelete(0).build()).withPagesPerDocument(1).build());return pdfReader.read();}@Beanpublic CommandLineRunner pdfCommandLineRunner() {return args -> {List<Document> documentList = this.getDocsFromPdf();log.info("MyPagePdfDocumentReader {} documents loaded", documentList.size());for (Document document : documentList) {log.info("MyPagePdfDocumentReader document: {}", document);}};}}
输出:
MyPagePdfDocumentReader 1 documents loaded
MyPagePdfDocumentReader document: Document{id=‘2aa42ca0-279d-4da6-b7a6-cbde0c5e98cb’, text=’
简介 这是一个测试内容 内容 张三,计算机科学专业,擅长 Java 开发;李四,数据科学方向,数学建模竞赛获奖者;王 五,外语学院,精通英日双语
', media=‘null’, metadata={page_number=1, file_name=student.pdf}, score=null}
除了PagePdfDocumentReader还有一个ParagraphPdfDocumentReader通过PDF目录结构(如书签/标题层级)实现智能段落分割
特性 | ParagraphPdfDocumentReader | PagePdfDocumentReader |
---|---|---|
分割依据 | 逻辑段落结构 | 物理分页 |
适用文档类型 | 带标准目录的电子书/技术文档 | 扫描件/版式固定文档 |
输出质量 | 保持语义完整性 | 保留原始版式 |
兼容性 | 需PDF包含目录 | 通用所有PDF |
2.1.6 Tika (DOCX, PPTX)
TikaDocumentReader
基于 Apache Tika 库,支持从多种文档格式(如 PDF、Word、PPT、HTML 等)中提取文本内容,并将其转换为 List 对象;
Apache Tika 可解析 50+ 文件格式(https://tika.apache.org/2.9.0/formats.html),包括但不限于:Office 文档:DOC/DOCX、PPT/PPTX、XLS/XLSX、PDF(包括扫描件 OCR 支持)、HTML/XML、纯文本(TXT、CSV、JSON 等)、电子邮件(EML、MSG)、压缩文件(ZIP、TAR)
student.docx
代码解析:
@Component
public class MyTikaDocumentReader {private final Resource resource;public MyTikaDocumentReader(@Value("classpath:/student.docx") Resource resource) {this.resource = resource;}public List<Document> loadText() {TikaDocumentReader reader = new TikaDocumentReader(resource);return reader.read();}@Beanpublic CommandLineRunner tikaCommandLineRunner() {return args -> {List<Document> documentList = this.loadText();log.info("MyTikaDocumentReader {} documents loaded", documentList.size());for (Document document : documentList) {log.info("MyTikaDocumentReader document: {}", document);}};}
}
输出
MyTikaDocumentReader 1 documents loaded
MyTikaDocumentReader document: Document{id=‘22cd03d7-7a49-4f4a-8e53-693708fe69ca’, text='简介
这是一个测试内容
内容
张三,计算机科学专业,擅长Java开发;李四,数据科学方向,数学建模竞赛获奖者;王五,外语学院,精通英日双语
2.2 Transformer(转换)
2.2.1 TokenTextSplitter
TextSplitter
是一个抽象基类,用于将文档分割成适合 AI 模型上下文窗口的片段,TokenTextSplitter
是其具体实现,基于CL100K_BASE编码(兼容OpenAI模型)按Token数量分割文本,默认分块(800Tokens/块)
核心功能:
- 智能分块
- 按 Token 数量(而非字符数)分割,确保符合大模型限制
- 自动在句子边界(句号、问号等)处拆分,保持语义完整性
- 元数据保留
- 原始文档的元数据(如来源、作者)自动继承到所有分块
- 高性能处理
- 支持批量文档分割,内置流式编码/解码
**构造方法: **
public List<Document> splitCustomized(List<Document> documents) {TokenTextSplitter splitter = new TokenTextSplitter(1000, 400, 10, 5000, true);return splitter.apply(documents);}
参数含义:
TokenTextSplitter splitter = new TokenTextSplitter(
1000, // 目标Token数 (defaultChunkSize)400, // 最小字符数 (minChunkSizeChars)10, // 最小有效分块长度 (minChunkLengthToEmbed) 5000, // 最大分块数 (maxNumChunks)true // 保留分隔符 (keepSeparator)
);
执行流程:
代码示例:
public class MyTokenTextSplitter {public static void main(String[] args) {Document doc = new Document("自然语言处理(NLP)是AI的核心领域之一。它使计算机能理解人类语言。", Map.of("source", "AI百科"));TokenTextSplitter splitter = new TokenTextSplitter(30, // defaultChunkSize(目标Token数,需调小)20, // minChunkSizeChars(最小字符数,覆盖中文特性)1, // minChunkLengthToEmbed(避免过滤短句)10, // maxNumChunks(限制分块数)false // keepSeparator(中文通常无需保留换行符));List<Document> ll = splitter.apply(List.of(doc));System.out.println(ll.size());for (Document document : ll) {System.out.println(document);}}
}
参数设计原理(拆分为两个Document):
- defaultChunkSize=30:中文Token约为字符数的1.5-2倍("自然语言处理(NLP)是AI的核心领域之一。"约消耗25-30 Tokens),设置略高于单句Token数,强制在句号处分隔
- minChunkSizeChars=20:覆盖中文无空格特性(英文默认350需大幅降低)确保短句如"它使计算机能理解人类语言。"(12字)不被合并
- keepSeparator=false:中文分句通常依赖标点而非换行符
输出:
[main] INFO org.springframework.ai.transformer.splitter.TextSplitter – Splitting up document into 2 chunks.
2
Document{id=‘d759acc8-0684-42ae-92ed-2b43e805108f’, text=‘自然语言处理(NLP)是AI的核心领域之一。它使计算机能理解人’, media=‘null’, metadata={source=AI百科}, score=null}
Document{id=‘1184a2a3-daed-4473-b048-f8fc26bda4e1’, text=‘类语言。’, media=‘null’, metadata={source=AI百科}, score=null}
2.2.2 ContentFormatTransformer
ContentFormatTransformer
是一个文档内容转换器,通常结合ContentFormatter
来使用,使用特定规则处理文档内容
代码示例:
核心关注DefaultContentFormatter的参数设置
public class MyContentFormatTransformer {public static void main(String[] args) {Document doc = new Document("Spring AI 最新版本发布",Map.of("author", "Spring AI","date", "2025-05","internal_id", "X-123","timestamp", "122323232"));DefaultContentFormatter formatter = DefaultContentFormatter.builder().withMetadataTemplate("{key}>>>{value}") // 元数据显示格式.withMetadataSeparator("\n") // 元数据分隔符.withTextTemplate("METADATA:\n{metadata_string}\nCONTENT:\n{content}") // 内容模板.withExcludedInferenceMetadataKeys("internal_id") // 推理时排除的元数据.withExcludedEmbedMetadataKeys("timestamp") // 嵌入时排除的元数据.build();String content = formatter.format(doc, MetadataMode.EMBED);System.out.println(content);ContentFormatTransformer transformer = new ContentFormatTransformer(formatter, false);List<Document> ll = transformer.apply(List.of(doc));System.out.println(ll.size());for (Document document : ll) {System.out.println(document);}}
}
2.2.3 KeywordMetadataEnricher
KeywordMetadataEnricher
是一个利用生成式AI模型从文档内容中提取关键词,并将其作为元数据添加到文档中的文档转换器。
**代码示例: **
KeywordMetadataEnricher 的构造函数接收两个参数:
- ChatModel chatModel:用于生成关键词的AI模型
- int keywordCount:为每个文档提取的关键词数量
public class MyKeywordMetadataEnricher {private final ChatModel chatModel;MyKeywordMetadataEnricher(ChatModel chatModel) {this.chatModel = chatModel;}List<Document> enrichDocuments(List<Document> documents) {KeywordMetadataEnricher enricher = new KeywordMetadataEnricher(this.chatModel, 5);return enricher.apply(documents);}@Beanpublic CommandLineRunner keywordMetadataCommandLineRunner() {return args -> {Document doc = new Document("""春天来了,大地披上绿装。和煦的阳光洒满田野,嫩绿的草芽从泥土中探出头来,枝头的花朵竞相绽放,空气中弥漫着淡淡的花香。微风轻拂,带来泥土的芬芳和鸟儿的欢唱。孩子们在草地上奔跑,风筝在蓝天中飞舞,一切都充满生机与希望。春天是生命的季节,是梦想的开始,让人心旷神怡,充满期待。""");List<Document> documentList = enrichDocuments(List.of(doc));String keywords = (String) documentList.get(0).getMetadata().get("excerpt_keywords");System.out.println("提取的关键词: " + keywords);};}}
输出:
提取的关键词: renewal, blossoms, vitality, childhood joy, seasonal awakening
提取出来的关键词为英文,核心是KeywordMetadataEnricher
内部提示词是英文导致的, 如果要解决该问题,可以仿照KeywordMetadataEnricher
实现类似的功能,把提示词修改为中文。
2.2.4 SummaryMetadataEnricher
SummaryMetadataEnricher
是一个利用生成式 AI 模型为文档生成摘要,并将其作为元数据添加到文档中的文档转换器,它不仅可以生成当前文档的摘要,还能生成相邻文档(前一篇和后一篇)的摘要
代码示例:
SummaryMetadataEnricher
构造方法
- chatModel:用于生成摘要的 AI 模型。
- summaryTypes:指定生成哪些类型的摘要(PREVIOUS、CURRENT、NEXT)。
- summaryTemplate(可选):自定义摘要生成模板。
- metadataMode(可选):控制生成摘要时如何处理文档的元数据。
@Configuration
public class SummaryEnricherConfig {// 配置 SummaryMetadataEnricher Bean@Beanpublic SummaryMetadataEnricher summaryEnricher(ChatModel chatModel) {return new SummaryMetadataEnricher(chatModel,List.of(SummaryMetadataEnricher.SummaryType.PREVIOUS, SummaryMetadataEnricher.SummaryType.CURRENT, SummaryMetadataEnricher.SummaryType.NEXT));}
}@Slf4j
@Component
public class MySummaryMetadataEnricher {private final SummaryMetadataEnricher enricher;// 通过构造函数注入public MySummaryMetadataEnricher(SummaryMetadataEnricher enricher) {this.enricher = enricher;}// 文档增强方法public List<Document> enrichDocuments(List<Document> documents) {return enricher.apply(documents);}@Beanpublic CommandLineRunner summaryMetadataCommandLineRunner() {return args -> {// 创建测试文档Document doc1 = new Document("春天来了,大地复苏。树木抽出新芽,花朵竞相开放。");Document doc2 = new Document("夏季是万物生长的季节。阳光充足,植物茂盛,动物活跃。");Document doc3 = new Document("秋天是收获的季节。果实成熟,树叶变黄,天气凉爽。");// 应用增强器List<Document> enrichedDocs = enrichDocuments(List.of(doc1, doc2, doc3));// 打印结果for (int i = 0; i < enrichedDocs.size(); i++) {Document doc = enrichedDocs.get(i);System.out.println("\n文档 " + (i + 1) + " 的元数据:");System.out.println("当前摘要: " + doc.getMetadata().get("section_summary"));System.out.println("前一篇摘要: " + doc.getMetadata().get("prev_section_summary"));System.out.println("后一篇摘要: " + doc.getMetadata().get("next_section_summary"));}};}}
输出:
文档 1 的元数据:
当前摘要: The section describes the arrival of spring, highlighting the rejuvenation of nature.
Key Topics:
Seasonal change (spring)
Nature’s renewal
Key Entities:
Earth (“大地”)
Trees (“树木”)
Flowers (“花朵”)
Summary: The passage depicts spring as a time of rebirth, with trees budding and flowers blooming.
前一篇摘要: null
后一篇摘要: The section highlights the key characteristics of summer, emphasizing growth and vitality.
Key Topics:
Summer as a season of growth
Abundant sunlight
Lush plant life
Active animal behavior
Entities:
Sunlight
Plants
Animals
Summary: Summer is depicted as a vibrant season marked by plentiful sunshine, flourishing vegetation, and heightened animal activity.
文档 2 的元数据:
当前摘要: The section highlights the key characteristics of summer, emphasizing growth and vitality.
Key Topics:
Summer as a season of growth
Abundant sunlight
Lush plant life
Active animal behavior
Entities:
Sunlight
Plants
Animals
Summary: Summer is depicted as a vibrant season marked by plentiful sunshine, flourishing vegetation, and heightened animal activity.
前一篇摘要: The section describes the arrival of spring, highlighting the rejuvenation of nature.
Key Topics:
Seasonal change (spring)
Nature’s renewal
Key Entities:
Earth (“大地”)
Trees (“树木”)
Flowers (“花朵”)
Summary: The passage depicts spring as a time of rebirth, with trees budding and flowers blooming.
后一篇摘要: The section describes the autumn season, highlighting its key characteristics:
Key Topics:
Harvest season
Ripening of fruits
Changing leaf colors (turning yellow)
Cool weather
Entities:
Autumn/Fall (季节)
Fruits (果实)
Leaves (树叶)
Weather (天气)
Summary: The passage emphasizes autumn as a time of harvest, marked by fruit maturity, yellowing leaves, and cooler temperatures.
文档 3 的元数据:
当前摘要: The section describes the autumn season, highlighting its key characteristics:
Key Topics:
Harvest season
Ripening of fruits
Changing leaf colors (turning yellow)
Cool weather
Entities:
Autumn/Fall (季节)
Fruits (果实)
Leaves (树叶)
Weather (天气)
Summary: The passage emphasizes autumn as a time of harvest, marked by fruit maturity, yellowing leaves, and cooler temperatures.
前一篇摘要: The section highlights the key characteristics of summer, emphasizing growth and vitality.
Key Topics:
Summer as a season of growth
Abundant sunlight
Lush plant life
Active animal behavior
Entities:
Sunlight
Plants
Animals
Summary: Summer is depicted as a vibrant season marked by plentiful sunshine, flourishing vegetation, and heightened animal activity.
后一篇摘要: null
改写提示词为中文:
@Beanpublic SummaryMetadataEnricher summaryEnricher(ChatModel chatModel) {String template = """请基于以下文本提取核心信息:{context_str}要求:1. 使用简体中文2. 包含关键实体3. 不超过50字摘要:""";return new SummaryMetadataEnricher(chatModel, List.of(SummaryMetadataEnricher.SummaryType.PREVIOUS, SummaryMetadataEnricher.SummaryType.CURRENT, SummaryMetadataEnricher.SummaryType.NEXT), template, MetadataMode.ALL);}
输出:
文档 1 的元数据:
当前摘要: 春天来临,树木新芽萌发,花朵绽放。(关键词:春天、树木、新芽、花朵)
前一篇摘要: null
后一篇摘要: 夏季阳光充足,万物生长旺盛,植物繁茂,动物活跃。
(关键实体:夏季、阳光、植物、动物)
文档 2 的元数据:
当前摘要: 夏季阳光充足,万物生长旺盛,植物繁茂,动物活跃。
(关键实体:夏季、阳光、植物、动物)
前一篇摘要: 春天来临,树木新芽萌发,花朵绽放。(关键词:春天、树木、新芽、花朵)
后一篇摘要: 秋天是收获的季节,果实成熟,树叶变黄,天气凉爽。
(关键实体:秋天、果实、树叶、天气)
文档 3 的元数据:
当前摘要: 秋天是收获的季节,果实成熟,树叶变黄,天气凉爽。
(关键实体:秋天、果实、树叶、天气)
前一篇摘要: 夏季阳光充足,万物生长旺盛,植物繁茂,动物活跃。
(关键实体:夏季、阳光、植物、动物)
后一篇摘要: null
2.3 DocumentWriter(写入/加载)
2.3.1 File
FileDocumentWriter
是一个DocumentWriter
实现类,用于将Document
对象列表的内容写入文件
代码示例:
提供三种构造函数:
- FileDocumentWriter(String fileName)
- FileDocumentWriter(String fileName, boolean withDocumentMarkers)
- FileDocumentWriter(String fileName, boolean withDocumentMarkers, MetadataMode metadataMode, boolean append)
参数说明:
- fileName:目标文件名
- withDocumentMarkers:是否在输出中包含文档标记(默认false)
- metadataMode:指定写入文件的文档内容格式(默认MetadataMode.NONE)
- append:是否追加写入文件(默认false)
@Slf4j
@Component
public class MyDocumentWriter {public void writeDocuments(List<Document> documents) {FileDocumentWriter writer = new FileDocumentWriter("output.txt", true, MetadataMode.ALL, false);writer.accept(documents);}@Beanpublic CommandLineRunner documentWriterCommandLineRunner() {return args -> {// 创建测试文档Document doc1 = new Document("春天来了,大地复苏。树木抽出新芽,花朵竞相开放。");Document doc2 = new Document("夏季是万物生长的季节。阳光充足,植物茂盛,动物活跃。");Document doc3 = new Document("秋天是收获的季节。果实成熟,树叶变黄,天气凉爽。");writeDocuments(List.of(doc1, doc2, doc3));};}}
**输出:**
Doc: 0, pages:[null,null]
春天来了,大地复苏。树木抽出新芽,花朵竞相开放。
Doc: 1, pages:[null,null]
夏季是万物生长的季节。阳光充足,植物茂盛,动物活跃。
Doc: 2, pages:[null,null]
秋天是收获的季节。果实成熟,树叶变黄,天气凉爽。
2.3.2 VectorStore
向量数据库是一种特殊数据库类型,其核心特征是通过相似性搜索(而非精确匹配)来检索数据。当输入查询向量时,系统会返回与之最相似的若干向量;
VectorStore
定义操作向量数据库的接口:
public interface VectorStore extends DocumentWriter {default String getName() {return this.getClass().getSimpleName();}void add(List<Document> documents);void delete(List<String> idList);void delete(Filter.Expression filterExpression);default void delete(String filterExpression) { ... };List<Document> similaritySearch(String query);List<Document> similaritySearch(SearchRequest request);default <T> Optional<T> getNativeClient() {return Optional.empty();}
}
这里不展开操作示例,直接在下一节演示
三、利用PostgreSQL做RAG
PostgreSQL(简称 Postgres)是一款开源的关系型数据库管理系统(RDBMS),支持 SQL标准并扩展了高级功能,如 JSON 文档存储、时序数据处理和向量计算(通过 PgVector 扩展),本节通过Postgres来做向量存储后提供RAG功能
3.1 PostgreSQL安装
配置pom.xml
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-docker-compose</artifactId><scope>runtime</scope><optional>true</optional>
</dependency><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>
创建****compose.yaml
services:pgvector:image: 'pgvector/pgvector:pg16'environment:- 'POSTGRES_DB=mydatabase'- 'POSTGRES_PASSWORD=secret'- 'POSTGRES_USER=myuser'labels:- "org.springframework.boot.service-connection=postgres"ports:- '5432'
schema.sql
- CREATE EXTENSION IF NOT EXISTS vector :启用向量计算支持(PgVector)
- CREATE EXTENSION IF NOT EXISTS hstore : 启用键值对存储(可选,用于高级元数据)
- CREATE EXTENSION IF NOT EXISTS “uuid-ossp” : 支持UUID生成函数
- content:原始文本内容
- metadata:结构化元数据(如来源、标签)
- embedding VECTOR(1536) : 存储1536维向量(适配OpenAI等模型)
- 索引类型:HNSW(Hierarchical Navigable Small World)一种近似最近邻(ANN)算法,显著加速向量相似度搜索,适合高维数据(如1536维),平衡查询速度和精度。
- vector_cosine_ops 指定使用 余弦相似度 计算向量距离(其他选项:vector_l2_ops 欧氏距离)
create extension if not exists vector ;
create extension if not exists hstore ;create extension if not exists "uuid-ossp" ;-- drop table vector_store;create table if not exists vector_store(id uuid default uuid_generate_v4() primary key ,content text,metadata json ,embedding vector(1536)
);create index on vector_store using hnsw(embedding vector_cosine_ops);
application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/mydatabase
spring.datasource.username=myuser
spring.datasource.password=secret# index-type=HNSW:使用Hierarchical Navigable Small World算法加速向量检索,适合高维数据
spring.ai.vectorstore.pgvector.index-type=HNSW
# 相似度计算使用余弦距离(更适合语义搜索)
spring.ai.vectorstore.pgvector.distance-type=COSINE_DISTANCEspring.ai.vectorstore.pgvector.dimensions=1536# 控制 Docker Compose 的生命周期行为,应用启动时自动启动关联的容器(如 PostgreSQL),但不会在停止时关闭容器,其他模式:none(不管理)、start_and_stop(完全托管)
spring.docker.compose.lifecycle-management=start_only# 自定义向量存储表名(默认是 vector_store)
#spring.ai.vectorstore.pgvector.table-name=my_vector_store
# 管理数据库 Schema 初始化
spring.sql.init.mode=always
spring.sql.init.data-source-generation=never
3.2 初始化数据
通过一个接口触发数据的初始化,执行一次即可
- 使用
PagePdfDocumentReader
解析pdf格式文件 PgVectorStore
会在PgVectorStoreAutoConfiguration
类中自动申明
@Value("classpath:/doc/菜鸟驿站站点合作协议.pdf")private org.springframework.core.io.Resource doc;
@Resource
private PgVectorStore vectorStore;@GetMapping("/pg/init")
public String initData() {PdfDocumentReaderConfig config = PdfDocumentReaderConfig.builder().withPageExtractedTextFormatter(new ExtractedTextFormatter.Builder().withNumberOfBottomTextLinesToDelete(0).withNumberOfTopPagesToSkipBeforeDelete(0).build()).withPagesPerDocument(1).build();PagePdfDocumentReader pdfReader = new PagePdfDocumentReader(doc, config);TokenTextSplitter textSplitter = new TokenTextSplitter();vectorStore.accept(textSplitter.apply(pdfReader.get()));return "done";
}
在控制台查看表数据
3.3 发起检索
代码逻辑和第一节的示例保持一致,支持提的问题调整了下
public String pgRag(@RequestParam(value = "input", defaultValue = "和菜鸟合作驿站会有哪些义务") String input) {PromptTemplate promptTemplate = new PromptTemplate("""你是一个驿站站点合作协议答疑助手,请结合文档提供的内容回答用户的问题,如果不知道请直接回答不知道用户输入的问题:{input}文档:{documents}""");String relevantDocs = String.join("\n", findSimilarDocuments(input));log.info("用户问题={},查询结果={}", input, relevantDocs);Map<String, Object> params = new HashMap<>();params.put("input", input);params.put("documents", relevantDocs);return chatClient.prompt(new Prompt(promptTemplate.render(params))).call().content();}private List<String> findSimilarDocuments(String message) {List<Document> similarDocuments = vectorStore.similaritySearch(SearchRequest.builder().query(message).topK(3).build());return similarDocuments.stream().map(Document::getText).collect(Collectors.toList());}
输出结果:
与菜鸟合作成为驿站后,您将承担以下义务:
提供服务设施:您需要自费提供存货场地、人员、设备等线下资源,为菜鸟用户提供代收发货、货品保存、代寄件等服务。
信息准确性:您承诺驿站页面所登记的信息准确真实,如因填写错误或提供虚假信息造成的责任由您承担。
遵守运营规则:严格遵守《菜鸟驿站站点运营规则》,涉及入驻、退出、服务质量、投诉赔付等内容。
营业时间:需在菜鸟系统设定的营业时间内正常营业,如需临时停止服务,需提前至少7个工作日申请并下线。
物料张贴:按菜鸟要求张贴相关物料,如拒不履行,菜鸟有权终止合作。
规则更新:菜鸟有权更新服务规则和要求,您需及时查阅并遵守,更新内容无需事先征得您的同意。
这些义务旨在确保服务质量和用户满意度,同时您需定期查阅菜鸟发布的更新和公告。
引入spring-ai-advisors-vector-store
有内置advisor
可直接复用
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
public String pgRag2(@RequestParam(value = "input", defaultValue = "菜鸟驿站有什么作用") String input) {return chatClient.prompt(input).advisors(new QuestionAnswerAdvisor(vectorStore)).call().content();
}
四、RAG架构
上面章节提到的RAG是一种朴素RAG(Naive RAG),随着RAG技术的发展,为进一步提升RAG的数据准确性、多样性、智能化等目标,陆续衍生出Advanced RAG(Retrieve-and-rerank)、Multimodal RAG、Graph RAG、Hybird RAG、Agentic RAG等多样化架构;当前最前沿的Agentic RAG正在向"AI科学家"方向发展,其核心是通过自主规划、工具使用和反思迭代,实现人类认知过程的自动化模拟
本篇不会对所有模式做逐一的介绍,重点介绍Graph RAG和Agentic RAG,下表给出各种架构的差异,感兴趣可自行深入挖掘(可参考文章:
https://weaviate.io/blog/introduction-to-rag#advanced-rag
https://weaviate.io/blog/graph-rag
https://weaviate.io/blog/multimodal-rag
https://weaviate.io/blog/what-is-agentic-rag )
类型 | 核心特点 | 优势 | 缺点 | 适用场景 |
---|---|---|---|---|
Naive RAG | 直接检索Top-K文档片段,拼接后输入生成模型。 | 实现简单,计算成本低 | 检索噪声可能影响生成质量。 | 简单问答、快速原型开发。 |
Advanced RAG | 两阶段优化:初步检索 + 重排序(如Cross-Encoder);支持查询扩展、HyDE等策略 | 显著提升上下文相关性 | 重排序增加计算开销 | 高精度问答(医疗、法律) |
Multimodal RAG | 支持文本、图像等多模态检索与生成(如CLIP向量化 + GPT-4V) | 解锁跨模态应用(如图文生成) | 需多模态嵌入模型,预处理复杂 | 视觉问答、多媒体内容创作。 |
Graph RAG | 基于知识图谱检索,支持多跳推理(实体-关系遍历) | 适合复杂逻辑推理,解释性强。 | 图谱构建成本高,动态更新难 | 科研分析、金融关系推理 |
Hybrid RAG | 混合稠密检索(向量)和稀疏检索(如BM25),支持前/后融合策略 | 兼顾语义与关键词匹配,鲁棒性强 | 需调优融合策略 | 搜索引擎、企业知识库 |
Agentic RAG | 由智能体动态控制流程(如迭代检索、工具调用、自我修正) | 灵活处理多轮交互和复杂任务。 | 开发难度高,延迟较大 | 自主客服、多步骤任务自动化 |
4.1 Graph RAG
Graph RAG 使用图结构来扩展传统的 RAG 系统,利用图的关系和层级结构,增强multi-hop推理和 context丰富度。Graph RAG 可以生成的结果更丰富更准确,特别是对于需要关系理解的任务;在知识图谱(Knowledge Graph)领域中以图结构的形式描述客观世界中的实体、关系、属性。不同于传统数据库,知识图谱更强调语义关联和逻辑推理能力,是实现机器认知智能的核心基础设施;
本节将基于Neo4j来演示Graph RAG的能力,Neo4j采用属性图模型,包含三个核心要素:
- 节点(Node):表示实体,可以有标签(Label)分类,可以包含属性(键值对)
- 关系(Relationship):连接节点的有向边,必须有类型(Type),可以有属性,总是有方向(但查询时可忽略)
- 属性(Property):节点和关系的键值对
4.1.1 Neo4j的安装
compose.yaml (仍然使用Docker进行软件安装)
services:neo4j:image: 'neo4j:latest'environment:- NEO4J_AUTH=neo4j/medicalrag # 用户名/密码- NEO4JLABS_PLUGINS=["apoc", "graph-data-science"] # 必要的插件- NEO4J_dbms_security_procedures_unrestricted=apoc.*,gds.*,db.*- NEO4J_apoc_export_file_enabled=true- NEO4J_apoc_import_file_enabled=trueports:- "7474:7474"- "7687:7687"healthcheck:test: ["CMD", "neo4j", "status"]interval: 10stimeout: 5sretries: 5
pom.xml
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-neo4j</artifactId>
</dependency>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-vector-store-neo4j</artifactId>
</dependency>
配置完成后启动应用会自动下载与启动镜像,访问http://localhost:7474/browser/ 进入Neo4j可视化控制台,标识安装成功。
4.1.2 节点&关系&属性
以一个病人问诊为例子,围绕疾病、症状、药品节点构建相互之间关系,
Disease(疾病)
- 疾病会拥有多种症状(关系类型=HAS_SYMPTOM)
- 疾病可以用特定药品来治疗(关系类型=TREATED_WITH)
// 疾病节点
@Data
@Node("Disease")
public class Disease {@Idprivate String code; // ICD-11编码,疾病、症状、异常发现的分类与统计private String name;private String description;private List<String> riskFactors;private List<Float> embedding;@Relationship(type = "HAS_SYMPTOM", direction = Relationship.Direction.OUTGOING)private Set<SymptomSeverity> symptoms;@Relationship(type = "TREATED_WITH", direction = Relationship.Direction.OUTGOING)private Set<DrugEffectiveness> treatments;@Overridepublic boolean equals(Object o) {if (this == o) return true;if (o == null || getClass() != o.getClass()) return false;Disease disease = (Disease) o;return Objects.equals(code, disease.code);}@Overridepublic int hashCode() {return Objects.hash(code);}
}
Drug(药品)
// 药品节点
@Data
@Node("Drug")
public class Drug {@Idprivate String code; // ATC编码,药品的解剖学-治疗学-化学分类private String name;private String mechanism;private List<String> contraindications;private List<Float> embedding;
}
Symptom(症状)
- 症状之间会存在相互关联,比如恶心了就会想呕吐
package com.lkl.ai.rag.neo4j.graph.node;import com.lkl.ai.rag.neo4j.graph.relation.SymptomRelation;
import lombok.Data;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;
import org.springframework.data.neo4j.core.schema.Relationship;import java.util.List;
import java.util.Set;/*** 症状节点*/
@Data
@Node("Symptom")
public class Symptom {@Idprivate String code; // ICD-11编码private String name;private String description;// private float[] embedding; // 症状描述向量private List<Float> embedding; // 改为List类型@Relationship(type = "OCCURS_WITH", direction = Relationship.Direction.OUTGOING)private Set<SymptomRelation> relatedSymptoms;
}
SymptomRelation(疾病-症状关系)
@Data
@RelationshipProperties
public class SymptomRelation {@Id@GeneratedValueprivate Long id;private String type; // "comorbid"(共病) / "similar"(相似) / "precedes"(先于)private Double correlation; // 相关性系数 0-1@TargetNodeprivate Symptom targetSymptom;
}
DrugEffectiveness(疾病-药品关系)
@Data
@RelationshipProperties
public class DrugEffectiveness {@Id@GeneratedValueprivate Long id;private String effectiveness; // high/medium/lowprivate List<String> guidelines;@TargetNodeprivate Drug drug;
}
SymptomRelation(症状之间的关系)
@Data
@RelationshipProperties
public class SymptomRelation {@Id@GeneratedValueprivate Long id;private String type; // "comorbid"(共病) / "similar"(相似) / "precedes"(先于)private Double correlation; // 相关性系数 0-1@TargetNodeprivate Symptom targetSymptom;
}
4.1.3 数据初始化
测试数据初始化(DataInitializer)
- 比如疾病偏头痛可能会导致头疼、畏光、恶心等症状,可以用舒马普坦药品来治疗,由于恶心等症状可能会导致呕吐症状(症状之间的关系)
- 调用embeddingClient.embed(xxx)对属性做向量化存储
package com.lkl.ai.rag.neo4j.init;import com.lkl.ai.rag.neo4j.graph.node.Disease;
import com.lkl.ai.rag.neo4j.graph.node.Drug;
import com.lkl.ai.rag.neo4j.graph.node.Symptom;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.neo4j.core.Neo4jClient;
import org.springframework.data.neo4j.core.Neo4jTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;import java.util.List;
import java.util.Map;/*** 数据初始化*/
@Component
public class DataInitializer {@Autowiredprivate Neo4jClient neo4jClient;@Autowiredprivate Neo4jTemplate neo4jTemplate;@Autowiredprivate EmbeddingModel embeddingClient;private void cleanDatabase() {// 使用Cypher删除所有节点和关系neo4jClient.query("MATCH (n) DETACH DELETE n").run();// 按类型分批删除neo4jTemplate.deleteAll(Symptom.class);neo4jTemplate.deleteAll(Disease.class);neo4jTemplate.deleteAll(Drug.class);// 重置Schema(需要APOC插件)neo4jClient.query("CALL apoc.schema.assert({}, {}) YIELD label, key RETURN *").run();}// @PostConstruct@Transactionalpublic void init() {// 清空现有数据(生产环境慎用)cleanDatabase();// 1. 初始化所有症状createSymptom("SY001", "发热", "体温高于正常范围");createSymptom("SY002", "头痛", "头部疼痛不适");createSymptom("SY003", "畏光", "对光线异常敏感");createSymptom("SY004", "恶心", "胃部不适想呕吐");createSymptom("SY005", "呕吐", "胃内容物经口排出");createSymptom("SY006", "颈强直", "颈部肌肉僵硬");// 2. 初始化所有疾病createDisease("DI001", "偏头痛", "反复发作的头痛疾病,常伴随恶心、畏光", List.of("遗传因素", "压力", "激素变化"));createDisease("DI002", "细菌性脑膜炎", "脑膜和脊髓膜的细菌性炎症", List.of("细菌感染", "免疫低下"));createDisease("DI003", "胃肠炎", "胃肠道炎症反应", List.of("病毒感染", "食物中毒"));// 3. 初始化所有药物createDrug("DR001", "舒马普坦", "5-HT1受体激动剂,收缩血管缓解偏头痛", List.of("心脏病患者禁用"));createDrug("DR002", "青霉素", "β-内酰胺类抗生素", List.of("青霉素过敏者禁用"));createDrug("DR003", "奥美拉唑", "质子泵抑制剂,减少胃酸分泌", List.of("长期使用需监测镁水平"));// 4. 构建症状间关系buildSymptomRelationships();// 5. 构建疾病-症状关系buildDiseaseSymptomRelationships();// 6. 构建疾病-药物关系buildDiseaseDrugRelationships();}private void createSymptom(String code, String name, String description) {float[] floats = embeddingClient.embed(description);mergeNode("Symptom", code, Map.of("name", name, "description", description, "embedding", embeddingClient.embed(description)));}private void createDisease(String code, String name, String description, List<String> riskFactors) {mergeNode("Disease", code, Map.of("name", name, "description", description, "riskFactors", riskFactors, "embedding", embeddingClient.embed(description)));}private void createDrug(String code, String name, String mechanism, List<String> contraindications) {mergeNode("Drug", code, Map.of("name", name, "mechanism", mechanism, "contraindications", contraindications, "embedding", embeddingClient.embed(mechanism)));}/*** 关系构建方法*/private void buildSymptomRelationships() {// 头痛 -> 畏光 (共病关系)mergeRelationship("MATCH (s1:Symptom {code: $from}), (s2:Symptom {code: $to}) " + "MERGE (s1)-[r:OCCURS_WITH {type: $type, correlation: $correlation}]->(s2)", Map.of("from", "SY002", "to", "SY003", "type", "comorbid", "correlation", 0.7));// 头痛 -> 恶心 (先于关系)mergeRelationship("MATCH (s1:Symptom {code: $from}), (s2:Symptom {code: $to}) " + "MERGE (s1)-[r:OCCURS_WITH {type: $type, correlation: $correlation}]->(s2)", Map.of("from", "SY002", "to", "SY004", "type", "precedes", "correlation", 0.6));// 恶心 -> 呕吐 (因果关系)mergeRelationship("MATCH (s1:Symptom {code: $from}), (s2:Symptom {code: $to}) " + "MERGE (s1)-[r:OCCURS_WITH {type: $type, correlation: $correlation}]->(s2)", Map.of("from", "SY004", "to", "SY005", "type", "causes", "correlation", 0.8));}private void buildDiseaseSymptomRelationships() {// 偏头痛的症状mergeRelationship("MATCH (d:Disease {code: $disease}), (s:Symptom {code: $symptom}) " + "MERGE (d)-[r:HAS_SYMPTOM {severity: $severity, probability: $probability}]->(s)", Map.of("disease", "DI001", "symptom", "SY002", "severity", "moderate", "probability", 0.9));mergeRelationship("MATCH (d:Disease {code: $disease}), (s:Symptom {code: $symptom}) " + "MERGE (d)-[r:HAS_SYMPTOM {severity: $severity, probability: $probability}]->(s)", Map.of("disease", "DI001", "symptom", "SY003", "severity", "severe", "probability", 0.8));mergeRelationship("MATCH (d:Disease {code: $disease}), (s:Symptom {code: $symptom}) " + "MERGE (d)-[r:HAS_SYMPTOM {severity: $severity, probability: $probability}]->(s)", Map.of("disease", "DI001", "symptom", "SY004", "severity", "mild", "probability", 0.6));// 脑膜炎的症状mergeRelationship("MATCH (d:Disease {code: $disease}), (s:Symptom {code: $symptom}) " + "MERGE (d)-[r:HAS_SYMPTOM {severity: $severity, probability: $probability}]->(s)", Map.of("disease", "DI002", "symptom", "SY001", "severity", "severe", "probability", 0.95));mergeRelationship("MATCH (d:Disease {code: $disease}), (s:Symptom {code: $symptom}) " + "MERGE (d)-[r:HAS_SYMPTOM {severity: $severity, probability: $probability}]->(s)", Map.of("disease", "DI002", "symptom", "SY002", "severity", "severe", "probability", 0.9));mergeRelationship("MATCH (d:Disease {code: $disease}), (s:Symptom {code: $symptom}) " + "MERGE (d)-[r:HAS_SYMPTOM {severity: $severity, probability: $probability}]->(s)", Map.of("disease", "DI002", "symptom", "SY006", "severity", "moderate", "probability", 0.7));}private void buildDiseaseDrugRelationships() {// 偏头痛的治疗药物mergeRelationship("MATCH (d:Disease {code: $disease}), (dr:Drug {code: $drug}) " + "MERGE (d)-[r:TREATED_WITH {effectiveness: $effectiveness, guidelines: $guidelines}]->(dr)", Map.of("disease", "DI001", "drug", "DR001", "effectiveness", "high", "guidelines", List.of("急性发作期使用")));// 脑膜炎的治疗药物mergeRelationship("MATCH (d:Disease {code: $disease}), (dr:Drug {code: $drug}) " + "MERGE (d)-[r:TREATED_WITH {effectiveness: $effectiveness, guidelines: $guidelines}]->(dr)", Map.of("disease", "DI002", "drug", "DR002", "effectiveness", "high", "guidelines", List.of("细菌性脑膜炎一线用药")));// 胃肠炎的治疗药物mergeRelationship("MATCH (d:Disease {code: $disease}), (dr:Drug {code: $drug}) " + "MERGE (d)-[r:TREATED_WITH {effectiveness: $effectiveness, guidelines: $guidelines}]->(dr)", Map.of("disease", "DI003", "drug", "DR003", "effectiveness", "medium", "guidelines", List.of("每日一次,饭前服用")));}private void mergeNode(String label, String code, Map<String, Object> properties) {String cypher = String.format("""MERGE (n:%s {code: $code})SET n += $properties""", label);neo4jClient.query(cypher).bind(code).to("code").bind(properties).to("properties").run();}private void mergeRelationship(String cypher, Map<String, Object> params) {neo4jClient.query(cypher).bindAll(params).run();}
}
IndexInitializer(构建索引)
public class IndexInitializer {@Autowiredprivate Neo4jClient neo4jClient; // 直接注入Neo4jClientpublic void init() {try {System.out.println("neo4jClient.query(\"RETURN apoc.version()\").fetch().first(): " + neo4jClient.query("RETURN apoc.version()").fetch().first());} catch (Exception e) {throw new IllegalStateException("APOC插件未正确安装或版本过低", e);}// 创建症状名称索引runQuery("CREATE INDEX symptom_name_index IF NOT EXISTS FOR (s:Symptom) ON (s.name)");// 创建疾病编码索引runQuery("CREATE INDEX disease_code_index IF NOT EXISTS FOR (d:Disease) ON (d.code)");// 创建原生向量索引 (Neo4j 5.11+)runQuery("""CALL db.index.vector.createNodeIndex('symptom_embedding_index','Symptom','embedding',1536,'cosine')""");System.out.println("------------索引执行成功-----------");}private void runQuery(String cypher) {neo4jClient.query(cypher).run();}
}
代码执行成功后再控制台输入命令MATCH(n)RETURN n
任意点击节点,可看到节点属性
4.1.4 问诊
MedicalController(模拟病人向医生阐述个人病情,男性23岁,出现体温高与头疼等症状)
public class MedicalController {@Autowiredprivate MedicalRAGService medicalRAGService;@GetMapping("/call")public MedicalRAGService.DiagnosisResponse call() {MedicalRAGService.PatientQuery query = new MedicalRAGService.PatientQuery();query.setSymptoms(List.of("体温高", "头疼"));query.setPatientProfile("男 23");return medicalRAGService.processMedicalQuery(query);}}
MedicalRAGService(问诊RAG服务)
- 核心流程: 检索与症状匹配的疾病 -> 向量搜索扩展相关疾病 -> 获取每种疾病的推荐治疗方案 -> 生成诊断报告
/*** RAG服务*/
@Service
public class MedicalRAGService {@Autowiredprivate DiagnosticService diagnosticService;@Autowiredprivate VectorSearchService vectorSearch;private ChatClient chatClient;public MedicalRAGService(ChatClient.Builder builder) {this.chatClient = builder.build();}public DiagnosisResponse processMedicalQuery(PatientQuery query) {// 1. 检索与症状匹配的疾病List<Disease> possibleDiseases = diagnosticService.inferDiseases(query.getSymptoms(), 3); // 3跳检索// 2. 向量搜索扩展相关疾病if (possibleDiseases.size() < 3) {String symptomsText = String.join(", ", query.getSymptoms());List<Disease> relatedByVector = vectorSearch.findRelatedDiseases("症状包括: " + symptomsText, 3);possibleDiseases.addAll(relatedByVector);}Set<Disease> uniqueDiseases = new HashSet<>(possibleDiseases);// 3. 获取每种疾病的推荐治疗方案Map<Disease, List<Drug>> treatments = uniqueDiseases.stream().collect(Collectors.toMap(disease -> disease,disease -> diagnosticService.recommendTreatments(disease.getCode(), query.getPatientProfile())));// 4. 生成诊断报告String diagnosisReport = generateReport(query, uniqueDiseases, treatments);return new DiagnosisResponse(diagnosisReport, uniqueDiseases, treatments);}private String generateReport(PatientQuery query,Set<Disease> diseases,Map<Disease, List<Drug>> treatments) {String context = buildContextString(diseases, treatments);String prompt = """作为医疗诊断助手,请根据以下信息为患者生成诊断建议:患者主诉: %s患者档案: %s医学知识上下文:%s请用专业但易懂的语言:1. 列出最可能的3种诊断2. 解释每种诊断的可能性依据3. 给出治疗建议4. 注明需要进一步检查的项目""".formatted(String.join(", ", query.getSymptoms()),query.getPatientProfile(),context);return chatClient.prompt(prompt).call().content();}private String buildContextString(Set<Disease> diseases,Map<Disease, List<Drug>> treatments) {StringBuilder sb = new StringBuilder();diseases.forEach(disease -> {sb.append("疾病: ").append(disease.getName()).append("\n");sb.append("描述: ").append(disease.getDescription()).append("\n");sb.append("推荐治疗:\n");treatments.get(disease).forEach(drug -> {sb.append("- ").append(drug.getName()).append(" (").append(drug.getMechanism()).append(")\n");});sb.append("\n");});return sb.toString();}@Datapublic static class PatientQuery {private List<String> symptoms;private String patientProfile; // 包含年龄、性别、病史等}@Data@AllArgsConstructorpublic static class DiagnosisResponse {private String diagnosis;private Set<Disease> possibleDiseases;private Map<Disease, List<Drug>> treatments;}
}
检索与症状匹配的疾病(多跳查询关联疾病)
public List<Disease> inferDiseases(List<String> symptomNames, int hops) {// 1. 向量搜索找到最匹配的症状节点List<Symptom> symptoms = vectorSearch.findSimilarSymptoms(symptomNames);// 2. 多跳查询关联疾病String cypher = """CALL apoc.cypher.run('MATCH (s:Symptom)-[rels:HAS_SYMPTOM*1..'+$hops+']-(d:Disease)WHERE s.code IN $symptomCodesUNWIND rels AS rWITH d, sum(r.probability) AS totalScore,count(DISTINCT r) AS symptomMatchCountRETURN dORDER BY totalScore * symptomMatchCount DESCLIMIT 10', {symptomCodes: $symptomCodes}) YIELD valueRETURN value.d AS d""";List<String> symptomCodes = symptoms.stream().map(Symptom::getCode).collect(Collectors.toList());return neo4jTemplate.findAll(cypher, Map.of("symptomCodes", symptomCodes, "hops", hops), Disease.class);}
// 症状向量搜索public List<Symptom> findSimilarSymptoms(List<String> symptomDescriptions) {// 生成症状描述的嵌入向量/* float[][] embeddings = symptomDescriptions.stream().map(desc -> embeddingModel.embed(desc)).toArray(float[][]::new);*/float[] embeddings = embeddingModel.embed(String.join(",", symptomDescriptions));// 向量相似度搜索String cypher = """MATCH (s:Symptom)WHERE s.embedding IS NOT NULLWITH s, gds.similarity.cosine($embeddings, s.embedding) AS similarityWHERE similarity > 0.75RETURN s AS symptom, similarityORDER BY similarity DESCLIMIT 5""";return neo4jTemplate.findAll(cypher, Map.of("embeddings", embeddings), Symptom.class);}
向量搜索扩展相关疾病
// 疾病向量搜索 (用于扩展查询)public List<Disease> findRelatedDiseases(String description, int limit) {float[] embedding = embeddingModel.embed(description);String cypher = """MATCH (d:Disease)WHERE d.embedding IS NOT NULLWITH d, gds.similarity.cosine($embedding, d.embedding) AS similarityWHERE similarity > 0.7RETURN dORDER BY similarity DESCLIMIT $limit""";return neo4jTemplate.findAll(cypher, Map.of("embedding", embedding, "limit", limit), Disease.class);}
获取每种疾病的推荐治疗方案
// 治疗方案推理
public List<Drug> recommendTreatments(String diseaseCode, String patientProfile) {// 1. 获取基础治疗方案String cypher = """MATCH (d:Disease {code: $code})-[:TREATED_WITH]->(drug:Drug)RETURN drugORDER BY drug.effectiveness DESC""";List<Drug> drugs = neo4jTemplate.findAll(cypher, Map.of("code", diseaseCode), Drug.class);// 2. 应用禁忌症过滤规则return drugs.stream().filter(drug -> !hasContraindications(drug, patientProfile)).collect(Collectors.toList());
}private boolean hasContraindications(Drug drug, String profile) {// 简化的规则推理 - 实际应使用更复杂的规则引擎return drug.getContraindications().stream().anyMatch(contra -> profile.contains(contra));
}
生成诊断报告
利用Neo4j检索出数据后作为调用大模型提示词中的医学知识上下文
做信息补充
“diagnosis”: "根据患者的主诉和档案信息,这位23岁男性患者主要表现为体温升高和头痛。以下是三个最可能的诊断:
1. 偏头痛\n可能性依据:虽然偏头痛通常不伴有发热,但主诉中的头痛是典型症状之一。如果患者曾有偏头痛病史或头痛伴随恶心、畏光等症状,这种诊断的可能性将增加。
治疗建议:推荐使用舒马普坦,这是一种5-HT1受体激动剂,可以通过收缩血管来缓解偏头痛。
2. 细菌性脑膜炎\n可能性依据:患者表现为高热和头痛,这些是细菌性脑膜炎的常见症状。虽然没有提到其他症状,如颈部僵硬、恶心或呕吐,但这些症状可能会随着疾病进展而出现。由于脑膜炎的危急性,需高度重视。
治疗建议:建议立即开始使用青霉素进行治疗,青霉素是对细菌性脑膜炎有效的抗生素。
3. 感冒或流感\n可能性依据:流感或重感冒通常会导致发热和头痛,特别是在流感季节或患者有接触病人的历史时。其他常见症状如咳嗽、喉咙痛、疲劳等,也可能存在但未被患者强调。
治疗建议:建议对症治疗,包括多休息、补充液体和使用非处方药如对乙酰氨基酚来缓解症状。
进一步检查\n- 血常规检查:以识别潜在的感染迹象,如白细胞增多。\n- 腰椎穿刺:用于确诊或排除脑膜炎,特别是细菌性脑膜炎。\n- 头部影像学检查:如CT或MRI,以排除其他可能引起头痛的病因,如脑出血或肿瘤。
这些诊断和治疗建议是基于当前提供的信息。建议患者立即就医以获得全面评估和诊断。",
“possibleDiseases”: [
{"code": "DI001","name": "偏头痛","description": "反复发作的头痛疾病,常伴随恶心、畏光","riskFactors": ["遗传因素","压力","激素变化"]}
4.2 Agentic RAG
Agentic RAG(智能体驱动的检索增强生成)是传统RAG架构的进化,通过引入智能体(Agent)机制,使系统具备动态决策、多工具协作和持续学习能力。
上图是Agentic RAG概要流程,其中基础RAG流程
可以是前面提到的任意一种RAG架构模式,多Agent协作等待下一篇再阐述;针对路由Agent给一个简单示例,通过大模型提示词的【设计】,做决策分支。
@Service
public class AgenticService {private final ChatClient chatClient;private final RagService ragService;public AgenticService(ChatClient chatClient, RagService ragService) {this.chatClient = chatClient;this.ragService = ragService;}public String processQuery(String query) {// 1. 分析查询类型String analysis = analyzeQueryType(query);// 2. 根据分析结果决定处理方式if (analysis.contains("需要检索")) {return ragService.retrieveAndGenerate(query);} else if (analysis.contains("简单回答")) {return chatClient.call(query).getResult().getOutput().getContent();} else {return "无法确定如何处理此查询。";}}private String analyzeQueryType(String query) {PromptTemplate promptTemplate = new PromptTemplate("""分析以下查询的类型,并返回分析结果:- 如果查询需要基于特定知识或事实回答,返回"需要检索"- 如果查询是通用问题或闲聊,返回"简单回答"查询: {query}分析结果:""");Prompt prompt = promptTemplate.create(Map.of("query", query));return chatClient.call(prompt).getResult().getOutput().getContent();}
}
结语:站在知识巨人肩膀上
苏格拉底曾说:“我唯一知道的就是自己的无知。”这句话同样适用于大模型,尽管它们拥有海量通用知识,但在特定领域仍可能“无知”。而RAG技术,正是解决这一问题的关键之一,通过动态检索外部知识库,并结合大模型的推理能力,我们让AI不仅“博学”,更能“专精”。