Skip to content

助手 · 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. 场景:为什么关键词搜不到「爬山」=「徒步」 ​

我们最早的知识检索是关键词版:用户问句里必须逐字出现攻略里的词才命中。

攻略:赛里木湖最佳季节是 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. 教学收获 ​

  1. 「语义搜索」的本质是把语言问题变成几何问题:文字→向量(embedding)→ 算距离(余弦)。想懂 RAG,先接受「意思可以用坐标表示」这件事。
  2. embedding ≠ 向量库 ≠ LLM,是三种东西:embedding 算坐标、向量库存坐标、LLM 写人话。一个 AI 应用同时用好几家模型是常态,不是配重复。
  3. 「够用就好」是工程纪律不是偷懒:25 条数据用内存 List + 惰性建索引,比上 Qdrant 更合理——复杂度要匹配数据量,留好 Port 等真需要了再换(我们就是这么留的)。
  4. RAG 的护城河在「引用 + 拒答」,不在「检索多花哨」:能溯源、会说「不知道」,才是可信助手;检索精度可以慢慢升(关键词→向量→rerank)。
  5. 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 延伸阅读 ​

Powered by VitePress