Skip to content
This page has been auto-translated and may contain errors.View in English

Embeddings(嵌入) ​

docs.scrimba.com

先说说这个词。embedding(嵌入) 是一串数字,用来代表一段文本并捕捉它的含义,让含义相近的文本转换出相近的数字。这一章要讲的就是如何把词语变成计算机可以比较的东西,也正是这一点让搜索能够匹配含义而不是关键词。

把文本变成向量 ​

计算机怎么知道"dog"(狗)和"puppy"(小狗)是相关的,而"dog"和"stapler"(订书机)不是?这些单词并没有共享什么关键的字母。

这里的诀窍是把文本转换成能捕捉其含义的数字,让含义相近的文本得到相近的数字。这串数字就叫**embedding(嵌入)**,也叫向量(vector)。模型读取文本,输出这个向量。

你自己不需要去解读这些数字。重要的是它们之间的关系。

一个大致的画面:想象每一段文本都是空间中的一个点,含义相近的东西彼此靠近。"dog"和"puppy"落在同一片区域,"stapler"则在很远的另一边。真正的嵌入用的是成百上千个维度,而不是你能想象出来的两三个维度,但这个直觉是成立的:越近就越相似。

这些数字从哪儿来的?为什么它们会和含义对齐?嵌入模型是大语言模型的工作原理里那种预测模型的近亲,是用海量文本训练出来的。为了把任务做好,它必须学会哪些词语和概念会出现在相似的语境中——这正是那句老话"观其伴而知其词"的意思。

"dog"和"puppy"经常出现在相同类型的句子里,所以模型学会了把它们表示得相似。"stapler"混的圈子不一样,所以它落在别处。这几百个数字是习得的"含义方向",每一个都捕捉文本变化的某个维度,没有一个是人类能直接读懂的标签。你从来不需要去解码这些数字,只需要衡量两个向量有多接近。

Juno把文本变成向量 嵌入是一串数字,也就是一个向量,代表一段文本的含义,由一个在海量文本上训练出来的模型生成。它学会了把出现在相似语境中的词语和概念放得彼此靠近,所以相似的含义会得到相似的向量。你从不需要去读这些数字,只需要衡量两个向量有多接近,而"接近"反映的就是"含义相近"。

你想要的是能匹配含义而不是拼写的搜索,这样"帮我把钱退回来"就能找到一条关于退款的内容。做法是把每一段文本转换成一个**向量(vector)**,一个固定长度的数字列表,含义相近的文本会落在相近的位置。嵌入模型读取文本并输出这个向量,整件事的关键就在于:向量之间的距离代表着含义上的相似度。

向量有固定的长度,也就是它的维度,通常是几百到几千个数字。用同一个模型嵌入的每一段文本,输出的长度都是一样的,这也是你能拿任意两个向量做比较的前提。这些维度是习得的"含义方向",不是人为标注的类别,所以你不能把第47维读成"正式程度"之类的东西。你要把整个向量当作一个不透明的点,只去衡量距离。

在真正开始嵌入任何东西之前,有一步会决定整套方案是否奏效:分块(chunking)。你几乎不会把一整篇文档嵌入成一个向量,因为一篇20页的手册用一个向量表示,会把里面所有的主题都模糊平均成一个谁都不像的点。正确的做法是把文档切分成**chunk(块)**——大致一段到一页长的段落,然后分别为每个块生成嵌入。嵌入的应该是块,不是整篇文档,这样每个向量才能承载一个聚焦的想法。

块的大小是一个需要你刻意权衡的取舍。块太大,会把好几个想法平均混在一起,检索时就会不够精准。块太小,又会丢失让一句话有意义的上下文。一个常见的起点是每块几百个 token,相邻块之间留一点重叠,这样一句话即便正好被切分点截断,也能在其中一个块里完整出现,之后再根据检索的实际效果做调整。

Juno把文本变成向量 嵌入把文本转换成固定长度的向量,距离代表含义上的相似度,同一个模型输出的向量长度都一样,所以你能互相比较。这些维度是习得的方向,不是标签,所以要把向量当作一个不透明的点来看。这里最关键的一步是:把文档切成段落大小的块,分别嵌入每一块,因为整篇文档用一个向量表示,会把所有信息平均成一团模糊。

按含义匹配的搜索建立在一个核心想法之上:把文本映射进一个**向量空间(vector space)**(一个固定维度的坐标空间,其中每个点都是一串数字),让语义上的相似变成几何上的接近。嵌入模型就是这张映射:它读取文本,返回一个向量。

在这之上你构建的一切——索引、检索、排序——本质上都是在这些向量上做几何运算。如果映射本身出了问题,后面的任何一步都无法挽回。

第一个可以设计的关键点是"你嵌入的是什么",这就是分块(chunking):先把源文档切分成段落,再进行嵌入,而不是把整份文件直接嵌入。原因在于,嵌入本质上是把输入有损压缩成一个点,一个块里包含的想法越多,它们平均出来的结果就越是一个模糊的中心点,检索时也就越难精准命中。要把块的大小调整到用户实际会问的那个粒度,通常是几百个 token 的一段文字,并留一点重叠,让跨越边界的事实至少能在一个块里完整保留。

分块策略往往比模型选择更能决定检索质量的成败:

  • 固定长度窗口成本低,但会把句子从中间切断,打断思路。
  • 基于结构的切分(按标题、段落或代码块切分)能让每个块保持连贯,通常检索效果更好,代价是流程更复杂一些。

不管你选哪种方式,都要把块的来源和位置和它的向量一起存起来,因为在检索的时候你需要能指回某个事实出自哪里,而单独一个向量是说不出这个信息的。

还有一点要牢记:嵌入的维度不是可解释的坐标轴,所以你没法通过读向量来调试检索问题。调试嵌入的方式是去看它检索出了什么结果,而不是去读那些数字。当某次查询拉出了错误的块时,该修的是分块方式、模型或查询本身,而修正的依据是检索出来的结果,不是坐标本身。

Juno把文本变成向量 嵌入模型把文本映射进一个向量空间,让语义相似变成几何距离,后面每一步都是在这些点上做几何运算。分块是决定检索质量的关键杠杆:把块的大小调整到用户实际会问的粒度,尽量按结构切分,并把每个块的来源和位置和它的向量存在一起,方便追溯一个事实的出处。你没法靠读向量来调试,所以检索出错时,该检查的是检索出来的结果,不是那些数字。

生成嵌入 ​

你向嵌入模型请求向量,过程很像普通的模型调用,只是换了一个端点和一个模型。

python
from openai import OpenAI

client = OpenAI()

EMBED_MODEL = "text-embedding-3-small"  # 换成嵌入模型的位置

def embed(text):
    response = client.embeddings.create(model=EMBED_MODEL, input=text)
    return response.data[0].embedding  # 一串数字

vector = embed("一只顽皮的小狗")
print(len(vector))  # 比如 1536 个数字

结果是一串数字。有一条规则比其他都重要:一定要比较由**同一个模型**生成的嵌入。不同模型生成的向量属于不同的空间,不能互相比较,就像用不同的单位去做测量一样。混用不同模型的结果,得到的分数每次都毫无意义。

而且和其他模型调用一样,你是按token计费的,所以给一个大型文档集合做嵌入是有成本的,值得在真正跑几千篇文档之前先估算一下。

Juno生成嵌入 你通过调用嵌入模型来生成嵌入,它会为你的文本返回一串数字。一定要比较由同一个模型生成的向量,因为不同模型生成的向量互不可比,就像厘米和英寸没法直接比较。嵌入是按 token 计费的,所以一个大型集合是有真实成本的,值得先算一算。

调用本身是一个不同的端点,但结构和聊天调用差不多:你发送文本,拿到一个向量。

python
from openai import OpenAI

client = OpenAI()

EMBED_MODEL = "text-embedding-3-small"

def embed_batch(texts):
    # 在一次请求中发送多段文本,每段文本各返回一个向量
    response = client.embeddings.create(model=EMBED_MODEL, input=texts)
    return [item.embedding for item in response.data]

chunks = ["一只顽皮的小狗", "一只幼犬", "一件办公用品"]
vectors = embed_batch(chunks)  # 每个块对应一个向量,顺序和输入一致

具体的接口形态因服务商而异,但"请求-响应"的思路到处都一样。除了基本调用之外,还有两个实用要点。第一,要**批量**嵌入:大多数接口可以在一次请求里传入一串文本,然后各自返回一个向量,这比一个块调一次要快、要便宜得多,因为你付的请求开销更少,也更不容易撞到速率限制。当你要给一个大型语料库建索引时,批量处理正是让成本和耗时保持在合理范围的关键。

第二,"同一模型"这条规则还有个搭档:模型和维度是一个需要主动做的选择,不是一个默认项。维度更多的大模型能捕捉更细微的区别,但计算、存储和比较的成本也更高。要选能满足你数据区分需求的、最小的模型和维度。

有些模型甚至允许你请求更短的向量,用一点精度换取更低的存储成本和更快的搜索速度。不管你选哪个,它都会变成一份"契约":以后如果换模型,存下来的每一个向量都得重新生成。

Juno生成嵌入 嵌入就是一次请求,输入文本,返回向量,这个模式在不同服务商之间是通用的。能批量处理就尽量批量处理,因为这能大幅降低大型语料库的请求开销和速率限制压力。模型和维度是一个实实在在的选择:选能满足数据区分需求的最小配置,并记住模型一旦选定就是一份契约,换模型意味着要重新嵌入你存下的所有内容。

这套 API 本身很简单:你发送文本,拿回一个浮点数向量,真正值得琢磨的决策都在这次调用的周围,而不是调用本身。

python
from openai import OpenAI

client = OpenAI()

EMBED_MODEL = "text-embedding-3-small"

def embed_batch(texts):
    # 一次请求,返回多个向量,顺序与输入一致
    response = client.embeddings.create(model=EMBED_MODEL, input=texts)
    return [item.embedding for item in response.data]

请求和响应的具体形态因服务商而异,但每个嵌入端点都遵循同样的"批量输入、批量返回向量"的约定。在大规模场景下,批量处理不是可有可无的优化,它是索引任务能顺利跑完还是整个下午都被限流的分水岭:每次请求都有固定开销,还会占用速率限额,所以要通过发送大批量请求来分摊这些成本,再针对大任务必然会遇到的部分失败做退避重试。

模型和维度是一项影响深远的架构决策。维度更高的向量能存储更多的区分度,但在生产环境中每一个重要指标上都要付出更高成本:每个向量的存储、索引占用的内存、每次比较所需的时间,都随维度线性增长。有些模型支持**俄罗斯套娃嵌入(Matryoshka embeddings)**(可以截断成更短长度、依然可用的向量),让你既能保留完整向量用于精确匹配,又能用截断后的前缀做便宜的初筛。当你的语料库大到存储和查询延迟(而不是模型质量)成为瓶颈时,这一招就很值得用。

真正在后期会让人吃苦头的是版本管理。一个存下来的嵌入,永远绑定着某个确切的模型版本。

来自两个不同模型版本的向量是不可比的,所以你不能用更新的模型嵌入一个新查询,再拿它去和用旧模型建的索引比较。而且这个错误是悄无声息的:分数看起来很合理,排名却是错的。要把模型版本当作索引 schema 的一部分,和向量一起存下来,并把重新嵌入当作一次有计划的迁移来对待——这一点会在本章后面讨论"新鲜度"的部分详细展开。

Juno生成嵌入 嵌入调用遵循"批量输入、批量返回向量"的约定,具体形态因服务商而异,思路却始终一样,批量加重试是让大型索引任务顺利完成而不是被限流卡住的关键。模型和维度会成比例地影响存储、内存和查询延迟,所以当规模(而不是质量)才是瓶颈时,考虑用可截断的向量。版本是一份永久的承诺:不同模型版本的向量会悄无声息地无法比较,所以要把版本存进 schema,并把重新嵌入当作一次有计划的迁移。

衡量相似度 ​

要判断两段文本有多相关,你需要衡量它们的向量有多接近。标准的衡量方式是**余弦相似度(cosine similarity)**,它返回一个 -1 到 1 之间的数:越接近 1 越相似,接近 0 表示不相关。你不需要理解背后的数学原理才能用它。可以把它想象成比较两个向量指向的方向。

这里承载含义的是方向,这也是为什么余弦相似度比较的是角度而不是原始距离。两段关于退款的文本,不管一个是短短一句话,另一个是长长一段话,指向的方向都是一样的。

python
import math

def cosine_similarity(a, b):
    dot = sum(x * y for x, y in zip(a, b))
    mag_a = math.sqrt(sum(x * x for x in a))
    mag_b = math.sqrt(sum(x * x for x in b))
    return dot / (mag_a * mag_b)

dog = embed("一只顽皮的小狗")
puppy = embed("一只幼犬")
stapler = embed("一件用来装订纸张的办公用品")

print(cosine_similarity(dog, puppy))    # 更高,两者含义相近
print(cosine_similarity(dog, stapler))  # 更低,两者不相关

这些数字印证了直觉:dog 和 puppy 的分数更高,dog 和 stapler 的分数更低。拿一个查询的向量去和已存储的向量做比较,这正是语义搜索的核心。这一个函数,正是本章后面内容要不断构建在上面的基础动作。

Juno衡量相似度 余弦相似度衡量两个向量有多接近,取值范围是 -1 到 1,越接近 1 越相似。你不需要懂背后的数学就能用它,它比较的是两个向量指向的方向,这也是为什么一句短话和一段长文字,只要说的是同一件事,分数照样能匹配上。拿查询的向量去和存储的向量比较,正是语义搜索的核心动作。

有了向量之后,你还需要一个数字来表示两个向量有多接近。标准做法是**余弦相似度(cosine similarity)**:它衡量两个向量之间的夹角,忽略它们的长度,返回一个 -1 到 1 的值,数值越高越相似。你想要的是角度,不是距离,因为同一主题下的一段长文字和一句短语,尽管向量的长度不同,理应得到相似的分数。

python
def dot(a, b):
    return sum(x * y for x, y in zip(a, b))

# 如果向量都归一化为长度 1,余弦相似度就等于它们的点积
score = dot(query_vector, doc_vector)  # 不再需要除法

有一个技巧在大规模场景下非常划算:把向量存储时就**归一化(normalize)**成长度为 1。一旦所有向量的长度都一样,余弦相似度里的除法就消失了,分数变成了一个普通的点积——每个维度只需要一次乘加运算,在大批量数据上跑起来要快得多。存储时归一化一次,之后用点积来比较。很多嵌入模型返回的向量本身就已经归一化了,但不要想当然,要检查一下。

和玩具示例相比的另一个变化是:你几乎不会只想要最接近的那一个结果。你想要的是**top-k**检索,也就是分数最高的 k 个结果,通常是三到十个。有时候排第一的向量差一点,而排第二、第三的加在一起才真正包含答案,返回一小批结果能给下一个环节留出发挥的空间。只返回排名第一的结果,会丢掉你之后会后悔没保留的上下文。

Juno衡量相似度 余弦相似度比较两个向量的夹角,范围是 -1 到 1,忽略长度,所以同一主题下的一句短话和一段长文字照样能匹配上。存储时把向量归一化成长度 1,比较就会退化成一个快速的点积运算,在大批量数据上这个优势会不断累积。而且要检索 top-k(三到十个结果),不是只要最好的那一个,因为答案往往分散在前几个结果里。

你需要一个相似度度量,余弦相似度(cosine similarity)(两个向量夹角的余弦值,范围 -1 到 1)是默认选择,因为它忽略了向量的大小,所以文档长度不会淹没主题信息。这个选择带来一个值得围绕设计的性能后果:对原始向量计算余弦相似度,每次比较需要一次点积加两次求模计算,而索引中每个向量在每次查询时都要付出这个代价。

python
import numpy as np

# 在写入时归一化,每个向量做一次即可
def normalize(v):
    v = np.asarray(v, dtype=np.float32)
    return v / np.linalg.norm(v)

# 查询时,对已归一化的向量计算余弦相似度就是一次矩阵-向量乘法
def search(query_vec, matrix):
    return matrix @ query_vec  # 每行一个分数,向量化计算

这里的优化手段是在写入时把每个向量**归一化(normalize)**成单位长度,这样余弦相似度就等同于点积,让你可以用一次矩阵-向量乘法给整个语料库打分,而不需要用 Python 循环逐个计算。写入时归一化,相似度就变成了一次覆盖整个索引的点积运算。这是暴力(brute-force)检索的基线方案:精确、直接,而且和语料库大小呈线性关系,大致在几十万向量以内还能撑得住,超过这个量级就会顶不住,这也是需要转向近似索引的信号,后面会讲到。

玩具版度量方式掩盖了两个生产环境中的实际问题。第一,你几乎从来不想要 argmax(单个最高分),而是想要带分数阈值的 top-k,因为第一名和第二名之间的差距能告诉你这次匹配有多大把握,而如果排名第一的结果分数依然很低,说明你的语料库里其实没有任何东西真正回答了这个查询。这种情况应该返回"没有好的匹配",而不是硬塞给用户一个"最不差"的结果。第二,余弦相似度是相对的,不是绝对的:0.4 这样的分数,只有放在你的语料库和模型产生的分数分布里才有意义,所以阈值要针对每个索引用实验去校准,而不是从某篇博客文章里抄一个"神奇数字"。

Juno衡量相似度 余弦相似度忽略向量大小,所以长度不会淹没主题信息,但暴力计算余弦相似度的开销和语料库大小呈线性关系,大致只能撑到几十万向量,之后就得上近似索引。写入时归一化,这个度量就变成了一次能覆盖整个语料库的矩阵-向量乘法。检索时要带阈值取 top-k,把排名第一但分数很低的结果当作"没有匹配"而不是"最不差的选择",而且这个阈值要按索引单独校准,因为这些数字是相对的,不是绝对的。

语义搜索 ​

把这些环节拼在一起,你就能按含义搜索,而不是按关键词。先把你的文档都嵌入一遍并存下向量。当一个查询进来时,同样把它嵌入,然后返回向量最接近的那些文档。

python
docs = [
    "在账户设置页面重置你的密码。",
    "我们的退款政策允许在30天内退货。",
    "有账单问题请通过邮件联系客服。",
]

# 提前一次性嵌入所有文档
index = [{"text": text, "vector": embed(text)} for text in docs]

def search(query):
    query_vector = embed(query)
    scored = [{"text": item["text"], "score": cosine_similarity(query_vector, item["vector"])} for item in index]
    return sorted(scored, key=lambda x: x["score"], reverse=True)[0]  # 最接近的匹配

print(search("我怎么才能把钱要回来?"))
# 返回退款政策那一条,即便它和查询没有共享任何关键词

查询"我怎么才能把钱要回来?"找到了退款那条内容,尽管它和这句查询没有共享任何重要的词语。语义搜索匹配的是含义,不是拼写。关键词搜索本来会完全错过它,因为查询和文档之间没有共同的关键词。

这个流程始终是同一个套路:先嵌入一次并保留向量,在请求时嵌入查询,打分、排序、返回最接近的结果。这就是**语义搜索**的循环,你会在这本手册接下来的内容里反复用到它。

Juno语义搜索 语义搜索先把你的文档一次性嵌入并存下向量,然后把每个新来的查询也嵌入,再按余弦相似度返回最接近的文档。它匹配的是含义,所以"帮我把钱要回来"能找到一条"退款政策"的内容,即便两者没有任何共同的关键词。这种基于含义的匹配,正是关键词搜索做不到的事。

玩具版方案把向量存在一个 Python 列表里,每次查询都给所有向量打一遍分。这对演示来说没问题,但语料库一旦变大就会崩:线性扫描要为每个查询重新对每一个向量做完整比较,而且每次重启,你还得在内存里重建整个索引。

解决办法是用一个**向量数据库(vector database)**:一种专门用来存储向量、并能快速从磁盘上回答"找出离这个查询向量最近的 k 个"这类问题的存储系统。你不再自己维护列表和循环,而是把向量写进去一次,搜索、持久化和单条记录的更新都交给数据库处理。

python
# 具体形态因向量数据库而异,但模式到处都一样
index.upsert([
    {"id": "doc-1", "vector": embed(chunk), "metadata": {"source": "faq.md", "section": "refunds"}}
    for chunk in chunks
])

results = index.query(vector=embed(user_question), top_k=5)  # 最近的5个,不只是1个

数据库能提供两个列表做不到的东西:

  • 一次调用就返回top-k结果,是最接近的若干个而不是单个最优结果,而这正是你要真正喂给下一个环节的内容。
  • 在每个向量旁边存储metadata(元数据)——来源文件、章节、时间戳——这样一个结果就能指回它的出处,你也可以据此过滤。

一旦你手上有了真实数据,就把向量存进向量数据库,而不是 Python 列表。列表是用来学习这套机制的,不是用来真正运行它的。

Juno语义搜索 语义搜索的流程是:嵌入一次、存储、嵌入查询、返回最接近的结果,但一个 Python 列表加一个循环只能撑住一次演示,因为每次查询扫描所有向量、每次重启都要重建,这套东西撑不住规模。一旦有了真实数据就转向向量数据库:它能从磁盘上快速做 top-k 搜索,并在每个向量旁存储元数据,让结果能指回自己的出处。列表用来讲清楚机制,数据库用来真正运行它。

那套参考流程——嵌入一次、存储、嵌入查询、打分、排序、返回——是正确的,但一旦碰到真实规模就撑不住。暴力扫描的开销和语料库大小呈线性关系,一旦到了百万级向量,加上任何实际的查询频率,每次搜索都要花掉你负担不起的一整个 CPU 秒。生产级搜索的核心思路就是尽量避免把查询和每一个向量都比较一遍。

这正是**近似最近邻(approximate nearest neighbor)**搜索能带来的好处(简称 ANN:一类通过接受"偶尔漏掉真正最近邻"这一小概率,来换取快速找到最接近向量的算法)。主要有两大流派:

  • HNSW(层次化可导航小世界图)为向量构建一个分层图,从粗到细地在图上遍历,查询延迟低,但内存开销大。
  • IVF(倒排文件)对向量做聚类,只在最近的几个簇里搜索,占用内存更少,但需要随着数据变化不断重新训练聚类中心。

超过几十万向量的规模,ANN 就不是可选项了,而是索引本身的必需品。你要调的取舍是召回率(recall)(索引实际返回的真实最近邻占比)相对延迟和内存的权衡:把召回率往 1.0 推,查询就会变慢;放松召回率,你就会悄无声息地漏掉相关的块。

玩具版流程完全掩盖的一个失效模式是新鲜度问题。索引是一份快照,所以当源文档发生变化时,它存下的向量就已经过时,却还会继续像最新内容一样被检索出来。你需要一套以文档变更为触发点的重新嵌入流水线:检测编辑动作、对该文档重新分块并重新嵌入,然后写入新向量、删除旧向量,理想情况下用内容哈希来追踪,这样你只需要重新嵌入真正变化过的部分,而不是每次改动都重新嵌入整个语料库。过时的向量,正是检索系统悄悄开始"说谎"的方式。

最后一个缺口是精确匹配。向量搜索天生就容易漏掉这一点:查询一个订单号 A-10293 或一个错误代码,嵌入出来会落在一片模糊的区域里,可能根本浮不出那份真正包含这个字符串的文档。**混合搜索(hybrid search)**把关键词搜索(精确命中确切字符串)和向量搜索(精确命中含义)一起跑,并把两边的排序结果融合起来,这样标识符、名称和罕见词就不会被语义上的模糊给"吞掉"。只要你的语料库里有用户会原样输入的东西,就该考虑用它。

Juno语义搜索 暴力扫描在大规模场景下会崩溃,所以超过几十万向量就要用近似最近邻:HNSW 延迟低但内存高,IVF 反过来,而且要在召回率和延迟之间做权衡,记住召回率越高、速度越慢。要建一套以文档变更为触发、配合内容哈希的重新嵌入流水线,因为索引是一份快照,过时的向量还会被当作最新内容检索出来。当用户会输入订单号之类的精确字符串时,加上混合的"关键词+向量"搜索,因为纯向量搜索会把这些内容模糊掉。

嵌入不适合做什么 ​

嵌入是一种相似度工具,不是一种思考工具。记住几个**局限**,才能用对工具:

  • 它做不了精确匹配。要找一个准确的订单号或错误代码,普通的关键词搜索或数据库搜索会更好用。
  • 它不会推理。它只能告诉你两件事是相关的,不能告诉你为什么相关,也判断不出一件事是否能从另一件事逻辑推出。
  • 它会过时。如果你的文档发生了变化,你必须重新嵌入它们,否则搜索结果指向的还是旧内容。

嵌入能找出相关的东西,但判断不出什么是真的、什么是最新的。每一个短板都有更合适的工具去补,知道该用哪个工具正是这门技能的一半。

Juno嵌入不适合做什么 嵌入衡量的是相似度,它不会推理,也做不了精确匹配。对于准确的订单号或错误代码,该用关键词搜索或数据库搜索。而且当你的文档发生变化时,要重新嵌入它们,否则搜索结果会指向过时的内容。

一个工具用得太顺手,陷阱就是什么都想拿它来用。有三个局限,标出了该在哪儿停下来、换个别的工具。

第一个是精确匹配。嵌入会把一个订单号或错误代码,和其他所有东西一样,映射进同一片模糊的语义空间,所以 A-10293 会落在其他"看起来像订单号"的字符串附近,而不是精确地落在自己身上。对于标识符、代码,以及任何用户会原样输入的值,关键词搜索或数据库搜索才是正确的工具,而**混合搜索(hybrid search)**(把关键词搜索和向量搜索一起跑)能让你两者兼得。

第二个是推理。相似不等于推断:两个块的分数很高,只说明它们讨论的是相关的东西,不代表其中一个能回答某个问题,也不代表一个能从另一个逻辑推出。真正的推理发生在你把检索出的块喂给的那个模型里,不发生在检索这一步。检索找出的是候选内容,思考的活儿归模型来干。

第三个是新鲜度,它是那种悄无声息咬你一口的问题。你的索引是你嵌入时那一刻的快照,所以文档被编辑之后,搜索结果还会一直返回旧的措辞,直到你重新嵌入为止。要把"变更时重新嵌入"当作系统的一部分来设计,而不是等有人发现答案不对了才手动去补。

Juno嵌入不适合做什么 嵌入是一种相似度工具,所以它会漏掉像订单号这样的精确匹配,这类需求要靠关键词搜索或者两者混合来解决,而且它不会推理,推理的活儿是你喂给块的那个模型在做。悄悄咬人的那个问题是新鲜度:索引是一份快照,文档被编辑后,搜索结果还会一直返回过时的措辞,直到你重新嵌入为止。要把重新嵌入建进系统里,而不是等有人发现答案错了才去处理。

知道嵌入的边界在哪里,才能让一套检索系统保持可靠,生产环境中有三条边界特别重要。

精确匹配是一个结构性的盲区,不是靠调参能解决的问题。嵌入会把输入压向语义上的"邻域",所以标识符、代码、SKU 和罕见的专有名词,都会被拉向看起来相似的字符串,而不是落在它们自己身上。纯向量搜索会把一个"差不多"的结果排在真正精确命中的结果前面。解决办法是混合检索,把一个词法索引(关键词或 BM25,一种经典的基于词频的排序方法)和向量索引融合起来,这样精确的词元能被精确匹配,含义能被语义匹配,你也不用把一次订单查询的成败赌在几何距离上。

漂移(drift)是那种会悄悄侵蚀系统的边界。**嵌入漂移(embedding drift)**指的是当你索引背后的模型版本发生变化时产生的错位:来自两个不同版本的向量处在不同的空间里,不可比较,所以用新模型嵌入的查询,拿去和用旧模型建的索引比较,会返回看起来很合理、实际上却是错的结果。要把模型版本固定写进索引的 schema,并把任何模型升级都当作一次完整的重新嵌入迁移,而不是简单替换,因为这里不会抛出任何错误,只会悄悄地让结果变差。

评估是那种大家会一直忽略,直到检索已经失效才想起来的边界。你无法靠肉眼判断检索好不好,必须去衡量它:留出一批已知相关块的查询作为测试集,计算recall@k(正确的块是否落在前 k 个结果里)和精确率(你返回的结果里有多少是真正相关的)。如果你没有在衡量 recall@k,你就根本不知道检索到底有没有在起作用。

这个评估集还能告诉你,一种新的分块策略、新的模型,或者新的混合权重配置,到底是真的带来了改善,还是只是用一批失败换掉了另一批你还没发现的失败。检索质量是RAG赖以立足的地基,值得用一套真正的测试来把关,而不是靠感觉。

Juno嵌入不适合做什么 嵌入在精确匹配上存在一个结构性盲区,所以对标识符和代码,要把词法索引和向量索引融合起来用,而不是完全依赖几何距离。嵌入漂移是那种悄无声息的问题:来自不同模型版本的向量无法比较,所以要固定版本,并把升级当作一次完整的重新嵌入。而且要用留出的测试集去衡量 recall@k,因为你无法靠肉眼判断检索是否有效,这个评估也是你区分"真正的改进"和"换了个方式一样失败"的方法。

实践中的应用 ​

完整的流程就在上面那个 search 函数里:一次性嵌入一小批 FAQ 条目,然后按含义回答查询。这个模式在任何规模下都是一样的,变化的只是存储方式。

在下一章里,你会拿最接近的匹配结果去喂给一个模型,让它基于你的文档来回答,而不是凭记忆回答。这个技巧就是RAG,它正是直接建立在你在这里写的这套东西之上。

Juno实践中的应用 语义搜索就是这整套流程:先把你的文档一次性嵌入,然后对每个查询嵌入它,并按余弦相似度返回最接近的结果。把这些最接近的匹配结果喂给一个模型,它就能基于你的文档回答,而不是凭记忆。这就是检索,也是下一章要讲的内容,直接建立在这套东西之上。

这套机制已经很清楚了:分块、批量嵌入、存进向量数据库、按相似度检索 top-k。等真正上生产环境,变化的是周边的配套工作——变更时的重新嵌入、你用来过滤的元数据、你固定下来的模型版本。

检索出的块并不是终点。在下一章里,你会把它们作为上下文喂给一个模型,让它基于你的文档来回答,而不是依赖它那已经冻结的训练知识。这就是RAG,它的回答质量,上限就是你在这里搭建的检索能力。

Juno实践中的应用 这套循环是:分块、批量嵌入、存进向量数据库、检索 top-k,而生产环境要做的是围绕它的配套工作:变更时的重新嵌入、元数据过滤、固定的模型版本。这些检索出的块会在下一章变成模型的上下文,那就是 RAG。它的回答质量,完全取决于你在这里搭好的检索能力。

这里讲到的一切——分块、归一化、ANN 索引、混合检索、新鲜度,以及 recall@k 评估——共同构成了检索层,它正是一套 AI 系统里悄悄决定答案质量好坏的那一部分。模型只能基于检索交给它的内容去推理,所以一个自信满满却错误的答案,往往是检索出了问题、只是披着"生成错误"的外衣。

这正是通向下一章的桥梁。RAG就是这套检索层,把有据可查的上下文喂给一个模型,而这里的每一种失效模式——过时的向量、漏掉的精确匹配、低召回率——都会在那里表现为一个错误的答案。把检索建得可衡量、够新鲜,RAG 才有坚实的地基可以立足。

Juno实践中的应用 检索层——从分块到 recall@k——决定了答案好不好,因为模型只能基于检索交给它的内容去推理。一个自信却错误的答案,往往是检索失败伪装出来的,所以真正的问题通常出在过时的向量或漏掉的精确匹配上。下一章的 RAG 就是这层检索在喂给一个模型,它的稳固程度完全取决于你在这里搭建的东西。