摘要:本文基于 Spring AI 与智谱 AI 的 Embedding、Chat 模型,完整演示了一套轻量级 RAG 实现方案。文章先说明文本向量化与余弦相似度检索的基本原理,再依次给出 pom 依赖、配置文件、相似度计算工具类、查找相似文本的 Service、文档向量化的手动切分与自动拆分两种实现,最后通过接口层和启动类把整条链路串起来,帮助读者快速跑通“文档向量化—相似度检索—大模型整理回答”的完整流程。
目录
基本原理阐述
项目
pom
配置
工具类
查找相似文本service
文档向量化(手动切分)
古代诗歌常用意向.txt
文档向量化(自动拆分)
接口
启动类
基本原理阐述
文档文本向量化后保存在内存中,用户问题向量化后,二者进行余弦相似度计算,找出相似文本块交给大模型整理并回复答案。
项目
官方文档和配置获取方式参考:02.springboot项目对接国内模型实现对话聊天-CSDN博客
pom
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>com.erbao</groupId> <artifactId>SpringAI</artifactId> <version>1.0-SNAPSHOT</version> </parent> <artifactId>EmbeddingModels</artifactId> <properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-zhipuai</artifactId> </dependency> </dependencies> </project>配置
spring.application.name=SpringAIEmbedding server.port=8080 #使用 智普AI Embedding 模型,需要在pom.xml中引入对应依赖 spring.ai.zhipuai.api-key=your-api-key spring.ai.zhipuai.base-url=https://open.bigmodel.cn/api/paas spring.ai.zhipuai.embedding.options.model=Embedding-3 #使用 智普AI Chat 模型,需要在pom.xml中引入对应依赖 spring.ai.zhipuai.chat.options.model=GLM-4.7-Flash值得一提的是,智谱目前有免费模型提供调用。
工具类
计算余弦相似度
package com.erbao.utils; /** 相似度计算工具类 */ public class ComputeSimilarity { /** @param a 向量a @param b 向量b @return 余弦相似度值 余弦相似度实现,公式的实现 计算两个向量之间的余弦相似度,数值范围在 [-1, 1] 之间 相似度越高,越接近1,越接近0,越不相似 */ public static double cosineSimilarity(float[] a, float[] b) { double dot = 0, na = 0, nb = 0; for (int i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; } return dot / (Math.sqrt(na) * Math.sqrt(nb)); } }查找相似文本service
- 首先把文本库向量化保存在内存中
- 用户问题进行向量化
- 用户问题和文本库向量计算余弦相似度(实际应用中是向量库检索实现)
- 找出最相似文本块返回
package com.erbao.service; import com.erbao.utils.ComputeSimilarity; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.stereotype.Service; import java.util.List; @Service public class EmbeddingService { private EmbeddingModel embeddingModel; private final List<float[]> docVectors; // 1. 准备知识库文本内容 private final List<String> docs = List.of( "美食非常美味,服务员也很友好。", "这部电影既刺激又令人兴奋。", "阅读书籍是扩展知识的好方法。" ); public EmbeddingService(EmbeddingModel embeddingModel) { this.embeddingModel = embeddingModel; // 2. 启动时,将所有文档向量化 this.docVectors = this.embeddingModel.embed(docs); } /** 输入用户查询,返回最相似文档的索引 (使用余弦相似度计算) @param query 用户输入的查询文本 @return 最相似的知识库文本 */ public String queryBestMatch(String query) { //将用户的输入通过EmbeddingModel转换成向量 float[] queryVec = embeddingModel.embed(query); int bestIdx = -1; //记录目前最匹配文档在列表中的索引位置 double bestSim = -1; //记录目前的最高相似度 // 遍历所有文档对应的向量,计算与查询向量的相似度 for (int i = 0; i < docVectors.size(); i++) { //计算余弦相似度 double sim = ComputeSimilarity.cosineSimilarity(queryVec, docVectors.get(i)); if (sim > bestSim) { bestSim = sim; bestIdx = i; } } System.out.println("最高相似度:bestSim=" + bestSim); return docs.get(bestIdx); } }文档向量化(手动切分)
文档
- 根据文档分隔符切分成不同的文档块
- 对文档块向量化
- 用户问题向量化
- 用户问题和文档块的向量做余弦相似度计算,找出最相似的两个文档块
- 文档块和用户问题交给模型,模型整理答案
package com.erbao.service; import com.erbao.utils.ComputeSimilarity; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; /** RAG 服务,用于处理基于本地文档的问题回答。 自动拆分文档为多个 chunk。 */ //@Service public class RagService { private final EmbeddingModel em; //嵌入模型,用于生成文本的向量 private final ChatClient chatClient; // 聊天客户端,用于与 AI 进行交互 private final List<String> docs = new ArrayList<>();//存储本地文档内容 private final List<float[]> vectors = new ArrayList<>();//存储本地文档向量 public RagService(EmbeddingModel embeddingModel, ChatClient.Builder chatBuilder) throws IOException { this.em = embeddingModel; this.chatClient = chatBuilder.build(); // 创建 智普AI Chat 聊天客户端 // 加载本地文档 Resource res = new ClassPathResource("古代诗歌常用意向.txt"); String content = new String(res.getInputStream().readAllBytes(), StandardCharsets.UTF_8); // 分割文档内容并生成向量 for (String part : content.split("----")) { System.out.println("part: " + part); if (part.isBlank()) continue; docs.add(part);// 存储切分的文档内容 vectors.add(em.embed(part)); // 将文档生成 embedding 并存储,这是文本自动chunk后的每个部分的向量 } } /** 输入用户问题,返回 AI 回答(基于 RAG 模型) 生成用户问题的向量 找到与用户问题最相似的两个文档 构建上下文(用户问题+这两个文档),包含这两个文档 调用 ChatClient 与 AI 模型交互,获取回答 @param q 用户输入的问题文本 @return AI 回答的文本 */ // 对用户输入的问题进行回答 public String answer(String q) { // 生成用户问题的向量 float[] qv = em.embed(q); // 最相似两个文档的索引 int index1 = -1, index2 = -1; // 最相似两个文档的相似度 double v1 = -1, v2 = -1; // 找到最相似的两个文档 for (int i = 0; i < vectors.size(); i++) { // 计算用户问题向量与切分的每个文档向量的余弦相似度 double sim = ComputeSimilarity.cosineSimilarity(qv, vectors.get(i)); if (sim > v1) { index2 = index1; v2 = v1; index1 = i; v1 = sim; } else if (sim > v2) { index2 = i; v2 = sim; } } //获取两个最相似文档的内容,拼接在一起作为上下文 String ctx = docs.get(index1) + (index2 >= 0 ? "\n---\n" + docs.get(index2) : ""); //构建 AI 模型的提示信息,包含上下文和用户问题 String message = "以下是知识内容:\n" + ctx + "\n请基于上述知识回答用户问题:“" + q + "”"; // 使用 ChatClient 流式构建 var response = chatClient .prompt() .system("你是知识助手,结合上下文回答问题") // 添加系统提示 .user(message) // 用户 + 上下文 .call(); // 发起同步调用 // 获取内容文本 return response.content(); } }古代诗歌常用意向.txt
---- 1.植物类 草:生命力强、生生不息、希望、荒凉、离恨、卑微。 黄叶:凋零、别离、美人迟暮、新陈代谢。 绿叶:生命力、长久、活力、希望。 梧桐:悲秋、凄苦、感伤,还可表达高贵。 芭蕉:孤独、离别。 柳:送别、思亲、挽留、操守(介之推)。 花开:青春、希望、人生的美好。 花落:惜春、凋零、失意、事业的挫折、对美好事物的留恋。 禾黍:黍离之悲(国家衰败)。 杨花:漂泊、流散、无情。 牡丹:富贵、美好、憧憬。 莲:怜、爱、纯洁。 梅子:少女怀春。 丁香:愁思、情结。 虞美人:罂粟科一年生草本花卉,亦称丽春、赛牡丹。相传此花系西楚霸王项羽爱妾虞姬自刎垓下碧血所化,故有闻虞兮歌而起舞之说。历代文人雅士歌咏此花,多涉及这一悲壮传说。 红豆:《南州记》称为海红豆,《本草》称其为“相思子”。常用以象征爱情或相思。 豆蔻:豆蔻是一种多年生草本植物。后来称女子十三四岁的年纪为豆蔻年华。 岁寒三友:指古诗文中经常提到的松、竹、梅。松,是耐寒树木,终冬不凋,常被看做刚正节操的象征。竹,也经冬不凋,且自成美景,它刚直、谦逊、不卑不亢,潇洒处世,常被看做不同流俗的高雅之士的象征。梅,迎寒而开,美丽绝俗,是坚忍不拔的人格的象征。花中四君子:古诗人中常提到的梅、兰、竹、菊。兰,一则花朵色淡香清,二则多生于幽僻之处,故常被看做是谦谦君子的象征。菊,它不仅清丽淡雅、芳香袭人,而且具有傲霜斗雪的特征;它艳于百花凋后,不与群芳争艳,故历来被用来象征恬然自处、傲然不屈的高尚品格。(“梅、竹”见上条。) ---- 2.动物类 鹧鸪:行不得也哥哥、离愁、物是人非。 燕:家园、物是人非。 鹰:自由、强劲、人生搏击、事业成功。 乌鸦:小人、奸臣、俗客庸夫、哀伤。 沙鸥:飘零、无依、伤感。 鱼:自由、惬意。 鲤鱼:尺素书、信。 寒蝉:悲秋、高洁之士。 鸡狗:田园、世俗生活。 猿猴:凄清、哀伤、荒远。 马:追求、仕途、漂泊(瘦马)。 鸿雁:《汉书·苏武传》载有大雁传书事,后因以之喻书信。 鸿鹄:鸿鹄飞得很高,常用来比喻志气高远的人。 杜鹃:杜鹃鸟俗称布谷,也叫子归、子鹃。春夏季节,杜鹃彻夜不停啼鸣,啼声清脆而短促,唤起人们多种情思。杜鹃口腔上皮和舌部都为红色,古人误以为它们啼得满嘴流血,凑巧杜鹃高歌之时,正是杜鹃花开之际,人们见杜鹃花开得那样鲜红,便把这种颜色说成是杜鹃啼的血。比翼鸟:传说中的一种鸟,雌雄老在一起飞,古典诗歌里用作恩爱夫妻的比喻。 ---- 3.景象类 草原:辽阔、人生境界、人的胸襟。 海:辽阔、深邃、力量、胸襟、人生的起伏。 江水:时光流逝、岁月短暂、愁苦绵长。 烟雾:情感朦胧、命运惨淡、前途迷惘、理想幻灭。 小雨:春景、生机活力、希望、伤感、潜移默化式的教化。 暴雨:热情、动荡、巨变、政治斗争、恶势力、荡涤污秽的力量。 狂风:作乱、恶势力、摧毁旧世界的力量。 东风:春风、欢愉、希望、美好、时光。 西风:悲秋、落寞、惆怅、衰败、游子思归。 霜:打击、考验、变易、挫折、社会环境恶劣,恶势力猖狂、人生路途坎坷。 雪:纯洁、美好、寒冷、环境恶劣、恶势力。 露:人生短促、生命易逝。 阴:压抑、愁苦、寂寞。 晴:欢景、光明、希望。 云:游子、飘泊、归隐、轻浮。 夕阳:迟暮、失落、消沉、美好而短暂的事物。 月亮:圆满、缺憾、思乡、思亲、变易、离别。 破晓:清静、迷茫、希望。 暮夜:愁思、怀旧、孤独、清冷。 天地:人的渺小、人生短暂、心胸广阔、情感孤独。秋水:秋水,喻指眼睛,形容盼望的迫切。 ---- 4.人文类 英雄:历史风云人物、功业有成者、令人追慕者、让人自愧不如者。 小人:奸人、被鄙夷的、被鞭挞的、使人反思者。 古迹:古营垒、旧楼台,衰败、萧条、怀旧明志、昔盛今衰。 乡村:思归、世俗、田园风光、生活气息、纯朴宁静的生活。 南浦:送别、感伤。 亭:长亭短亭,送别、挽留、依恋。 都市:市井繁荣、富贵奢华、世俗名利。 仙境:飘逸、忘尘厌俗、幻想、虚幻。 酒:长久、送别、欢悦、得意、失意、愁苦。 云帆:抱负、离别、思乡。 琴瑟:(1)比喻夫妇感情和谐,亦作“瑟琴”。(2)比喻兄弟朋友的情谊。 梨园:梨园原是皇帝禁苑中的果木园圃,唐玄宗开元年间,将其作为教习歌舞的地方,且在这里培养出了大批优秀的音乐舞蹈表演人才,在历史上产生了深远的影响。因此,后世的戏曲班社常以“梨园”为其代称,戏曲艺人称“梨园弟子”。 神器:指地位、政权。 月老:传说唐朝韦固月夜里经过宋城,遇见一个老人坐着翻检书本。韦固前往窥视,一个字也不认得。向老人询问后,才知道老人是专管人间婚姻的神仙,翻检的书是婚姻簿。(见《续幽怪录·定婚店》)后来因此称媒人为月下老人,或月老。 陶朱:春秋时越国大夫范蠡的别号。相传他帮助勾践灭吴后,离开越国到陶,善于经营生计,积累了很多财富,后世因此以“陶朱”或“陶朱公”来称富商。 祝融:传说中楚国唐诉祖先,为高辛氏帝喾的火正(掌火之官),以光明四海而成为祝融,后世祀为火神。由此,火灾称为祝融之灾。 青梅竹马:出自李白的《长干行》:“郎骑竹马来,绕床弄青梅。同居长干里,两小无嫌猜。”后来用“青梅竹马”形容男女小的时候天真无邪,也指幼小时就相识的伴侣。 问鼎:《左传·宣公三年》:“楚子伐陆浑之戎,隧至于雒观兵于周疆。定王使王孙满劳楚子,楚子问鼎之大小轻重焉。”三代以九鼎为传国宝,楚子问鼎,有觊觎周室之意。后遂以问鼎比喻图谋帝王权位。 逐鹿:《汉书·蒯通传》:“且秦失其鹿,天下共逐之。”颜师古注引张晏曰:“以鹿喻帝位。”后来用逐鹿比喻群雄并起,争夺天下。 三尺:三尺,也叫“三尺法”,是法律的代名词。古代把法律写在三尺长的竹简上,所以称“三尺法”。 杜康:《说文解字·巾部》:“古者少康初作箕帚,少康,杜康也。”后即以杜康作为酒的代称。 秦晋:春秋时,秦晋两国世为婚姻,后因称两姓联姻为“秦晋之好”。 彭祖:传说故事人物,生于夏代,至殷末时已八百余岁,旧时把彭祖作为长寿的象征,以“寿如彭祖”来祝人长寿。 谢家:唐诗宋词不达意之处常用“谢家”之典,其意有: (1)用谢安、谢玄家事,意指人有风度。 (2)专指谢安侄女谢道韫之事,表示有才有貌之美女。 (3)指山水诗人谢灵运事。 婵娟:婵娟,姿态美好,多用来形容女子;因人们常喻月为美女,故月亮称为婵娟。 献芹:《列子·杨朱》有一个故事说,从前有一个人在乡里的豪绅面前大肆吹嘘芹菜如何好吃,豪绅尝了之后,竟“蜇于口,惨于腹”。后来就用“献芹”谦称赠人的礼品菲薄,或所提的建议浅陋。也说“芹献”。 执牛耳:古代诸侯订立盟约,要每人尝一点牲血,主盟的人亲自割牛耳取血,故用“执牛耳”指盟主。后来指在某一方面居领导地位。文档向量化(自动拆分)
- 读取文档内容
- 对拆分规则进行设置,字符数、token 数
- 拆分块然后向量化
- 以下逻辑同手动拆分
package com.erbao.service; import com.erbao.utils.ComputeSimilarity; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.reader.TextReader; import org.springframework.ai.transformer.splitter.TokenTextSplitter; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.TreeSet; import java.util.stream.Collectors; /** RAG 服务 V2,用于处理基于本地文档的问题回答。 手动拆分文档为多个 chunk,每个 chunk 大小为 800 token,最小 400 字符。 */ @Service public class RagServiceV2 { private final EmbeddingModel em;//嵌入模型,用于生成文本的向量 private final ChatClient chatClient;// 聊天客户端,用于与 AI 进行交互 private final List<String> docs = new ArrayList<>(); //存储本地文档内容 private final List<float[]> vectors = new ArrayList<>(); //存储本地文档向量 public RagServiceV2(EmbeddingModel embeddingModel, ChatClient.Builder chatBuilder) throws Exception { this.em = embeddingModel; this.chatClient = chatBuilder.build();// 创建 智普AI Chat 聊天客户端 // 1. 从 resources 读取长文本文档 var resource = new ClassPathResource("古代诗歌常用意向.txt"); // 创建 TextReader 并读取文档内容 TextReader reader = new TextReader(resource); List<Document> rawDocs = reader.read(); // 2. 使用 TokenTextSplitter 工具将长文本手动拆分为合理大小片段 TokenTextSplitter splitter = TokenTextSplitter.builder() .withChunkSize(800) // 拆分每段最多 800 token .withMinChunkSizeChars(400) // 每段最小允许 400 字符 .withKeepSeparator(true) // 保留分隔符,提高上下文连贯 .build(); //按照设置的参数拆分长文本为多个Chunk List<Document> chunks = splitter.apply(rawDocs); // 3. 遍历每个 chunk,生成 embedding 并存储 for (Document d : chunks) { // 获取文本内容,并去除首尾空白 String text = d.getText().strip(); System.out.println("text: " + text); if (text.isBlank()) continue; docs.add(text);// 存储切分的文档内容 vectors.add(em.embed(text));// 将文档生成 embedding 并存储 } } public String answer(String q) { // 4. 将用户提问 embedding float[] qVec = em.embed(q); // 5. 计算每个 chunk 与提问的相似度,并找出前 K 高 int K = 5; double threshold = 0.05; // 相似度阈值 // 该列表用于存储 大于阈值的 chunk 信息(chunk索引和相似度) List<IndexSim> sims = new ArrayList<>(); for (int i = 0; i < vectors.size(); i++) { // 计算用户问题向量与切分的每个文档向量的余弦相似度 double sim = ComputeSimilarity.cosineSimilarity(qVec, vectors.get(i)); if (sim >= threshold) { sims.add(new IndexSim(i, sim)); } } // 按相似度降序排序 sims.sort((a, b) -> Double.compare(b.sim, a.sim)); // 取相似度最高的前 K 个片段 List<IndexSim> topKs = sims.stream().limit(K).collect(Collectors.toList()); // 6. 扩展上下文范围:在每个匹配片段基础上加入其前后相邻片段 // 为了避免重复,使用 TreeSet 来存储所有需要的片段索引,TreeSet 会自动去重并排序,保持从小到大顺序 Set<Integer> idxSet = new TreeSet<>(); for (IndexSim is : topKs) { idxSet.add(is.index); if (is.index - 1 >= 0) idxSet.add(is.index - 1); if (is.index + 1 < docs.size()) idxSet.add(is.index + 1); } // 7. 将这些片段拼接成最终上下文内容,用于生成回答 String context = idxSet.stream() .map(docs::get) .collect(Collectors.joining("\n---\n")); String prompt = "以下是相关知识片段:\n" + context + "\n请基于这些内容回答问题:“" + q + "”"; // 8. 调用 ChatClient 生成回答 var response = chatClient .prompt() .system("你是一个知识助手,请结合上下文进行回答") .user(prompt) .call(); // 返回大模型的回答结果 return response.content(); } // 辅助类:记录 index 与 相似度 static class IndexSim { int index;// 文档索引 double sim;// 相似度 IndexSim(int index, double sim) { this.index = index; this.sim = sim; } } }接口
package com.erbao.rest; import com.erbao.service.EmbeddingService; import com.erbao.service.RagService; import com.erbao.service.RagServiceV2; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.Map; @RestController @RequestMapping("/embedding") public class EmbeddingController { @Autowired private EmbeddingModel embeddingModel; @Autowired private EmbeddingService embeddingService; @Autowired private RagServiceV2 ragService; /** 获取文本的嵌入向量 @param message 用户输入的文本 @return 包含消息和嵌入向量的Map */ @GetMapping public Map<String, Object> getEmbedding(@RequestParam(defaultValue = "给我讲个笑话") String message) { float[] embed = embeddingModel.embed(message); return Map.of("message", message, "embedding", embed); } /** 输入用户查询,返回最相似文档的索引 (使用余弦相似度计算) @param query 用户输入的查询文本 @return 最相似的知识库文本 */ @GetMapping("best_match") public Map<String, Object> queryBestMatch(@RequestParam(defaultValue = "你好") String query) { String bestMatch = embeddingService.queryBestMatch(query); return Map.of("query", query, "bestMatch", bestMatch); } /** 输入用户问题,返回 AI 回答(基于 RAG 模型) @param question 用户输入的问题文本 @return AI 回答的文本 */ @GetMapping("/ask") public Map<String, String> ask(@RequestParam("question") String question) { String answer = ragService.answer(question); return Map.of("question", question, "answer", answer); } }启动类
package com.erbao; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class EmbeddingModelsApplication { public static void main(String[] args) { SpringApplication.run(EmbeddingModelsApplication.class, args); } }