助手 · RAG 向量检索新手科普(embedding / 向量 / 内存索引 / 领域模型是什么)
日期: 2026-08-19 | 作者: sunxin(+ Cursor AI 结对讲解) 关联代码:
IEmbeddingPort·IKnowledgeRetrievalPort·IKnowledgeAnswerPort·KnowledgeSnippet·KnowledgeAnswerCommand·KnowledgeAnswer·Citation·VectorKnowledgeRetrievalAdapter·TongyiEmbeddingAdapter关联决策: ADR-044 姊妹篇:助手-RAG从关键词到向量(升级路径)·助手-大模型幻觉与grounding(引用/拒答为什么重要)·通义百炼Embedding开通与接入说明(怎么开通) 定位:纯科普,给「代码写完了但概念还没吃透」的自己看。不讲怎么开通(那在接入说明),只讲每个词到底是什么意思。
TL;DR
- Embedding(向量化)= 把一段文字翻译成一串数字坐标,让「语义」变成「几何距离」。意思相近的两句话,坐标也挨得近。这是 RAG 检索能「懂近义词」的地基。
- 向量 = 那串数字(如 1024 个小数);余弦相似度 = 量两串数字方向有多接近的尺子(越接近 1 越像)。检索就是拿「问题向量」去和「攻略向量」逐个比这把尺子,挑最像的几条。
- 切块(chunk)= 把长攻略切成小段再各自向量化。因为「整篇文章」语义太杂,切成「赛里木湖·最佳季节」这种小段,检索才精准。
- 索引 = 提前把所有攻略片段的向量算好、存起来备查。我们存在内存里(一个 List),不引数据库/向量库——因为片段就几十条,暴力比一遍毫秒级完成。「惰性」= 应用启动时先不算,等第一次真有人问才算一次并缓存,之后都用缓存。「够用」是因为量小,没必要为几十条数据上重型中间件(YAGNI)。
- RAG 领域模型 = 我们为这条链路定义的那几个 Java 类/接口,各管一段:
IEmbeddingPort(算向量)→IKnowledgeRetrievalPort返回KnowledgeSnippet(检索到的片段)→KnowledgeAnswerCommand(把问题+片段打包)→IKnowledgeAnswerPort生成KnowledgeAnswer(答案 +Citation引用)。 - 一句话:RAG = 「先把资料变成坐标存好(建索引)→ 把问题也变成坐标,找最近的几段资料 → 把资料喂给大模型写成人话答案,并标注引用;找不到就老实说没有」。
目录
- 1. 场景:为什么关键词搜不到「爬山」=「徒步」
- 2. Embedding:把文字变成坐标
- 3. 向量与余弦相似度:怎么量「像不像」
- 4. 切块 chunk:为什么不整篇丢进去
- 5. 索引 / 内存向量 / 惰性:这仨词到底啥意思
- 6. RAG 领域模型:我们代码里的那几个类
- 7. 引用与拒答:RAG 和「瞎编」的分界
- 8. 教学收获
- 9. 附录:术语表 + 一次问答全流程
1. 场景:为什么关键词搜不到「爬山」=「徒步」
我们最早的知识检索是关键词版:用户问句里必须逐字出现攻略里的词才命中。
攻略:赛里木湖最佳季节是 6-9 月……
用户问:赛里木湖几月去合适? → "几月""合适" 攻略里没有 → 关键词打分低,可能漏
用户问:赛里目湖啥时候去? → "赛里目湖" 是错别字 → 直接 0 分,搜不到关键词只会「字面比对」,不懂「几月去 ≈ 最佳季节」「爬山 ≈ 徒步 ≈ 登山」。要让机器懂「意思相近」,就得先把「意思」变成它能算的东西——数字。这就是 embedding 干的事。
2. Embedding:把文字变成坐标
Embedding(嵌入 / 向量化)就是一个模型,输入一段文字,输出一串固定长度的小数。
"赛里木湖海拔多少米" ──[text-embedding-v4]──► [0.021, -0.174, 0.882, ..., 0.033] (1024 个数)
"赛里木湖有多高" ──[text-embedding-v4]──► [0.019, -0.170, 0.879, ..., 0.031] (几乎一样)
"成都有什么好吃的" ──[text-embedding-v4]──► [-0.55, 0.402, -0.11, ..., 0.77] (差很远)类比:把每句话在一张(超高维的)「语义地图」上标一个点。
- 「海拔多少米」和「有多高」是一个意思 → 两个点几乎重合。
- 「成都美食」跟它们不相关 → 点在地图的另一头。
这串坐标就叫向量(vector);长度(这里 1024)叫维度(dimension)。维度越高能表达的语义越细,但更占空间、更慢——1024 是性价比甜点。
关键认知:embedding 模型只算坐标,不回答问题。它跟 DeepSeek 那种「会聊天的模型」是两种活儿——一个把文字变数字,一个把数字/文字变人话。所以我们项目里同时用通义(算向量)和 DeepSeek(生成答案),不是配重复了(详见 接入说明 §7)。
3. 向量与余弦相似度:怎么量「像不像」
有了坐标,「两句话像不像」就变成「两个点近不近」——一道数学题。最常用的尺子是余弦相似度(cosine similarity):量两个向量方向的夹角。
方向几乎一致(夹角≈0°) → 余弦≈1.0 → 非常像
方向垂直(夹角≈90°) → 余弦≈0.0 → 不相关
方向相反(夹角≈180°) → 余弦≈-1.0 → 意思相反为什么用「方向」而不是「直线距离」:一句话说得长/短会让向量整体变大变小(长度不同),但方向(语义倾向)更稳定,所以比夹角更靠谱。
检索就是这么干的(暴力法):
问题向量 q
for 每个攻略片段向量 v in 索引:
score = 余弦(q, v)
挑 score 最高的 top-K(我们取 3)返回我们评测里打印的 top1 avg=0.285(库内)vs 0.145(库外)就是这个 score 的均值——库里有的问题,最相似片段分更高;库外的问题(冰岛极光)最高分也上不去,于是低于阈值就拒答。
4. 切块 chunk:为什么不整篇丢进去
一篇《赛里木湖攻略》可能有最佳季节、交通、注意事项、周边好几段。如果整篇算一个向量:
- 语义被「平均」得很糊——问「怎么去」和问「几月去」,跟整篇的相似度差不多,检索分不出来。
- 喂给大模型时太长,浪费 token。
所以切块:按段落把长文切成「一段只讲一件事」的小片段,各自算向量。
赛里木湖.md
├─ chunk1: 简介 + 海拔约 2070 米…… → 向量1
├─ chunk2: 最佳季节:6-9 月,7 月野花…… → 向量2 ← 问"几月去"命中这块
├─ chunk3: 交通:从乌鲁木齐/博乐…… → 向量3 ← 问"怎么去"命中这块
└─ chunk4: 注意事项:早晚温差大…… → 向量4我们 5 篇攻略切完共 25 个 chunk(评测日志里的 chunk 数:25)。切块粒度是门手艺:太大糊、太小碎,按「一个自然段/一个小标题」通常刚好。
5. 索引 / 内存向量 / 惰性:这仨词到底啥意思
这是你最想搞懂的三个词,逐个拆:
5.1 索引(index)= 提前把向量都算好存起来
如果每次有人提问,才现算 25 个片段的向量,太慢太费。所以提前把所有片段的向量算好、连同原文一起存在一个结构里备查——这个「算好待查的向量集合」就叫索引。检索时只需算 1 次「问题向量」,再拿它跟索引里 25 个比一遍。
类比:图书馆不是等你来了才一本本翻,而是提前编好目录卡片(索引),你来了直接查卡片。
5.2 内存向量 = 索引就放在应用的内存里(一个 List),不进数据库
存索引有很多档次:
| 存哪 | 什么时候用 | 我们 |
|---|---|---|
应用内存(一个 List<float[]>) | 片段几十~几百条 | ✅ 就这个 |
| 向量数据库(Qdrant/Milvus) | 几万~上亿条、要持久化/分布式 | 🔭 量大了再换 |
| 数据库向量字段(pgvector/ES) | 已有对应数据库、想省中间件 | 我们是 MySQL,用不上 |
25 条向量,暴力比一遍是毫秒级,内存里一个 List 完全够,没必要为它单独起一个向量库中间件(多一个要部署、要运维的东西)。这就是「内存向量够用」——够用 = 在当前数据量下,最简单的方案性能已经足够,上更重的方案只是徒增复杂度(YAGNI:You Aren't Gonna Need It)。
5.3 惰性(lazy)= 启动不算,第一次用到才算一次并缓存;现已改为启动后后台预热
「惰性加载」是个通用编程概念:能拖到真正需要时再做的工作,就别提前做。索引仍是惰性 loadIfNeeded(只建一次、失败可重试),但进程起来后会在后台线程先预热,避免把建库摊到用户第一句知识问答上、撞上小程序超时。
应用启动 → 种子知识库(如需)→ 后台线程开始 embed 切块
预热完成 → 日志 `[RAG] 索引预热完成`
有人问知识 → 索引已在内存则直接比对;若预热还没完,与预热共用同一把锁等它建完
应用重启 → 索引没了 → 再预热一次对应日志 [RAG] 内存向量索引就绪 / [RAG] 索引预热完成。小程序助手 chat 超时单独 60s(其它接口仍 10s)。
预热的代价:RAG 开着就会在启动后打一次 embedding(没人问也花这钱)。好处是第一句知识问答不再卡 30 秒。改了攻略走 Web 后台 reindex(下次检索惰性重算),不必为这个单独上向量库。
6. RAG 领域模型:我们代码里的那几个类
「领域模型」= 用 DDD 的思路,为「知识问答」这件事在 Domain 层定义的那组名词(类/接口),每个只管一段职责,串起来就是整条 RAG 链。逐个白话讲:
| 类 / 接口 | 类型 | 白话说它是啥 |
|---|---|---|
IEmbeddingPort | 出站端口(接口) | 「把文字变向量」这个能力的插座。谁实现不管(真实=通义、测试=Mock),业务只认这个口。embed("赛里木湖") → float[] |
IKnowledgeRetrievalPort | 出站端口 | 「按问题找相关片段」这个能力的插座。retrieve(问题, topK) → 一批 KnowledgeSnippet。关键词版/向量版都实现它,切换只换实现 |
KnowledgeSnippet | 值对象 | 一条检索到的攻略片段:标题 + 内容 + 来源 + 相似度分。是「检索」的产物 |
KnowledgeAnswerCommand | 命令对象 | 喂给生成模型的「投喂餐盘」:装着「用户问题 + 检索到的片段们 + 答案长度上限」。约定:模型只能吃盘子里的片段作答(grounding) |
IKnowledgeAnswerPort | 出站端口 | 「根据片段写出带引用答案」这个能力的插座。answer(命令) → KnowledgeAnswer。刻意独立于聊天用的 ILlmPort(各配各的 prompt/成本) |
KnowledgeAnswer | 值对象 | 生成结果:答案正文 + 引用列表 + 本次生成用了多少 token |
Citation | 值对象 | 一条引用出处(标题 + 来源标识),让答案「可溯源、不悬空」 |
它们怎么串起来(在 AssistantApplicationService 里):
用户问 "赛里木湖海拔多少米"(意图已识别为 KNOWLEDGE_QA)
│
├─(1) knowledgeRetrievalPort.retrieve(问题, topK=3)
│ 内部:IEmbeddingPort 把问题变向量 → 和内存索引算余弦 → 挑 top3
│ 产出:List<KnowledgeSnippet>(每条带 score)
│
├─(2) 判断:片段为空?or top1.score < 阈值(0.2)?
│ 是 → knowledgeRefuse():回"资料里没找到",【不调生成模型】← 拒答在这
│ 否 → 继续
│
├─(3) 打包 KnowledgeAnswerCommand(问题, 片段们, maxReplyChars)
│
├─(4) knowledgeAnswerPort.answer(命令) → KnowledgeAnswer
│ 内部:把片段拼进 prompt,模型据此生成,附上 Citation
│
└─(5) 回复:答案正文 + citationFooter(引用来源:赛里木湖) + 审计记 token为什么搞这么多接口(Port):这是 DDD 六边形架构的「依赖倒置」——Domain 只定义「我需要什么能力」(插座),具体谁提供(通义/DeepSeek/Mock)放 Infrastructure。好处是换供应商、加 Mock、将来剥离助手,业务代码一行不用改(详见 ADR-044 / ADR-039)。
7. 引用与拒答:RAG 和「瞎编」的分界
有了检索,为什么还非要「引用」和「拒答」?因为这是 RAG 区别于「让模型自由发挥」的关键(详见姊妹篇 幻觉与 grounding):
- 引用(Citation):答案必须标「这话出自哪篇攻略」。用户能溯源 = 答案可信;也逼着模型「只说资料里有的」。
- 拒答(Refusal):检索不到、或最相似片段的分都低于阈值 → 直接说「资料里没提到」,根本不调用生成模型。宁可不答,不可乱答。
- grounding(事实锚定):
KnowledgeAnswerCommand里那句「只能用盘子里的片段作答」就是 grounding——把模型锁死在真实资料里,不许它用脑补补全。
三者合起来保证:我们的知识问答「有答案必有出处,没资料就明说没有」,而不是一本正经地编一个赛里木湖海拔。
8. 教学收获
- 「语义搜索」的本质是把语言问题变成几何问题:文字→向量(embedding)→ 算距离(余弦)。想懂 RAG,先接受「意思可以用坐标表示」这件事。
- embedding ≠ 向量库 ≠ LLM,是三种东西:embedding 算坐标、向量库存坐标、LLM 写人话。一个 AI 应用同时用好几家模型是常态,不是配重复。
- 「够用就好」是工程纪律不是偷懒:25 条数据用内存 List + 惰性建索引,比上 Qdrant 更合理——复杂度要匹配数据量,留好 Port 等真需要了再换(我们就是这么留的)。
- RAG 的护城河在「引用 + 拒答」,不在「检索多花哨」:能溯源、会说「不知道」,才是可信助手;检索精度可以慢慢升(关键词→向量→rerank)。
- DDD 的 Port 让「学习期用 Mock、上线换真实」无痛:无 Key 时 Mock embedding 就能把整条链和评测跑通,拿到 Key 只改配置——这就是接口隔离的价值。
9. 附录:术语表 + 一次问答全流程
9.1 术语表
| 词 | 一句话解释 |
|---|---|
| RAG | 检索增强生成:先查资料,再让模型据此作答 |
| Embedding / 向量化 | 把文字变成一串数字坐标(语义指纹) |
| 向量 Vector | 那串数字(如 1024 个小数) |
| 维度 Dimension | 向量里有几个数(我们 1024) |
| 余弦相似度 | 量两个向量方向多接近的尺子(-1~1,越大越像) |
| Chunk 切块 | 把长文切成「一段讲一件事」的小片段 |
| 索引 Index | 提前算好、存起来备查的向量集合 |
| 内存向量 | 索引存在应用内存里(不进数据库/向量库) |
| 惰性 Lazy | 拖到第一次真用到才建索引,之后用缓存 |
| Top-K | 检索返回最相似的前 K 条(我们 K=3) |
| 阈值 Threshold | 相似度低于它就判定「没查到」→ 拒答 |
| Grounding | 把模型锁死在给定真实资料里作答,不许脑补 |
| Citation 引用 | 答案标注出处,可溯源 |
| Rerank 重排 | 对召回结果再用模型精排(L4 才做) |
| Port 端口 | DDD 里「我需要的能力」的接口,实现放 Infrastructure |
9.2 一次问答,从文字到答案
"赛里木湖海拔多少米"
│ ① 意图识别(DeepSeek/Mock)→ KNOWLEDGE_QA
│ ② IEmbeddingPort.embed(问题) → 问题向量[1024] (通义/Mock 算坐标)
│ ③ 和内存索引 25 个片段向量逐个算余弦,挑 top3 (检索,毫秒级)
│ ④ top1 分 ≥ 阈值? 否→拒答(不往下走) 是→继续
│ ⑤ KnowledgeAnswerCommand(问题, top3片段, 上限)
│ ⑥ IKnowledgeAnswerPort.answer(...) → 模型据片段生成 (DeepSeek/Mock 写人话)
▼
"赛里木湖平均海拔约 2070 米……
引用来源:赛里木湖"9.3 延伸阅读
- 升级路径全貌:助手-RAG 从关键词到向量
- 为什么要引用/拒答:助手-大模型幻觉与 grounding
- 怎么开通通义 embedding:通义百炼 Embedding 开通与接入说明
- 决策与成本:ADR-044
- 里程碑故事:devlog 14 · M4a 真向量 RAG