
用过关键词搜索的人大概都经历过这种挫败用户明明在问“我要退款”知识库里的 FAQ 写的是“如何办理退费流程”两边都认识“退”字可就是匹配不上。换成分词、同义词扩展效果也只是勉强够用。我自己第一次做客服问答原型的时候就在这个坑里趴了整整一周。去翻资料所有人都说“用 Embedding API 做语义检索”文档倒是写得清楚但真到自己写代码问题一个接一个这个 API 到底怎么调返回的一串浮点数是什么拿到了向量之后怎么找出最相似的文本网上教程多半只讲中间一步没人把整条链路讲全。这篇就从头到尾拆一遍文本向量化是在干什么、Embedding API 怎么选型、用 Python 怎么把接口调通、拿到向量之后怎么做相似度检索最后给一个可以直接抄作业的知识库 Demo。适合刚接触 Embedding、准备给项目加语义检索能力或者被 RAG 相关的面试题问得心里发虚的开发者和算法工程师。1. 先把文字变成数学问题检索系统为什么离不开 Embedding1.1 关键词匹配的致命短板传统搜索引擎的思路很直接建一个倒排索引把每篇文档里出现的词记录下来用户搜索的时候看哪些词命中了。这套方案跑了几十年到今天也没被淘汰但它有一个绕不过去的死穴——它只认识“词形”不认识“语义”。“退款”和“退货退款”还算好有公共词根。但“我的钱什么时候能回来”和“退款到账时间”之间几乎没有任何字面重合可人类一眼就知道这俩是一回事。关键词系统做不到这点它只能老老实实拿着“钱”“什么时候”“回来”去匹配结果自然是一塌糊涂。早期做法是靠同义词表硬补。你维护一张映射表把“钱回来”映射到“退款”匹配到就算命中。效果有但维护成本极高而且语言是活的东西今天出来一个新说法明天用户换一种方言映射表永远跟不上。更麻烦的是同义词表只能处理词级别的等价处理不了“我想关掉自动续费”和“别再每个月扣我钱了”这种句子级别的语义等价。于是大家把目光投向了向量化让计算机把文字转成稠密向量用数学上的距离来衡量语义上的远近。这就是 Embedding 登场的根本原因。1.2 Embedding 模型需要语义理解吗这是围绕 Embedding 最经典的疑问也是面试官最爱问的点。答案是需要但这个“理解”和人的理解不是一回事。Embedding 模型本质上是一个经过对比学习训练的语言模型。训练的时候模型会看到大量语义等价的句子对比如“怎么退款”和“退款流程是什么”然后被要求把这两个句子映射到高维空间中相邻的位置。反复训练之后模型内部的注意力机制学到的是什么样的词语组合在语义上是相近的。这其实是一种统计意义上的语义理解它不懂“退款”背后的交易逻辑但它知道“钱”“返回”“退回”“账户”这些词经常和“退款”出现在相同的语境里。所以你可以把 Embedding 理解成一个极其擅长“翻译”的机器它不改变你的文字而是把文字翻译成一串数字坐标。在这个坐标空间里语义相近的文本靠得近语义无关的文本离得远。我见过不少新手有个误解觉得 Embedding API 是某种 NLP 魔法传进去一段文字就自动提取出“意思”。实际上它只是在做表征学习——把变长的、离散的文本压缩成一个固定维度的、连续的数值向量。正因为是数值才可以做运算、排距离、聚类也给相似度检索提供了数学基础。1.3 一句话讲清文本向量化的本质给一个小白也能懂的类比。把每个词想象成棋盘上的一个位置原来我们只知道棋子的名字“车”“马”“炮”但不知道它们各自在哪儿。Embedding 做的事情是把这些棋子摆到棋盘上让棋风相近的棋子靠在一起。比如“退款”“退货”“赔付”这几个词会被放在左上角的小区域而“登录”“密码”“验证码”会被放在另一个角落。有了棋盘你就可以问“哪个棋子和退款离得最近”。这就是相似度检索。这里有一个很多人关心的点Embedding 模型输出的是“句子级”向量不是“词级”向量。一个 1536 维的向量把整个句子的语义压缩进去了。你可以拿这个向量去和文档库里的所有句子向量做比对找最相近的几个。这个思路支撑起了后面要讲的 RAG、语义搜索、文本聚类等一大堆应用。2. 选型之前先弄清这三个数字维度、长度上限和价格2.1 主流 Embedding API 横向对比市面上的 Embedding API 不少但核心差异就集中在几个参数上。我把最常见的几个列出来帮你在动手前先有个数。以下数据以各厂商近期的公开文档为准采购前建议再确认一次实时信息。服务模型名向量维度单次文本长度上限计费模式特点OpenAItext-embedding-3-small15368191 tokens按 token 计费英文效果好性价比高OpenAItext-embedding-3-large30728191 tokens按 token 计费精度更高成本和延迟也高智谱embedding-32048可调按 token 计费中文语义效果好有免费额度中文场景可以考虑Ollama 本地nomic-embed-text7688192 tokens 左右免费本地运行离线、隐私友好适合开发验证Ollama 本地all-MiniLM-L6-v2384512 tokens免费本地运行轻量适合原型阿里 DashScopetext-embedding-v31024 / 768 / 512 可选按 token 计费中文场景常用支持多粒度中文领域覆盖广这里先说明一点DeepSeek 这类以对话模型出名的 API主要提供 chat/completion 接口Embedding 通常不在其核心服务列表里。如果你在项目里遇到了“需要大模型 API 做生成还需要 Embedding API 做召回”的需求常见做法是生成用 DeepSeek、ChatGPT 这类大模型召回走专业的 Embedding API 或本地部署模型各管一段。这也是 RAG 架构里的标准分工。2.2 维度不是越高越好看到 1536、3072、2048 这些数字小白第一反应是维度越高越牛。这个直觉只说对了一半。维度高意味着每个向量能承载的信息量更大模型对语义的区分能力通常也更强。但代价同样直接存储成本成倍上涨计算相似度的时间边长。3072 维的向量和 384 维的向量相比单次点积运算量差了接近 8 倍如果你的文档库有 100 万条文本这个差距会直接变成不可忽略的基础设施成本。更微妙的一点是很多模型支持降维输出。OpenAI 的 text-embedding-3 系列就支持通过 dimensions 参数把向量截断成更低的维度OpenAI 官方说这么做在多数任务上精度损失很小。这背后的原因是高维空间中存在大量冗余信息模型把语义核心集中在了少数几个维度上。我个人的经验是原型阶段先用 768 或 1024 维跑通真要上线再根据检索效果和成本评估要不要上高维度。别一上来就冲着最大维度去后续改起来很费劲。如果你用的是云端 API向量维度还直接决定了存储和每次调用的成本不是无关大局的数字。2.3 本地部署也是一种选择不要再被“API 必须用云”限制住很多人一提到 Embedding API脑子里全是云端付费服务。实际上如果你有部署条件本地跑开源 Embedding 模型很香。Ollama 一条命令就能拉起 nomic-embed-textollama pull nomic-embed-text ollama run nomic-embed-text跑起来之后直接通过本地 HTTP 接口调用curl http://localhost:11434/api/embeddings -d { model: nomic-embed-text, prompt: 如何办理退款 }返回结果里就是一个 768 维的浮点数组。整个过程不需要 API Key不花钱响应速度还快。我之前做一个内部知识库工具文档都是公司内部资料不能往外传最后就是用 Ollama 本地部署解决的。开源模型这块值得记几个名字中文场景下BAAI 的 bge-m3 和 bge-small-zh-v1.5 效果不错英文场景nomic-embed-text、all-MiniLM-L6-v2、mxbai-embed-large 都是社区验证过的选择。这些模型在 HuggingFace 上都能直接下通过 sentence-transformers 库可以非常方便地调用from sentence_transformers import SentenceTransformer model SentenceTransformer(BAAI/bge-small-zh-v1.5) embeddings model.encode([如何办理退款, 我要退货])本地部署的缺点也要认清没有云端的可用性和弹性不擅长处理超大规模动态更新对部署机器的内存和 CPU/GPU 有一定要求。但用来做开发原型、内部工具、隐私敏感项目它是极合适的方案。而且本地跑出来的向量格式和云端 API 是一样的代码层面几乎无缝切换。3. 第一次调用 Embedding API从 RESTful 规范到 Python SDK3.1 拿到 Key 之后先看这几个字段不管选哪家服务商Embedding API 的调用范式都差不多POST 一个 JSON 到指定接口带上模型名和输入文本拿回来一个包含向量的 JSON 响应。哪怕各家接口路径不一样核心字段基本是这些model模型名称比如 text-embedding-3-small、embedding-3。input要向量化的文本字符串或者字符串数组。encoding_format部分服务支持指定返回浮点数还是 base64 编码省流量。dimensions部分服务支持指定输出维度降低存储成本。user可选用于做调用方标识。响应结构也很统一核心是一个data数组每个元素里有embedding字段和index字段。index对应你提交的输入顺序embedding是那一串浮点数。别把顺序弄错批量提交的时候很多人在这里翻车。3.2 用 requests 手写一个最简客户端先不急着上 SDK用 requests 直连一遍能强化对接口的理解。下面这段是一个完整的 OpenAI 兼容调用示例绝大多数厂商的 Embedding API 格式都长得差不多import requests import json API_KEY sk-xxxx EMBEDDING_URL https://api.openai.com/v1/embeddings MODEL_NAME text-embedding-3-small def get_embedding(text: str) - list: resp requests.post( EMBEDDING_URL, headers{ Authorization: fBearer {API_KEY}, Content-Type: application/json, }, datajson.dumps({ model: MODEL_NAME, input: text, }), timeout30, ) resp.raise_for_status() result resp.json() return result[data][0][embedding] if __name__ __main__: vec get_embedding(如何办理退款) print(len(vec), vec[:10])几个关键点值得解释一下。timeout30是必须的。Embedding API 在服务端高峰期可能响应很慢不设超时的话客户端请求会一直挂着吃满连接池。另外把/v1/embeddings这个路径记下来你就理解为什么这个接口被大家叫做 embeddings 接口了——它是 RESTful 风格里对“向量资源”的操作入口。3.3 用官方 SDK 简化开发requests 版本适合理解协议但真写工程代码我还是建议用官方 SDK。OpenAI Python SDK 的调用方式非常简洁from openai import OpenAI client OpenAI(api_keysk-xxxx) response client.embeddings.create( modeltext-embedding-3-small, input如何办理退款, encoding_formatfloat, ) vec response.data[0].embeddingSDK 会自动处理超时重试、异常包装、JSON 序列化这些脏活。而且不同厂商的 SDK 通常遵循相似的风格比如智谱的 Python SDKfrom zhipuai import ZhipuAI client ZhipuAI(api_keyxxxx) response client.embeddings.create( modelembedding-3, input如何办理退款, ) vec response.data[0].embedding调用两个服务商的代码几乎长得一样迁移成本很低。3.4 批量提交与并发别一条一条循环实际项目中极少逐条调用 Embedding API都是批量处理成百上千条文本。OpenAI 的接口支持一次提交一个字符串数组官方也建议这么做因为批量处理在服务端更高效而且能减少网络往返次数。批量调用的正确姿势texts [如何退款, 密码重置流程, 发票怎么开] resp client.embeddings.create( modeltext-embedding-3-small, inputtexts, ) # 注意按 index 排序不要依赖返回顺序 sorted_data sorted(resp.data, keylambda x: x.index) vectors [item.embedding for item in sorted_data]一个我踩过的细节虽然协议保证index和输入顺序一致但不同 SDK 版本对返回数据的排序处理不一致防御性编程的做法是永远手动按index排序别假设服务端一定按顺序返回。并发层面的考量是大多数 Embedding API 有每分钟请求数RPM和每分钟 token 数TPM限制。批量 100 条文本时可以一次提交完如果是 10 万条文本的离线任务必须用多线程 限速来控制速率避免触发 429 限流。常见做法是用 ThreadPoolExecutor 把任务切分成多批配合 token bucket 限速。网上很多开源项目直接扔一个 for 循环进去跑跑到一半被限流打回都是血泪教训。4. 相似度检索的工程实现从余弦公式到 TopK4.1 拿到了向量怎么判断“像不像”Embedding API 返回的向量只是一个高维坐标。现在的问题是用户查询“怎么退款”向量化了文档库里有 10 万条文本向量怎么找出最像的 5 条答案是度量向量之间的距离。最常用的指标是余弦相似度。余弦相似度不看两个向量的长度只看它们的方向是否一致。两个文本方向越一致夹角越小相似度越接近 1完全无关则趋近 0。这个特性很有用因为 Embedding 模型输出的向量模长和文本长度、措辞风格都有关系但我们关心的只是“语义方向”是否一致。公式长这样cosine_similarity(A, B) (A · B) / (||A|| × ||B||)分子是 A 和 B 的点积逐元素相乘后求和分母是两个向量的模长乘积。换算到 Python几行就写完import numpy as np def cosine_similarity(vec_a, vec_b): vec_a np.asarray(vec_a, dtypenp.float32) vec_b np.asarray(vec_b, dtypenp.float32) dot np.dot(vec_a, vec_b) norm_a np.linalg.norm(vec_a) norm_b np.linalg.norm(vec_b) return dot / (norm_a * norm_b)OpenAI 官方还有一个优化建议服务端返回的向量先做 L2 归一化除以模长之后再用点积代替余弦相似度结果完全一样但省掉了每次计算余弦时的模长除法。如果你的向量已经归一化检索时的点积运算会快很多。4.2 手写一个 TopK 检索器当向量规模在几万条以内时纯暴力检索完全够用逐条计算相似度取排序后前 K 个。下面这段代码就是一个能跑的最小检索器import numpy as np from typing import List, Tuple class VectorSearcher: def __init__(self, vectors: List[List[float]]): # 先把所有向量改成 numpy 矩阵一次计算全部相似度 self.mat np.asarray(vectors, dtypenp.float32) self.mat self.mat / np.linalg.norm(self.mat, axis1, keepdimsTrue) def search(self, query_vec: List[float], k: int 5) - List[Tuple[int, float]]: q np.asarray(query_vec, dtypenp.float32) q q / np.linalg.norm(q) # 向量化计算整个矩阵一次点积 scores self.mat q topk np.argsort(scores)[::-1][:k] return [(int(i), float(scores[i])) for i in topk]这里用了一个小技巧初始化时就把所有向量归一化查询时只需要做一次矩阵乘向量。self.mat q这一行numpy 会在底层用 C 循环完成全部点积计算比 Python for 循环快几个数量级。几万条向量在这种实现下毫秒级返回做原型绰绰有余。4.3 什么时候必须上向量数据库暴力检索虽然简单但它有一个硬伤每来一个查询就要和全量向量比较一次。文档量到十万级以上或者对响应延迟有硬性要求纯 numpy 方案就开始吃力了。这时候需要引入专门做向量检索的工具。向量数据库内部用 HNSW、IVF 这类近似最近邻ANN算法提前建好索引结构把查询阶段的时间复杂度从 O(N) 降到接近 O(log N)。常见选择Chroma轻量级Python 嵌入友好适合本地和小型项目。FAISSMeta 开源的向量检索引擎不是完整数据库但检索性能极强。Milvus分布式向量数据库适合大规模生产环境。QdrantRust 写的性能好部署简单。选型逻辑我一般这样判断原型阶段用向量数据库反而是负担内存数组 numpy 足够了如果文档量涨到几十万先把 FAISS 接上因为它轻、快、无额外服务依赖再往上走并发量大、需要数据管理和高可用再上 Milvus 或 Qdrant。FAISS 的调用也不复杂装一个 faiss-cpu 就够了import faiss dimension 1536 index faiss.IndexFlatIP(dimension) # 内积索引配合归一化向量即余弦相似度 # 添加文档向量必须是 float32 index.add(np.asarray(vectors, dtypenp.float32)) # 查询返回距离和索引 D, I index.search(np.asarray([query_vec], dtypenp.float32), k5)5. 一个能跑通的知识库 Demo从文本切分到语义命中5.1 需求定义与文本准备说了这么多理论我带你做一个完整的语义检索 Demo。场景设定为把一份 30 页的售后政策文档变成一个可搜索的小知识库用户输入自然语言问题系统返回文档中相关的片段。第一步不是调 API而是处理文档。Embedding API 对输入长度有硬性上限就算它能接收长篇文本直接把一整页塞进去也会稀释语义——一个 3000 字的段落里可能包含 5 个不同主题跟查询相关的可能只有其中 100 个字但向量化之后这 100 个字被其余无关内容淹没了。所以标准做法是切分。切分策略的朴素原则是尽量保证每个片段是一个语义完整的单元长度控制在模型输入范围的三分之一到二分之一以内。按段落切分是效果最自然的方式。Python 里一句text.split(\n\n)就能得到一个段落列表但实际文档经常有空行、标题、列表项需要先做一次清洗。我常用的切分逻辑是import re def chunk_text(text: str, max_chars: int 500) - list[str]: # 按段落切分 paragraphs [p.strip() for p in re.split(r\n, text) if p.strip()] chunks [] current for para in paragraphs: if len(current) len(para) max_chars: if current: chunks.append(current) current para else: current \n para if current: chunks.append(current) return chunks注意这个版本只是最简单可用的方案。实际项目中要对标题、列表结构做更精细的处理甚至引入按 Token 数的切分。但核心思想不变语义完整性 长度统一性。5.2 向量化并建立索引准备好文档片段之后批量向量化。我这里用一个统一的函数来做向量化操作方便后续切换不同的 APIdef encode(texts: list[str]) - list[list[float]]: resp client.embeddings.create( modeltext-embedding-3-small, inputtexts, ) sorted_data sorted(resp.data, keylambda x: x.index) return [item.embedding for item in sorted_data]假设chunks是切分后的 428 个文本片段那么调用一次encode(chunks)需要分批因为 API 对单次请求的 token 数有限制所以需要把 chunks 切成 64 个一组的小批次这就回到并发那一节的内容了。拿到所有向量后放到 FAISS 里import numpy as np import faiss vectors encode(chunks) # List[List[float]] matrix np.asarray(vectors, dtypenp.float32) faiss.normalize_L2(matrix) # 归一化使内积等价于余弦相似度 index faiss.IndexFlatIP(matrix.shape[1]) index.add(matrix)faiss.normalize_L2这一步很重要它把每行向量归一化成单位向量之后用内积算相似度就和余弦相似度完全等价。5.3 查询、返回与完整效果现在模拟一个用户提问“我买的东西坏了你们给不给换”查询流程分三步向量化查询 → 搜 TopK → 拼装结果返回。def search(query: str, k: int 3) - list[str]: q_vec encode([query])[0] q_arr np.asarray([q_vec], dtypenp.float32) faiss.normalize_L2(q_arr) D, I index.search(q_arr, k) results [] for score, idx in zip(D[0], I[0]): results.append({ chunk: chunks[int(idx)], score: float(score), }) return results我拿一个测试文档跑了一遍查询“东西坏了能不能换”确实召回了“七天无理由退货”“质量问题的退换货政策”这两个片段相关片段排在前两位。整个流程从查询到返回耗时不到 200 毫秒其中大头是 Embedding API 的 request 延迟本地检索部分只有几毫秒。这就是一个完整的语义检索 Demo也是后续做 RAG 最难的部分——召回模块。6. 实测中最容易翻车的四个细节与处理方案6.1 超长文本的 400 报错与静默截断Embedding API 对单条输入的 token 数有硬性上限。OpenAI 是 8191 tokens本地模型差异很大。超长时会怎样云端 API 通常直接报 400 错误错误信息里的关键词是maximum context length。如果你用的是某些本地模型它可能不会报错而是默默截断你的文本——这种情况下向量化的文本内容已经不全了但你还没有察觉等检索效果差的时候排查起来特别费劲。处理方案是统一做长度控制。tokens 数和字符数不是一回事中文场景一个字符可能占 1 到 2 个 token英文则是按词块切。稳妥做法是引入一个 tokenizer 库来计算真实长度超长就递归切分def truncate_or_split(text, max_tokens6000): tokens tokenizer.encode(text) if len(tokens) max_tokens: return [text] # 按 token 边界切分保证每段不超限 ...我自己常用的保险手段是文本送入 API 前先做一次防御性截断宁可截短一点也不要让它静默丢失尾部信息。文本尾部往往是总结性陈述丢了会影响整体语义。6.2 中文文本的编码与清洗调 Embedding API 时HTTP 层的编码坑不算多因为现代 HTTP 库默认都能处理好 UTF-8。真正的坑在文本本身。文档里经常带着全角半角混用、乱码字符、零宽空格、不可见字符这些噪音会被编码进向量里对检索做负贡献。我处理售后文档时遇到过一个问题正文里全是“\u200b”零宽空格肉眼看不见但 embed 之后和干净文本的向量差异明显导致同样意思的两段话相似度反而低。排查方式是先把文档 RAISE 到纯文本层面对噪声字符做清洗比如统一使用一个字符白名单做过滤。网上很多开源项目会直接忽略这一层我当时排查了半天最后定位到是零宽空格的问题之后我在所有文本向量化之前都强制做一次清洗效果立刻稳定。另外中文场景有一个敏感点涉及分词。有些模型自带分词器但如果你先用 Jieba 分词再拼接回去再向量化等于人为破坏了字与字之间的共现关系效果通常不升反降。我的建议是Embedding 模型直接吃原始中文文本就行不需要额外的分词预处理。这和传统 IR 时代的做法完全相反也是很多从搜索项目转过来的人最容易惯性踩坑的地方。6.3 相似度阈值不是拍脑袋定的Demo 里返回了相似度分数但“相似度多少算匹配成功”这个问题没法统一回答。不同模型、不同语料、不同文本长度下相似度分布差异很大。OpenAI 模型对语义接近的两句话相似度能到 0.85 以上但问“实际完全不相关”的问题相似度也未必低于 0.6因为通用 Embedding 模型会把常见词的共性也编码进去。正确定阈值的方法是观测分布。上线前拿一批真实的用户 query每一条都算出它与整个文档库的最高相似度画一个分布图。你会发现有相关答案的 query 和没有相关答案的 query最高相似度分数大致形成两个峰两个峰之间的谷底就是比较合理的阈值位置。如果懒得画图还有个经验做法先用 0.8 当作初始阈值再用 50 条人工标注的 query 测一遍召回率和精确率根据结果往上下调。检索系统的召回率比精确率重要阈值宁可调低一点让 Rerank 或后续的 LLM 去过滤也别一开始就拦掉太多相关结果。这里涉及的概念在慢热词那个“embedding rerank rag有关考题”里是高频考点等于是 RAG 管线的第一层漏斗。6.4 API 限流、超时与错误码处理跑偏了前面那么多最后绕回 API 调用本身。对接云端 Embedding API有一类错误几乎每个用的人都遇到429限流、401鉴权失败、503/529服务端过载。我看到很多项目在这块的处理方式是抛出异常然后终止任务这在大规模离线向量化时会非常痛苦跑了一个小时后崩掉前功尽弃。工程化的做法是重试 指数退避。下面这个函数是标准的容错模板import time import random def call_with_retry(func, max_retries5): for attempt in range(max_retries): try: return func() except Exception as e: if attempt max_retries - 1: raise # 指数退避 随机抖动防止多个请求同时重试造成雪崩 time.sleep(min(2 ** attempt, 30) random.uniform(0, 1))另外401 报错时优先检查 API Key 是否配错别把这个错误也塞进重试逻辑里因为重试 100 次也过不了鉴权只会浪费配额。503/529 这类过载错误则适合多等一下再重试。这里要说一句 Windows 用户使用某些 Docker 容器引擎时的常见报错信息比如failed to connect to the docker api它的意思是本地 Docker 引擎没启动和服务商无关先查本地再查代码。7. 进阶玩法Embedding Rerank 两阶段召回7.1 为什么 Embedding 一步到位还不够既然 Embedding 已经能算相似度了为什么还冒出一个 Rerank这要从模型结构说起。Embedding 模型是双塔结构——query 和 document 分别单独过一遍模型各自编码成向量最后再做向量运算。双塔的好处是文档向量可以提前算好、存起来、建索引查询时才计算 query 那一侧的向量所以可以实现大规模快速召回。但双塔结构有一个天然劣势query 和 document 在编码阶段完全没有交互。即使训练时它隐式地学到了一些对应关系本质上它仍然把两边的语义分别压缩到独立的向量里再在数学空间里去比较方向。这种压缩不可避免会把“问”和“答”之间细粒度的关联信息丢掉。Rerank 模型改用了交叉编码器的结构。它把 query 和 document 拼接成一个输入序列一起送进 Transformer让模型对 query 和 document 的每个 token 做充分的注意力交互最后输出一个相关性分数。因为交互更充分交叉编码器对相关性的判断通常比双塔更准但代价是 document 不能预先向量化缓存必须对每一个候选 document 实时过一遍模型速度慢、成本高。物理解释就是这样Embedding 负责广撒网Rerank 负责精确选。7.2 两阶段检索的完整流程把两者组合起来是生产环境最常见的标准方案召回阶段用 Embedding 从文档库中快速取回 Top 50 候选片段。精排阶段用 Rerank 模型对这 50 个候选片段重新打分。截断阶段取精排后的 Top 3 到 Top 5 交给后续的 LLM 做答案生成。这个过程就是热词里提到的“embedding rerank rag有关考题”的核心答案。先粗后精用最小的成本获得最高的精度。实现 Rerank 并不复杂社区里有 bge-reranker 这类现成模型也可以用 Cohere 等厂商的 Rerank API。一个最小实现大概长这样from sentence_transformers import CrossEncoder reranker CrossEncoder(BAAI/bge-reranker-v2-m3) def rerank(query: str, candidates: list[str], top_k: int 3): pairs [[query, doc] for doc in candidates] scores reranker.predict(pairs) # 按分数降序排列取 top_k ordered sorted(zip(candidates, scores), keylambda x: x[1], reverseTrue) return ordered[:top_k]我做过一个对比实验仅用 Embedding 取 Top 5Rerank 精排后取 Top 5同样的 100 个 query答案命中率大概有 15 到 20 个百分点的提升。尤其在用户用词和文档用词差异很大的场景下Rerank 的价值非常明显。7.3 小成本方案的取舍与一点个人经验两阶段方案听起来是标配但也要分场景。自己的离线知识库只有几千条文档用暴力检索 Top 10 就够了上 Rerank 是徒增复杂度。等文档量大、query 表达多样的时候再做 Embedding 召回 Rerank 精排也不迟。工程上一个灵活的取舍是Embedding 始终保留两条路——向量数据库直接召回和纯内存 TopK根据数据量去切换。最后说一点反复踩过的经验。Embedding 的效果高度依赖模型与语料的匹配度。英文通用语料用 OpenAI 模型没问题但中文学术文档、中文客服对话这种口语化强、专业术语密集的场景开源中文模型如 bge-m3反而经常比通用闭源模型更准。选型前先拿自己的 100 条真实语料做一个 semana 相似度的小测试比自己盲猜要可靠得多。Embedding API 的整套使用链路看起来知识点多其实本质上就是文本切分 → 向量化 → 建索引 → 检索 → 可选精排。把这五个环节逐个跑通之后不论是语义搜索、知识库问答还是 RAG 应用核心骨架都是一样的。遇到不懂的回来重新过一遍相似度计算和两阶段召回大部分面试题和线上问题都能找到答案。