04 · RAG 核心原理:让大模型"查得到"再"答得出"

适用读者:工作多年的 Java 后端工程师,LLM/Agent 新手。
本章用 Python 讲解原理(概念是语言无关的),第 13 章会讲 Java 生态的落地(LangChain4j / Spring AI)。

本章你将学到

  • 为什么大模型需要"外挂知识"——RAG(Retrieval-Augmented Generation,检索增强生成)解决的四个核心问题;
  • RAG 与模型微调(Fine-tuning)各自适合什么场景,怎么选;
  • Embedding 的本质:把文本变成高维空间里的点,语义相近则"距离"相近;
  • 余弦相似度与 Top-K 检索的数学直觉(不堆公式,只讲直觉);
  • 向量数据库为什么需要近似最近邻(ANN)算法,主流产品怎么选;
  • 分块(Chunking)的常见策略与坑,尤其是中文分块的注意事项;
  • 如何用 Hit Rate、MRR、Recall@k、Precision@k 度量检索质量;
  • RAG 的已知局限,以及后续章节如何解决它们。

配套代码:本章末尾给出一个可运行的 Embedding + 余弦相似度示例(04_embedding_similarity.py),建议先装好环境再跑。环境与版本约定见导读。


4.1 为什么需要 RAG

4.1.1 一个很 Java 的问题:模型的"知识"存在哪里

先想一个你熟悉的类比。

你写过一个 Service 类,它编译进 JAR 包发布上线。从此以后:

  • 它知道的知识(代码、配置、依赖)在发布那一刻就冻结了;
  • 数据库换了新数据、改了新规则,只要没人发布新版本,老 JAR 就永远不知道;
  • 你想让它"了解"某个私有系统的细节,只能改代码、重新编译、重新上线。

大语言模型(LLM)本质上就是这样一个"编译好的巨型 Service",只不过它的"代码"是几十亿~上万亿参数,"发布"一次要花掉巨额算力和资金。它的知识有两个硬约束:

  1. 知识有截止日期(Knowledge Cutoff):训练数据截止于某个时间点,之后发生的事情它一概不知。2026 年的新政策、新产品、新事件,2023 年训练的模型天然不知道。
  2. 知识只来自公开语料:你公司的内部文档、私有数据、实时数据,根本没有进过它的训练集。它对你的业务领域一无所知,这是任何模型都无法绕过的边界。

所以"再喂它一些文档就能会了"这个直觉是错的——你发给它的每句话只是"上下文窗口里的一次性提示",训练集不会因此改变。

RAG 的思路因此非常朴素:模型的知识不够,那就在提问的时候,临时把相关的知识"查出来"拼进 Prompt,再让模型基于这些资料作答。

用 Java 类比:LLM 像一个内存很小、但回答能力很强的专家,RAG 就是给这个专家配了一个"查询接口 + 资料库",每次提问前先把相关"分页"捞出来递给他看。专家还是那个专家,变的只是他手里拿了什么材料。

4.1.2 大模型的幻觉(Hallucination)问题

模型生成文本是"逐个 token 预测下一个 token 的概率",它没有"查证"这个动作。当知识空白时,它不会说"我不知道",而是会流畅地编造一个听起来很合理的答案——这就是幻觉。

幻觉不是 bug,是模型的设计特征:它是语言模型,不是数据库。

RAG 抑制幻觉的原理是"有据可依":把相关事实放进上下文,模型回答时有"参照物",编造的空间被大幅压缩;同时你可以要求它"只根据提供的资料回答",并附上来源(Source Citation),实现可溯源。

4.1.3 RAG 解决的四个核心问题

问题没有 RAG 的处境RAG 的解法
知识时效性模型只知道训练截止日期之前的事知识存外部库,可随时更新、可增量写入,不需要重新训练
私有/领域数据内部文档、数据库、实时业务数据不在训练集里检索这些私有数据,拼进上下文再回答
幻觉抑制知识空白处模型自由发挥,编造答案限定"只根据检索资料回答",答案有出处
可溯源 / 合规答案黑盒,无法说明依据返回来源文档片段,可审计、可追责
成本每次知识变更都要重新微调/重新部署数据即插即用,增量更新几乎零边际成本

4.1.4 RAG 与微调(Fine-tuning)怎么选

很多刚接触的人以为"给模型喂知识 = 微调模型"。两者目标完全不同:

  • RAG 是"临时给他看材料":检索相关片段,注入 Prompt。知识在系统里、在数据库里,模型本身不变。
  • 微调是"改造模型本身":用一批高质量数据继续训练模型,改变它的参数和行为模式。

一个类比:RAG 像考试开卷——带资料进场;微调像刷题改变答题风格——不改变你知道多少事实,但改变你"怎么答"。

维度RAG微调(Fine-tuning)
解决的问题注入事实型知识、私有数据、实时数据改变行为模式:格式、语气、工具调用、领域风格
知识时效天然实时,更新成本极低知识一更新就得重新训练,成本高
私有数据非常适合,无需接触模型厂商数据要交给训练方(或自训),有合规成本
幻觉抑制有直接效果(有据可依)间接效果,模型仍可能编造
可溯源天然支持(返回来源片段)基本不支持
成本主要是检索基础设施成本训练 + 托管成本,按次/按量,通常更高
适合场景文档问答、客服、知识库、企业内搜输出格式、特定指令遵循、工具调用强化

工程结论(重要):除非你的目标是"改变模型的输出行为"(比如让它总用某种 JSON 结构、总是调用某个工具),否则默认先做 RAG,别先微调。RAG 做不动了(检索效果到顶、行为需求明确)再考虑微调,两者也可以组合——RAG 负责"知识",微调负责"风格"。


4.2 RAG 经典流程总览

一个最小的 RAG 系统分两个阶段:索引(Indexing)和查询(Query/Retrieval)。

索引阶段是一次性(或增量)把文档准备好,离线进行;查询阶段是每次提问都走一遍的在线链路。

flowchart LR
    subgraph INDEX["索引阶段(离线,一次性构建)"]
        A[文档加载<br/>Document Loader] --> B[切分<br/>Chunking]
        B --> C[向量化<br/>Embedding]
        C --> D[(向量数据库<br/>Vector Store)]
    end

    subgraph QUERY["查询阶段(在线,每次提问)"]
        Q[用户提问] --> E[提问向量化<br/>Embedding]
        E --> F[向量检索 Top-K]
        F --> G[组装 Prompt<br/>上下文 + 问题]
        G --> H[LLM 生成回答]
    end

    D -.->|检索| F
    H --> R[答案 + 来源引用]

整条链路拆开看,每个环节都可以用一个 Java 工程师熟悉的组件来类比:

RAG 环节Java 类比本环节在做什么
文档加载(Document Loader)数据接入层 / 爬虫从 PDF、Word、Markdown、网页、数据库读文本
分块(Chunking)把大表按页切分、建立索引把长文档切成检索单元,见 4.6 节
Embedding特征工程 + 对象相似度计算把文本变成向量,见 4.3 节
向量存储与检索搜索引擎(ES)的倒排索引找"语义上最像"的 K 个片段,见 4.4、4.5 节
Prompt 组装SQL 拼接 + 模板渲染把检索结果塞进上下文模板
生成你写的答案组装方法LLM 基于上下文生成并注明来源

核心思想只有一句话:把"回答问题"变成"先查资料再回答"。 其余全是工程细节——而这正是你的主场。

下面按流程逐个环节深入。


4.3 Embedding 通俗原理

4.3.1 从"词"到"向量"

搜索引擎匹配靠的是"字面相同":用户搜"苹果手机"能命中"苹果手机",但搜"iPhone"就未必命中,搜"水果"更是不相关。它看不见语义。

Embedding 要解决的是语义匹配:把一段文本映射到一个高维空间(例如 1536 维)里的一个向量(一串浮点数),规则是——

语义越接近的文本,它们的向量就越"靠近"。

用 Java 类比:就像你把一个对象的若干特征(品牌、类目、价格区间、关键词)抽取成一个定长的特征数组,然后"算两个对象的相似度"。Embedding 就是语言版的"特征向量",只是特征不是人设计的,而是模型在大规模语料里学出来的。

4.3.2 一个直觉例子:词与词的距离

如果我们在 Embedding 空间里观察这些词:

"北京" ≈ "首都"     (同义词,向量很接近)
"北京" ─ "上海"     (都是城市,中等距离)
"北京" ─ "苹果"     (语义无关,距离很远)
"苹果" ≈ "水果"     (相关但不同义)

更直观的一个著名现象是"向量平移":king − man + woman ≈ queen。这证明了 Embedding 空间里不仅有远近关系,还有语义方向。当然这只是启发式的直觉,对单个词而言有噪音,但它解释了"为什么语义可以用几何表示"。

注意:Embedding 并不理解文本。它把文本压缩成一个坐标,能反映"像不像",但说不清"为什么像"。

4.3.3 常用文本向量模型

  • OpenAI text-embedding-3-small:本系列默认模型,1536 维(可用 dimensions 参数降维),性价比高,中文效果够用;
  • OpenAI text-embedding-3-large:3072 维,效果更好、更贵;
  • 国产 bge-m3(BAAI 智源):中英多语,支持 8192 长度,本地可部署,适合数据不出域的场景;
  • 国产 m3e(Moka AI):轻量中文向量模型,社区使用广。

选型时考虑:中文效果、维度与存储成本、是否允许数据出域(能否调用云端 API)、推理成本。具体指标以各模型官方文档为准。

4.3.4 维度与成本:为什么"维度"是个工程问题

两个方向,都是工程上的真金白银:

  1. API 计费:Embedding API 按 token 计费,文本越长、调用越频繁,费用越高。索引阶段要对全量文档做一次向量化,查询阶段每次提问还要对问题做一次向量化。
  2. 内存/存储:维度 × 向量数量决定了索引占用的内存。一个直觉:1536 维 float 向量 ≈ 6 KB。100 万个片段就是约 6 GB 内存(还没算索引结构的额外开销)。维度越高,单个向量越大、检索越慢。

所以"用最高维模型"不是免费的。降维(如 dimensions=512)牺牲一点精度换取内存与速度,是生产中常见的取舍。具体数值以各模型与向量库官方文档为准。


4.4 相似度计算与 Top-K 检索

4.4.1 余弦相似度(Cosine Similarity)

拿到两个向量,怎么判断"像不像"?最常用的指标是余弦相似度:

cos(A, B) = (A · B) / (|A| × |B|)
           = Σ(Ai × Bi) / (√ΣAi² × √ΣBi²)

其中 A · B 是点积(逐分量相乘再求和),|A| 是向量的模长。直觉上:它度量的是两个向量方向是否一致,只看"夹角",不看"长度"。夹角为 0(方向完全一致)时值为 1,夹角为 90 度(完全无关)时值为 0,方向相反时接近 -1。

用 Java 类比:两个对象的特征向量做归一化后的点积。归一化(除以模长)这一步很关键——它把"长度"(文本长度、表达繁简)的影响抹掉,只看"语义方向"。

余弦相似度与点积的关系:如果所有向量都做了 L2 归一化(模长为 1),那么 cos(A,B) = A · B,两者就完全等价了。很多向量库内部存储的就是归一化向量,直接用点积算。这个细节决定了你调库时要看清 API 文档用的是哪种相似度(cosine / dot_product / l2),选错了会得到反直觉的排序结果。

具体公式形式与归一化要求,以你所用的向量库/模型官方文档为准;以上是通用的数学定义。

4.4.2 Top-K 检索:不追求唯一正确答案

检索不是"找出唯一最像的那一个",而是取出相似度最高的 K 个片段返回给模型。

  • K 是工程参数(常见 3~10),决定"给模型喂多少材料";
  • K 太小,相关材料可能没进来,模型答不上来;
  • K 太大,噪音片段混入,模型被无关信息干扰,还可能撑爆上下文窗口;
  • 具体最优 K 值要靠评估实验确定(见 4.7 节),没有通用默认值。

Top-K 是一个朴素的召回策略:先按相似度从高到低排序,取前 K。它关心的是"有没有把相关的捞进来",而不关心"捞进来的都是相关的"——后者要靠后续的精确化手段(如混合检索、重排序,见第 6 章)。


4.5 向量数据库与 ANN

4.5.1 为什么暴力全量扫描不可行

有了向量和相似度公式,检索最朴素的做法是:把库里所有向量和查询向量算一遍余弦相似度,取最高的 K 个。这就是"暴力检索"(Brute-Force / Flat)。

数据量小(几千~几万片段)时完全够用。但当片段数到百万级:

  • 每次查询要算 100 万次维度为 1536 的点积 ≈ 15 亿次浮点乘法,就算单次查询做到几十毫秒,也是纯 CPU/GPU 算力硬扛;
  • 在线系统要支撑每秒几十上百次查询,暴力检索直接成为瓶颈。

所以工业界的做法是:放弃"绝对精确",用近似算法换速度,把精确度从 100% 降到 90%+,换取查询时间从"线性扫描"降到"接近对数"。这就是 ANN(Approximate Nearest Neighbor,近似最近邻)。

用 Java 类比:HashMap 用哈希把"全表扫描"变成 O(1),ANN 是把"向量全量扫描"变成 O(log n) 级别,只不过哈希有确定性,ANN 的近似性来自"它只搜了候选分区/候选邻居,没搜全量"。

4.5.2 两种主流 ANN 算法的直觉

HNSW(Hierarchical Navigable Small World,层级可导航小世界图)

直觉:想象地铁网络有多层线路。检索时先从最高层(站少、稀疏)进入,快速定位"大概在哪片区域",再逐层下沉到细粒度层,最后在底层找到最近的 K 个邻居。

  • 优点:查询快、精度高(在图上沿着"邻居的邻居"跳,天然有捷径);
  • 缺点:内存占用大(图结构 + 向量都在内存),插入慢(要维护图结构)。

IVF(Inverted File Index,倒排文件索引)

直觉:先把整个向量空间用聚类(K-Means)切成若干"区"(每个区一个质心),检索时先算出查询向量最近的几个区,只在这几个区里暴力扫描。

  • 优点:内存比 HNSW 小、写入快;
  • 缺点:如果查询向量落在分区边界附近,真正的近邻可能被遗漏(近似误差来源之一)。

工程结论:中小规模(百万级以内)无脑选 HNSW 居多;规模巨大、内存紧张时用 IVF 或混合方案。具体参数(M、ef_search、nprobe 等)以所用向量库官方文档为准。

4.5.3 主流向量数据库/检索引擎对比

产品形态特点适合场景
FAISS(Facebook)Python/C++ 库,非服务纯内存索引,快、灵活,需自己管持久化原型验证、内存可控的中小规模、离线批处理
Chroma嵌入式服务/客户端轻量,Python 生态集成好,适合教学与小型应用本系列代码示例、学习阶段、小规模应用
Milvus分布式服务云原生、支持大规模、丰富的索引类型大规模生产检索服务
pgvectorPostgreSQL 扩展直接复用 PG,一个数据库同时管业务表与向量,事务一致Java 团队最友好:无新增中间件、DBA 运维体系复用
Elasticsearch(+ kNN)搜索引擎文本倒排索引 + 向量检索一体(hybrid),词法/语义互补已有 ES 团队、需要"关键词 + 向量"混合检索的场景

给 Java 工程师的选型建议:

  1. 如果团队已重度使用 PostgreSQL,pgvector 是默认优先考察项——少一个中间件,少一份运维;
  2. 如果已有 Elasticsearch 基础设施,用它的 kNN 能力做混合检索,不必另起炉灶;
  3. 真正的海量高并发场景,再评估 Milvus 这类专用分布式向量库;
  4. FAISS、Chroma 更适合 Python 侧做原理验证和本系列代码。

各产品的功能边界(过滤、持久化、多租户、RAG 集成)迭代很快,具体以官方文档为准。


4.6 分块(Chunking):检索的最小单元

4.6.1 为什么必须切分

两个理由,缺一不可:

  1. 上下文窗口限制:一段 200 页的文档不可能整体塞进上下文(窗口 8K~128K token 不等,且越长的上下文越贵、越慢、越容易让模型"淹没"在无关内容里)。
  2. 检索粒度:检索返回的是"最像查询的片段"。如果一块是整本手册,问题和段落之间的相似度会被稀释——块越小,检索越精准,但每块的信息量越少。切分就是在这两者间找平衡。

用 Java 类比:把一张超宽的表建成索引前,你会先按主键/分区把数据拆成行、按页组织;分块就是把"行"定多大、页怎么分。Chunk 就是检索的"页"。

4.6.2 常见分块策略

策略做法特点风险
固定大小分块(Fixed-size)按固定字符/token 数切,超出就切下一块简单、可预期、省 token会在句子/段落中间"一刀切",切断语义
递归字符切分(Recursive Character Splitting)依次按段落 → 句号 → 逗号 → 字符的回退顺序切,尽量优先在语义边界切平衡好,LangChain 的 RecursiveCharacterTextSplitter 即此类,默认推荐无真正语义理解,只是"尽量"避开边界
语义分块(Semantic Chunking)用 Embedding 判断句子间相似度,相似度高则归为一块块边界贴合语义计算成本高、结果不稳定,需调参

工程上绝大多数场景用递归字符切分就够;语义分块是"边际收益"手段,等基础方案评估完再说(评估方法见 4.7)。

4.6.3 chunk_size 与 overlap:两个核心参数

文本流:  |----chunk1----|
              |----chunk2----|   <-- overlap: chunk2 复用 chunk1 的尾部
  • chunk_size:每个块的"页宽"(按 token 数或字符数计)。太小则单块信息不足、检索命中率下降;太大则检索噪音多、上下文浪费。经验上先试 500~1000 token 量级,再用评估数据调。
  • overlap(重叠):相邻块共享一段尾部内容。作用是避免切分把关键信息(一个句子的后半段、一个列表项)拦腰截断后,两块都不完整。overlap 一般取 chunk_size 的 10%~20%。

直觉:overlap 像 MapReduce 里给每个分片多带"上下行上下文",或者像做流式处理时"每批多读 100 条,防止批边界切断事件"。

4.6.4 中文分块注意事项

中文没有天然空格分词,按固定字符切最容易切碎语义,注意几点:

  1. 按句号/换行优先,按字数兜底:优先在句号、分号、问号、叹号、换行处切断;不要死板地"数到 N 个字符就切"。递归切分器天然符合这个思路。
  2. 警惕把"否定语义"切没:比如"不支持 A,但支持 B"被切成两块后,模型可能只看到"支持 B"而产生误判。overlap 和语义边界切分能缓解。
  3. 避免跨标题切块:Markdown 的 ##、### 是天然语义边界,优先按标题分段切(标题 + 正文作为一个块,检索时还附带标题作为定位信息)。
  4. 列表/表格尽量整块保留:表格被横切后,表头与数据分离,信息基本报废。

定性的影响规律(供调参方向):

  • chunk 过小 → 检索"乱开枪",命中很多碎片,模型凑不齐完整答案;
  • chunk 过大 → 相关语义被稀释,相似度分数普遍偏低,且上下文被无关内容占用;
  • overlap 过小 → 边界信息丢失;
  • overlap 过大 → 冗余内容多,浪费 token、稀释每个块的独特性。

这些参数没有标准答案,必须结合你自己的文档形态与 4.7 节的评估指标一起调。


4.7 检索质量度量:怎么算"检得好不好"

RAG 的效果先看检索,再看生成。检索质量用一组"排序/命中"指标度量,都是信息检索(IR)领域的标准指标,直觉如下。

4.7.1 Hit Rate(命中率)

定义直觉:给一批"问题 → 正确答案所在文档"的测试集,跑检索后,统计"正确答案所在的块有没有出现在 Top-K 结果里"的问题占比。

Hit Rate = 命中的问题数 / 总问题数
  • 只看"捞没捞到",不看排第几;
  • 这是最粗、最常用、最容易先跑起来的指标——先算它,再谈别的。

4.7.2 MRR(Mean Reciprocal Rank,平均倒数排名)

定义直觉:在命中的问题里,正确答案排得越靠前,得分越高。排第 1 得 1 分,排第 2 得 0.5,排第 3 得 0.33……对每个问题取"1 / 排名",再对所有问题取平均。

MRR = (1/rank1 + 1/rank2 + ... + 1/rankN) / N    (只取每个问题里正确答案的最高排名)
  • 命中但排第 5 的问题,MRR 只给 0.2 分——它惩罚"捞到了但排太靠后";
  • 因为 Prompt 组装时通常按相关度排序,排得靠前的片段更容易影响模型,所以 MRR 比 Hit Rate 更贴近真实效果。

4.7.3 Recall@k 与 Precision@k

  • Recall@k(召回率):某个问题有 M 个"真正相关的块",Top-K 结果里捞到了多少个(占 M 的比例)。回答的问题是"相关材料有没有漏"。
  • Precision@k(精确率):Top-K 结果里有多少是真正相关的。回答的问题是"捞进来的有没有噪音"。

直觉对比:

指标回答的问题类比
Hit Rate答得上答不上(捞到没有)能不能找到那个关键文档
MRR答案排多靠前关键文档排在第几屏
Recall@k该捞的捞全没有相关材料有没有漏(漏了就答不全)
Precision@k捞的是不是都是相关的捞进来的有没有一堆垃圾(垃圾会干扰模型)

调参方向很直观:

  • K 调大 → Recall 上升、Precision 下降(多捞总能捞全,但噪音变多);
  • chunk 变小 → 粒度细、Precision 可能上升,但单块信息少、Recall 可能下降;
  • chunk 过大 → 相反。

4.7.4 一份评估测试集长什么样

questionrelevant_chunk_ids
"平台的退款流程是什么?"["chunk_0231", "chunk_0232"]
"逾期订单怎么处理?"["chunk_0187"]
  • 从你的真实知识库里抽 50~200 个问题,人工标注每个问题的相关块(或相关文档);
  • 跑一次检索,算出上面的指标;
  • 任何分块参数、K 值、Embedding 模型的改动,都拿这套指标说话,而不是靠"感觉效果变好了"。

完整可运行的评估代码、RAGAS 等自动评估框架,见第 6 章。


4.8 RAG 的局限与改进预告

RAG 不是银弹。先认识它的四个典型失败模式,再看后续章节怎么解决:

4.8.1 四个典型失败模式

失败模式表现根因
检索失败 → 答非所问该捞的没捞到,模型只能瞎编或说"不知道"切分粒度不对、Embedding 效果差、K 太小、query 与文档用语差异大
检索到噪音上下文Top-K 里混入不相关片段,模型被带偏K 太大、相似度阈值太低、库里有脏数据
知识冲突不同文档/新旧版本说法矛盾,模型回答混乱或信了错的那份多来源文档缺乏时效/权威性排序
评估缺失上线后不知道检索质量是涨是跌,改参数全靠拍脑袋没有 4.7 节的测试集与指标基线

4.8.2 改进方向预告

  • 第 5 章(RAG 从零实战):不依赖框架,手写完整 RAG——加载 → 切分 → Embedding → 存储 → 检索 → 组装 → 问答,你会看到每个环节的真实代码长什么样;
  • 第 6 章(RAG 进阶与生产实践):解决上面的前三个失败模式——混合检索(关键词 + 向量互补)、重排序(Rerank)、查询改写(Query Rewriting)、RAG 评估体系;
  • 第 7 章(LangChain 核心概念):这些流程在 LangChain 里怎么被抽象成可组合的管道;
  • 第 13 章(Java 生态落地):用 LangChain4j / Spring AI 在 Java 里复刻同样的能力。

记住一个工程师的判断:RAG 项目的难点从来不在"调对 Embedding 模型",而在数据质量、切分策略和评估闭环——这三样恰好都是工程问题。


4.9 代码示例:Embedding + 余弦相似度手算

下面是一个可运行的最小示例:用 OpenAI 兼容的 Embedding API 把两个句子向量化,再用 numpy 手算余弦相似度(不依赖任何框架的相似度函数),验证"语义相近 → 向量相近"。

若使用国产兼容端点(DeepSeek、阿里云百炼、智谱、Kimi 等),把 base_url 换成对应端点即可;具体端点以各家官方文档为准。

4.9.1 环境准备

pip install openai numpy
# 版本参考:openai>=1.x、numpy>=1.26
# 本系列约定 langchain-openai==0.3.*,本章不依赖 langchain,仅用 openai SDK

4.9.2 示例代码

文件 04_embedding_similarity.py:

"""Embedding + 余弦相似度手算示例。
用法:
  export OPENAI_API_KEY="sk-xxx"
  export OPENAI_BASE_URL="https://api.deepseek.com/v1"   # 可选,国产兼容端点
  python 04_embedding_similarity.py
"""

import os

import numpy as np
from openai import OpenAI

client = OpenAI(
    api_key=os.getenv("OPENAI_API_KEY"),
    base_url=os.getenv("OPENAI_BASE_URL"),  # 为空则默认 OpenAI 官方
)

EMBEDDING_MODEL = "text-embedding-3-small"  # 国产端点若不支持此模型名,以各家官方文档为准替换


def get_embedding(text: str) -> list[float]:
    resp = client.embeddings.create(model=EMBEDDING_MODEL, input=text)
    return resp.data[0].embedding  # 1536 维浮点向量


def cosine_similarity(vec_a: list[float], vec_b: list[float]) -> float:
    """手算余弦相似度:cos = (A·B) / (|A| * |B|)"""
    a = np.array(vec_a, dtype=np.float32)
    b = np.array(vec_b, dtype=np.float32)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))


if __name__ == "__main__":
    sentences = [
        "北京是中国的首都,是一座历史悠久的城市。",
        "上海是中国最大的经济中心之一。",
        "苹果是一种常见的水果。",
        "The capital of China is Beijing.",  # 跨语言也能对齐语义
    ]

    vecs = [get_embedding(s) for s in sentences]

    print("向量维度:", len(vecs[0]))
    print()
    for i in range(len(sentences)):
        for j in range(i + 1, len(sentences)):
            sim = cosine_similarity(vecs[i], vecs[j])
            print(f"相似度 {sim:.4f} | {sentences[i][:14]}... <-> {sentences[j][:14]}...")

4.9.3 预期输出解读

向量维度: 1536
相似度 0.71xx | 北京是中国的首都... <-> 上海是中国最大的...
相似度 0.48xx | 北京是中国的首都... <-> 苹果是一种常见的水果...
相似度 0.7xxx | 北京是中国的首都... <-> The capital of China is Beijing...

三个观察,对应本章三个知识点:

  1. "北京"句与"上海"句相似度明显高于与"苹果"句——语义相近则向量相近(4.3 节);
  2. 中文句与它的英文同义句相似度同样很高——Embedding 跨语言对齐了语义,而非字面;
  3. 具体数值不必纠结(不同模型差异很大),看相对大小关系即可——这正是后续做 Top-K 排序的数学基础(4.4 节)。

4.9.4 可能的坑

  • OPENAI_API_KEY 未设置会直接报认证错误,先确认环境变量;
  • 国产端点可能不支持 text-embedding-3-small 这个模型名,换用 bge-m3 等该厂商支持的模型即可,以官方文档为准;
  • Embedding 是有状态的索引依赖:换了 Embedding 模型,历史上所有已入库的向量都必须重新生成,否则新旧向量没有可比性——这是生产环境最容易踩的坑。Embedding 模型一旦定下,尽量别换。

小结

  • RAG 的本质是先查资料再回答:模型的知识在训练时就冻结了,RAG 用外部检索给它"临时补课";
  • 知识时效、私有数据、幻觉抑制、可溯源、成本,是 RAG 的核心价值;默认先 RAG,行为问题才微调;
  • Embedding 把文本变成高维向量,语义相近则向量相近;余弦相似度度量"方向一致程度",与归一化后的点积等价;
  • 全量扫描算不过来了,所以用 ANN(HNSW 图近似 / IVF 分区近似)换速度,容忍少量精度损失;
  • 向量库选型先考虑团队现状:pgvector 对 Java 团队最省心,ES 适合混合检索,海量再上 Milvus;
  • 分块是检索的"页"设计:递归字符切分最通用,chunk_size 与 overlap 是核心参数,中文优先按句号/换行切,别切碎语义;
  • 检索质量先看 Hit Rate,再上 MRR / Recall@k / Precision@k,一切参数调整都拿指标说话;
  • RAG 会失败在检索、噪音、冲突、评估四类问题上——第 5、6 章逐一解决。

练习

  1. 动手跑:按 4.9 节搭好环境,运行示例代码。再把"北京是中国的首都"换成你业务里的一句真实描述,与三五句其他文本对比相似度,感受向量空间的远近。
  2. 思考你的业务:列出你所在系统/团队里 3 种"适合做 RAG 知识库"的文档(例如:操作手册、历史工单、产品需求文档、政策文件)。对每一种,说明它满足 4.1 节里的哪些价值点(时效/私有/幻觉/溯源),以及你预估的"文档量级"和"更新频率"。
  3. 试着选型:结合 4.5 节的对比表,为上面的知识库选一个检索底座(pgvector / ES / Milvus / FAISS),并写两句话说明理由。
  4. 预演切分:找一份你团队的真实长文档,用"递归切分 + overlap"的思路口头模拟切分,标出至少 2 处"按字数切会切坏语义"的位置,并说明如何用句号/换行边界避开。

参考答案

<details>
<summary>练习1(动手跑 + 换成业务描述)</summary>

把示例里的对比文本换成业务描述,例如:

sentences = [
    "机票已出票,出票后 24 小时内可免费退票",
    "退票手续费按起飞前剩余时间分档收取",
    "航班因天气原因取消,可免费改签一次",
    "会员积分可在购票时抵扣现金",
]
target = "退改签收费规则"

预期现象:与 target 语义最近的句子(退票手续费那条)相似度最高,其次按相关性递减。如果某条本该相关的句子分数异常低,先检查:Embedding 模型是否支持中文、文本是否被截断、归一化是否正确。这一步的目的是建立"向量空间的远近 ≈ 语义远近"的直觉,为后面 Top-K 检索打基础。
</details>

<details>
<summary>练习2(找 3 种适合 RAG 的文档)</summary>

以团队为例:

文档满足的价值点量级更新频率
退改签政策文件时效(政策会改)、溯源(客服要能说"依据条款 X")、幻觉抑制几十页低频(季度/政策变更时)
历史工单库(问题-答案对)私有数据(模型不可能见过你的工单)、幻觉抑制数万条高频(每天新增)
产品操作手册/内部 Wiki私有数据、溯源数百页中频(版本迭代时)

判断口诀:"内容会变 + 模型没见过 + 回答错了要能查证" 三者占其二,就值得进知识库。注意历史工单这种高频更新的,要重点设计增量更新(第 6 章)。
</details>

<details>
<summary>练习3(检索底座选型)</summary>

结合第 4.5 节对比表,参考理由:

  • Java 团队 + 已有 PostgreSQL:优先 pgvector。"公司已有 PG 集群,pgvector 直接建表装向量,不用新引入运维组件,Java 侧用 Spring AI 的 PgVectorStore 即插即用;量级十万级 chunk 完全够用。"
  • 要混合检索(向量 + 关键字)且已有 ES:选 ES。"现有 ES 集群的 kNN 查询 + 中文分词器天然支持混合检索,省一套存储。"
  • 数据量千万级以上 / 需要横向扩展:选 Milvus。"chunk 数量过千万,pgvector 单表扛不住,Milvus 的分片与 GPU 检索能扩展。"
  • 纯本地原型/验证:选 FAISS(内存索引,零运维,生产别用)。

一句话:选型跟着团队现有基础设施走,别为了"先进"新引一套存储。
</details>

<details>
<summary>练习4(预演切分,标出切坏位置)</summary>

拿一份真实长文档,按"递归切分 + overlap"口述模拟。中文文档典型的切坏位置:

  1. 表格跨块:如"退票手续费"表格,按 200 字硬切可能把"起飞前 4 小时内→收 40%"这一行和它的标题切到两个块里,检索"手续费多少"时只命中表头,答不出数值。对策:按表格/段落边界切,或让表格整体成块。
  2. 条款与它的例外说明被切开:如"改签免费"段落后面紧跟着"但特价票除外",硬切后检索"特价票能不能改签"只命中例外部分,回答以偏概全。对策:以句号/换行为主边界,overlap 保留上下文。
  3. 专有名词被从中间切开:"里程积分规则"这种词刚好跨在两个 chunk 边界,检索时哪个块都搜不到整词。对策:切分算法里对连续中文标点(句号、分号、换行)优先断句,避免从词中切断。

落地建议:先用 RecursiveCharacterTextSplitter(第 7 章组件)跑一遍,再打印每个 chunk 的头尾 20 字人工扫一遍,比凭空调参更有效。
</details>

延伸阅读

  • OpenAI Embeddings 官方文档(模型列表、维度、按 token 计费规则,以官方文档为准);
  • BGE 系列与 M3E 模型主页(中文 Embedding 选型参考);
  • FAISS、Milvus、Chroma、pgvector、Elasticsearch 各自官方文档中的索引与相似度说明;
  • LangChain RecursiveCharacterTextSplitter 源码与文档(chunk_size / overlap 的真实行为);
  • 想动手写完整 RAG,直接进入第 05 章。