ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LangChain4j 向量检索:文本怎么变成可搜索的坐标

LangChain4j 向量检索:文本怎么变成可搜索的坐标

LangChain4j 向量检索:文本怎么变成可搜索的坐标

作者:鱼宵 | LangChain4j实战精通营 · 第 7 篇

你在公司知识库搜"我最近吃不下饭",系统一个相关文档都没返回。可你翻半天发现,FAQ 里明明写着"食欲不振请少食多餐"——意思一模一样,就是字面上没有"吃饭"俩字。这就是传统搜索的死穴:它认字,不认意思。

这篇文章就解决这件事。我们用一个跑在你本机 CPU 上的小模型,把每句话变成一串数字(向量),再按"意思像不像"去检索——亲眼看到那句字面带"吃饭"的,反而排在真正对的答案后面。全程不需要 DeepSeek、不需要 API Key、不上传你的数据。代码全在仓库lesson-07/,clone 下来跑一遍,十几秒下载完模型,现象当场就出来。

一、核心原理:一句话怎么变成 384 个数字

一句话:Embedding(嵌入)就是给每句话发一张"语义身份证"——意思越像的话,身份证号在向量空间里住得越近。

1. 向量:语义指纹

大模型是文字模型,不认识数字;但计算机判断"相似",靠数字最快。Embedding干的事就是把一段文本映射成一个固定长度的浮点数数组——本课这个模型输出384 个数字,就是这句话的"语义指纹"。

关键性质就一条:语义相近 ⇔ 向量相近。"我没胃口"和"他吃不下饭"字面几乎不重叠,向量却很接近;"红烧肉"和"人工智能"离得老远。

2. 嵌入模型 ≠ 大模型

很多人以为向量化又要调一次 DeepSeek——不用。本课用的all-MiniLM-L6-v2是个开源小模型,下载到本机后一切计算都在内存里跑。类比成两台机器:大语言模型是"说话的",要联网、按 token 收费;嵌入模型是"指纹采集仪",只负责把文本拍成指纹,本地、免费、你的文档不出网。对企业知识库来说,"数据不上传"这一条就是硬优势。

3. 余弦相似度:只比方向,不比长短

余弦相似度算的是两个向量夹角的余弦值,范围[-1, 1]。越接近 1 = 方向越一致 = 意思越像;接近 0 = 不相干。

cos(a, b) = (a · b) / (|a| × |b|) // 点积 除以 两个模长的乘积

类比指南针:不关心两根针有多长,只看它们指向的方向差多少。指向几乎重合就是一个意思,指向垂直就是不相干。

为什么不用直线距离(欧氏距离)?因为句子越长,向量整体数值越大、模长越大——我们只想比语义方向,不想被句子长短带偏。余弦先除以模长,等于"只比方向",所以是文本检索的标配。

4. 语义检索 vs 关键词检索

对比关键词检索(LIKE / ES match)语义检索(向量最近邻)
怎么找逐字符/分词命中把查询也转向量,找余弦最高的
同义句字面没那个词就漏意思对就能命中
类比查字典,必须真看到那个字问聪明朋友,说"没胃口"他懂是"食欲不振"

这就是 RAG 能"听懂人话"的地基:它不是在文档里搜关键词,而是在语义空间里找最近邻。

二、动手:编译 + 跑 fat jar

环境:Windows + JDK 17。本课连 API Key 都不用配。

# 第 1 步:环境检查(本课不需要 Key)java-version# 第 2 步:编译cd langchain4j-journey\lesson-07$env:JAVA_HOME="C:\Program Files\Java\jdk-17"mvn clean install-DskipTests# 第 3 步:运行java-Dfile.encoding=UTF-8-jar target\lesson-07-1.0.0.jar

第一次跑要有点耐心:程序会自动下载 onnx 模型文件(20~30MB),十几秒到一两分钟,看到 DJL/tokenizers 日志就是在加载,别中断。下完缓存到用户目录,第二次起秒开。

三、关键代码:一个主类,全流程拆给你看

工程就一个 Java 文件。核心动作三步:建模型 → 每句语料转向量 → 查询句转向量、跟每句算 cosine 排序。完整贴出来:

packagecom.langchain4j.lesson07;importdev.langchain4j.data.embedding.Embedding;importdev.langchain4j.model.embedding.EmbeddingModel;importdev.langchain4j.model.embedding.onnx.allminilml6v2.AllMiniLmL6V2EmbeddingModel;importorg.springframework.boot.CommandLineRunner;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;importjava.util.ArrayList;importjava.util.List;/** * 第 7 课:向量化与语义检索。全程本地跑,不需要 API Key。 * 运行:java -Dfile.encoding=UTF-8 -jar target\lesson-07-1.0.0.jar */@SpringBootApplicationpublicclassEmbeddingDemoApplicationimplementsCommandLineRunner{publicstaticvoidmain(String[]args){SpringApplication.run(EmbeddingDemoApplication.class,args);}@Overridepublicvoidrun(String...args){// ① 创建本地嵌入模型:new 出来就能用,它就是那台"语义指纹机"EmbeddingModelmodel=newAllMiniLmL6V2EmbeddingModel();// 5 句语料,故意埋一个"字面有吃饭、但意思相反"的 c2 当对照组String[]corpus={"我今天心情不好,不想吃饭",// c1:没胃口"我刚吃完饭,现在正在散步消食",// c2:字面有"吃饭",但意思相反"他最近胃口不好,什么都咽不下去",// c3:没"吃饭"二字,意思却和 c1 一样"这家餐厅的招牌菜是红烧肉","人工智能正在改变医疗行业"};// ② 每句转向量,存起来List<Embedding>embeddings=newArrayList<>();for(Strings:corpus){embeddings.add(model.embed(s).content());// embed(文本) = 文本转向量}// ③ 语义检索:查询句也转向量,跟每句算 cosine,按分数排序Stringquery="我这两天没什么胃口,看到饭菜就腻";// 这句没有"吃饭"二字EmbeddingqueryEmb=model.embed(query).content();double[]scores=newdouble[corpus.length];for(inti=0;i<corpus.length;i++){scores[i]=cosine(queryEmb.vector(),embeddings.get(i).vector());}// 语料只有 5 句,冒泡排序把下标按分数从高到低排好Integer[]order={0,1,2,3,4};for(inta=0;a<order.length-1;a++){for(intb=a+1;b<order.length;b++){if(scores[order[b]]>scores[order[a]]){intt=order[a];order[a]=order[b];order[b]=t;}}}// 按分数从高到低输出——最高的就是"意思最接近"的那一句System.out.println("相似度排名(从高到低):");for(intrank=0;rank<order.length;rank++){intidx=order[rank];System.out.printf(" 第%d名 [%.3f] c%d:%s%n",rank+1,scores[idx],idx+1,corpus[idx]);}}/** * 余弦相似度:cos = 点积 / (模长 × 模长),范围 [-1,1]。 * 先除以模长,就消除了句子长短带来的数值差异,只比方向。 */privatedoublecosine(float[]a,float[]b){doubledot=0,normA=0,normB=0;for(inti=0;i<a.length;i++){dot+=a[i]*b[i];// 点积normA+=a[i]*a[i];// a 的模长平方normB+=b[i]*b[i];// b 的模长平方}returndot/(Math.sqrt(normA)*Math.sqrt(normB));}}

这段代码里没有任何一步用到"关键词匹配"——所谓检索,就是"查询句向量 vs 每句语料向量算 cosine,取最大的"。model.embed(...)是唯一的"指纹机入口",以后想换中文专用模型,业务代码只改这一行。

上面为了讲解只贴了"转向量 + 检索排名"这条主线,仓库里的完整主类还多打印了 384 维前 5 个数、两两相似度矩阵和"最像的一对"——clone 下来跑一遍对照下面的实测输出。

四、实测输出:字面有"吃饭"的,反而排第三

以下是 2026-10-05 本机真实运行(all-MiniLM-L6-v2,本地 CPU)。先看两两余弦相似度矩阵——c1 和 c3 是全矩阵非对角线上最大的一对:

c1 c2 c3 c4 c5 c1 1.000 0.522 0.643 0.468 0.441 c2 0.522 1.000 0.533 0.578 0.545 c3 0.643 0.533 1.000 0.573 0.592 c4 0.468 0.578 0.573 1.000 0.603 c5 0.441 0.545 0.592 0.603 1.000

对角线是自己和自己 = 1.000。非对角线最大的c1–c3 = 0.643——正是"不想吃饭"和"什么都咽不下去"这对同义句,字面几乎不重叠,向量却最近。

再看最关键的语义检索——查询句是"我这两天没什么胃口,看到饭菜就腻",排名如下:

查询句:我这两天没什么胃口,看到饭菜就腻 (注意:这句里没有「不想吃饭」三个字,但意思和 c1/c3 一样) 相似度排名(从高到低): 第1名 [0.736] c1:我今天心情不好,不想吃饭 第2名 [0.730] c3:他最近胃口不好,什么都咽不下去 第3名 [0.647] c2:我刚吃完饭,现在正在散步消食 第4名 [0.586] c4:这家餐厅的招牌菜是红烧肉 第5名 [0.534] c5:人工智能正在改变医疗行业

看出门道了吗?查询句字面上没有"吃饭"俩字,却把 c1(不想吃饭)、c3(咽不下去)顶到了前两名(0.736 / 0.730)。反观 c2"我刚吃完饭"——它字面就带着"吃饭",相似度却只有 0.647,排到第三,因为它意思相反(刚吃完 vs 没胃口)。

换成关键词检索LIKE '%吃饭%'呢?只会孤零零返回 c2,把真正对的 c1/c3 全漏掉。这就是"按语义而非字面匹配"的铁证。

诚实提一句:all-MiniLM-L6-v2 偏英文,中文上绝对相似度整体挤在 0.4~0.7,拉不开差距。但相对排序完全正确——教学看的就是排序。生产中文知识库换bge-small-zh这类专用模型即可,接口不变,换个 artifact。

五、挑战题:改一改,看看会怎样

  1. ⭐换个查询句:把query改成"餐厅里哪道菜最招牌"重跑,看第一名是不是从 c1 切到了 c4。查询句一变、排序跟着变——这就是检索在"按意思"响应。答案就在主类那一行query。
  2. ⭐⭐加一句新语料:往corpus里塞一句"我最近失眠,夜里翻来覆去睡不着",重跑相似度矩阵,看它和谁最像、大概多少分。跑出来你就摸到"向量空间里同类句子自然聚堆"的感觉。
  3. ⭐⭐⭐写个关键词检索做对照:加一个String.contains("吃饭")的方法找出带"吃饭"的句子,和上面语义检索结果并排看——亲眼确认关键词检索只捞到 c2,漏了 c1/c3。

六、生产环境进阶:三个加分项

1. 中文场景换中文嵌入模型。all-MiniLM 是教学用的,偏英文。真上中文知识库(FAQ、规章制度),换bge-small-zh之类的专用模型,相似度拉得更开。好在 LangChain4j 的EmbeddingModel是接口,换实现不改业务代码。

2. 别误以为检索要花钱。本课连langchain4j-open-ai依赖都没加——嵌入全程本地跑。RAG 的成本大头在末尾那步"大模型生成回答",检索环节是零 API 费用、隐私安全。

3. 向量库别拿 List 硬撑。本课为了讲清楚用内存List手工算 cosine。语料上到几千几万条,就该上InMemoryEmbeddingStore,生产换 PostgreSQL-pgvector 或 Milvus——这是下一课和更后面的事。

七、面试回答模板

面试官:什么是 Embedding?为什么"意思相近的句子向量也相近"?

一句话:把文本映射成固定长度的浮点数向量(本模型 384 维),核心性质是语义相近 ⇔ 向量相近。展开:嵌入模型像一台"语义指纹机",任何一句话塞进去吐一串数字当指纹;"没胃口"和"吃不下饭"字面不重叠,向量却很近。本课实测 c1–c3 相似度 0.643,是全语料最像的一对。(指向本课第一节 + lesson-07 相似度矩阵)

追问:余弦相似度怎么衡量两句话像不像?为什么用夹角不用直线距离?

cos = 点积/(模长×模长),范围 [-1,1],越接近 1 越像。用夹角是因为向量模长会被句子长短带偏,我们只关心语义方向;余弦先除以模长,等于"只比方向、不比长短"。类比指南针——不看针多长,只看指向差多少。(指向本课第三节cosine()方法)

追问:语义检索和关键词检索的本质区别?

关键词靠字面/分词命中(LIKE、ES match),没那个词就漏同义句;语义靠向量最近邻,字面不重叠也能命中。本课铁证:查询"没胃口看到饭菜就腻",字面带"吃饭"的 c2 只排第三(0.647),真正对的 c1/c3 反而被顶到前两名——换成LIKE '%吃饭%'只会返回 c2。(指向本课第四节实测输出)

八、总结表

坑现象解法
artifact 老名字 404Maven 报 Could not find artifact用langchain4j-embeddings-all-minilm-l6-v2:1.21.0-beta31
首次跑像卡死半天没输出在下载 onnx 模型(20~30MB),等它下完会缓存
绝对相似度才 0.5以为模型坏了all-MiniLM 偏英文,看相对排序;中文换 bge-small-zh
字符串里套中文引号编译错编译失败Java 字符串内别嵌套 ASCII",引用词用「」
以为要联网调大模型纠结 API Key嵌入纯本地跑,本课连 open-ai 依赖都没加

九、关于这个系列

本文是「Java 后端实战精通营」系列第 7 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。

👉LangChain4j 实战精通营(10 课):https://gitee.com/j67mk2/langchain4j-journey

  • 本文对应源码位置:lesson-07/(控制台工程,单个EmbeddingDemoApplication主类:建本地嵌入模型 + 5 句语料向量化 + 余弦相似度矩阵 + 语义检索排名)

系列文章一览(按发布顺序):

篇主题
1LangChain4j 第一个对话程序:手写 LLM 调用链路,看清模型怎么"开口"
2LangChain4j 提示词工程:模板 + few-shot + 温度,提示词从写死到复用
3LangChain4j 结构化输出:让模型吐出 Java 对象,JSON 不再手写解析
4LangChain4j 对话记忆:让模型记住聊过什么,三种方式实测对比
5LangChain4j 流式输出:打字机效果 + SSE,回答不再干等三秒
6LangChain4j 工具调用:Function Calling 让模型动手查数据,多工具连调实测
7LangChain4j 向量检索:文本怎么变成可搜索的坐标
8LangChain4j RAG 流水线:手写问答全流程,给 AI 开卷考试
9LangChain4j Agent:多步推理自主调用,放手让"实习生"自己安排工作
10LangChain4j 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,验收 5 项全过

下一篇预告:《LangChain4j RAG 流水线:手写问答全流程,给 AI 开卷考试》——今天只做了"把几句话转向量、算相似度"这个最小内核。下一课把它拼成完整 RAG:加载文档 → 切分 → 入库 → 提问时检索相关片段 → 塞进提示词让大模型"看着资料回答",还带拒答兜底。

跑完有任何报错,把控制台输出发评论区,一起排查。


标签建议:LangChain4j、Embedding、语义检索
摘要建议(≤256 字):用 LIKE ‘%吃饭%’ 搜知识库,只会返回字面带"吃饭"的句子,真正同义的"没胃口""咽不下去"全被漏掉。本文用本地 all-MiniLM-L6-v2 嵌入模型把中文句子转成 384 维向量,手写余弦相似度,实测"没胃口"查询把 c1/c3 顶到前两名、字面带"吃饭"的反而排第三。全程本地零 API 成本,附换查询句、加语料等挑战题与面试模板,源码在 gitee lesson-07 可 clone 直接跑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进