前 13 篇里,Agent 获取知识的路子只有两条:模型参数里的训练记忆,和工具实时拿回的外部数据。问它公司内部的报销制度,问它你三年前博客里写过的某句结论,它要么一本正经地编,要么老实承认不知道。从本篇起系列进入第三模块——RAG(Retrieval-Augmented Generation,检索增强生成),给 Agent 接上第三条知识通道:你自己的文档、笔记、文章这些私有知识。
RAG 的地基是两块积木:Embedding(嵌入)把文本变成可以比较的向量,向量库负责存下成千上万个向量、毫秒级找出最相近的几个。本篇从零把这两块过手:先用三句话亲手算一次语义距离,再过模型选型、Chroma 入门、检索实验、索引原理、成本账。代码基于 langchain-core 1.6.x 与 langchain-chroma(2026-10 基线),全文按"嵌入原理 → 选型 → 建库 → 实验 → 索引 → 成本 → 总结"组织,细节可对照 OpenAI 嵌入模型指南。
1、Embedding 是什么
Embedding 模型做的事一句话讲完:输入一段文本,输出一个固定长度的浮点数数组——向量,并且让语义相近的文本在几何上离得近。"猫在垫子上"和"小狗趴在地毯上"字面几乎不重叠,但都在描述"动物趴在某个面上";"今天股市大涨"跟它们八竿子打不着。好模型会把前两句映射到高维空间里相邻的位置,把第三句推远。空口无凭,直接算。
下面用 text-embedding-3-small 嵌入三句话,用 numpy 手写余弦相似度:
import numpy as np
from langchain_openai import OpenAIEmbeddings
emb = OpenAIEmbeddings(model="text-embedding-3-small")
texts = ["猫在垫子上", "小狗趴在地毯上", "今天股市大涨"]
vecs = np.array(emb.embed_documents(texts))
def cos(a, b): # 余弦相似度 = 内积 / 模长乘积
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
print(f"猫 vs 狗: {cos(vecs[0], vecs[1]):.3f}")
print(f"猫 vs 股市: {cos(vecs[0], vecs[2]):.3f}")
print(f"狗 vs 股市: {cos(vecs[1], vecs[2]):.3f}")
运行输出(数值随模型版本小幅浮动,排序稳定):
猫 vs 狗: 0.512
猫 vs 股市: 0.118
狗 vs 股市: 0.104
前两句的相似度是不相关句对的三到四倍,语义结构被编码进了几何距离。余弦相似度只看向量方向、不管向量长度,取值 -1 到 1,越大越像;自然语言句对通常落在 0.1(无关)到 0.6(高度相关)之间,很少逼近 1。语义检索的本质,是把"意思像不像"这个模糊问题,变成"向量距离近不近"这个可计算问题。
2、模型与维度选型
常用 Embedding 模型一张表看清,API 价格以 OpenAI 定价页为准:
| 模型 | 渠道 | 维度 | 价格(每百万 token) |
|---|---|---|---|
text-embedding-3-small |
OpenAI API | 1536 | $0.02 |
text-embedding-3-large |
OpenAI API | 3072 | $0.13 |
bge-m3、bge-large-zh-v1.5 |
本地 sentence-transformers | 1024 | 免费,算力自理 |
nomic-embed-text |
Ollama | 768 | 免费,算力自理 |
选型建议:API 起步用 small——1536 维、质量对得起价格,是绝大多数 RAG 的默认答案;large 只在检索质量实测卡脖子时再上,存储与计算成本翻倍。本地路线走 sentence-transformers 的 HuggingFaceEmbeddings(需装 langchain-huggingface)或 OllamaEmbeddings(langchain-ollama),数据不出机器,代价是自己管 GPU 与模型质量。
LangChain 侧所有模型实现同一个抽象——langchain-core 的 Embeddings 接口,核心就两个方法:
from langchain_openai import OpenAIEmbeddings
emb = OpenAIEmbeddings(model="text-embedding-3-small")
q = emb.embed_query("怎么提高睡眠质量") # 查询侧:一条文本 → 一个向量
ds = emb.embed_documents(["文档一", "文档二"]) # 入库侧:批量文本 → 向量列表
print(len(q), len(ds), len(ds[0])) # 1536 2 1536
两个方法对应两种场景:embed_documents 批量嵌入要入库的文档,embed_query 嵌入用户的一句话查询。多数实现里两者算法相同,拆开是给"查询与文档可用不同策略"留的口子——不对称检索里两边各有各的编码器。换模型只改构造参数一行,下游代码不动。完整清单见 Embeddings 接口参考。
中文场景三条注意。其一,选多语言模型:text-embedding-3 系列与 bge-m3 都是原生多语言,中英混合语料没问题。其二,OpenAI 3 系列支持降维,构造时传 dimensions=512 这类参数,向量更省但质量略降。其三最致命:换了模型必须全量重建索引——维度可能对不上,就算凑巧相同,两个模型的语义空间也完全不同,新向量插进旧库等于往英文书架里塞法语词典。
3、第一个向量库
第 1 节的相似度是现算的:三句话无所谓,三千篇文档每篇 1536 维,每次查询逐条算余弦扛不住,还缺持久化、元数据过滤这些正经能力。这些活交给向量库。教学与原型场景首选 Chroma:装包即用、零外部服务、数据自动落盘。
uv add langchain-chroma
装的是官方集成包 langchain-chroma,它会带上 Chroma 本体。最小完整链路:建库、写入、查询。
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
texts = ["改善失眠:固定作息,睡前一小时远离屏幕",
"深度睡眠与记忆巩固密切相关",
"咖啡因半衰期约 5 小时,午后慎饮",
"本周 A 股成交额突破两万亿"]
metas = [{"topic": "sleep"}] * 3 + [{"topic": "stock"}]
db = Chroma(
collection_name="demo",
embedding_function=OpenAIEmbeddings(model="text-embedding-3-small"),
persist_directory="./chroma_db", # 落盘目录,自动持久化
)
db.add_texts(texts, metadatas=metas)
for hit in db.similarity_search("怎么提高睡眠质量", k=2):
print(hit.page_content)
逐行拆解。Chroma(...) 建库:collection_name 相当于表名,同一个 persist_directory 下可并存多个 collection;embedding_function 绑定模型,之后所有读写都经过它。add_texts 写入:此刻才真正发生嵌入调用,四条文本被打成四个向量,连同原文、元数据一起落盘。similarity_search 查询:把问题嵌入成向量,在库里找最近的 k 条,返回 Document 对象,page_content 是原文。
两个实战提醒。其一,持久化是自动的:指定了 persist_directory 就写盘,老教程里那句 .persist() 在新版已经废除,照抄会直接报 AttributeError。其二,同目录重复运行脚本会把四条文本再写一遍——给 add_texts 传 ids(内容哈希即可),同 id 覆盖、不重复。完整参数见 Chroma 集成文档。
顺带认识两个邻居,后面模块会用到:langchain-core 自带 InMemoryVectorStore,进程内存版,单测与教学最轻;FAISS(langchain-community 包)是 Meta 出品的检索库,速度极快,但没有内置服务与元数据过滤,持久化要自己导出索引文件。本系列教学统一用 Chroma。
4、检索实验与阈值过滤
4.1、top-k 检索实验
第 3 节的查询"怎么提高睡眠质量",打印的前两条是"改善失眠……"和"深度睡眠……"——第一条连"睡眠"两个字都没出现。这就是向量检索与关键词搜索的分水岭:匹配发生在语义空间,不在字面。用带分数的接口看个通透:
results = db.similarity_search_with_score("怎么提高睡眠质量", k=4)
for doc, score in results:
print(f"{score:.3f} {doc.page_content}")
输出(数值随模型版本小幅浮动,量级稳定):
0.887 改善失眠:固定作息,睡前一小时远离屏幕
1.012 深度睡眠与记忆巩固密切相关
1.204 咖啡因半衰期约 5 小时,午后慎饮
1.711 本周 A 股成交额突破两万亿
三条 sleep 主题全部排在股市前面,咖啡因那条离得稍远但也在相关圈内。注意分数方向:Chroma 返回的不是相似度,是距离,越小越近。默认距离是平方 L2(欧氏距离的平方),而 OpenAI 输出的向量已归一化(模长为 1),此时平方 L2 = 2 − 2×余弦相似度——0.887 对应相似度约 0.56,与第 1 节的量级对上了。做阈值判断时这个口径不直观,建库时把距离换成余弦:
cos_db = Chroma(
collection_name="demo_cos",
embedding_function=emb,
persist_directory="./chroma_db_cos",
collection_metadata={"hnsw:space": "cosine"}, # 距离换成余弦
)
cos_db.add_texts(texts, metadatas=metas) # 四条重新灌入,代码同第 3 节
余弦空间下返回的分数是余弦距离,等于 1 减余弦相似度,取值 0 到 2:0 表示方向一致,1 附近是"不相干",2 是方向相反。这个口径一眼能读懂,也才谈得上阈值。
4.2、阈值过滤
top-k 有个先天毛病:无论问什么,它都硬凑满 k 条——问"怎么做红烧肉",它也会把库里相对最近的塞给你。RAG 场景这很致命:不相关内容进了上下文,模型要么被带偏要么开始编。宁可空手,不可凑数:
for doc, dist in cos_db.similarity_search_with_score("怎么做红烧肉", k=4):
if dist < 0.7: # 余弦距离阈值,小于才相关
print(f"{dist:.3f} {doc.page_content}")
输出为空——四条里没有一条够得着 0.7,这是正确行为。换回睡眠查询则命中前三条,股市那条(距离约 0.86)被挡在门外。阈值没有圣杯:0.5 严、0.7 是常见起点,拿自己的语料标十来个正负例校准最靠谱。另注意 hnsw:space 必须建库时指定,建好的 collection 改不了——距离度量决定索引结构,详见 Chroma 集合配置文档。
5、暴力计算与 ANN 索引
向量库检索的瞬间发生了什么,值得拆开看一次。最朴素的做法是暴力(brute-force):查询向量与库里 N 条逐一算距离、排序、取 top-k。结果精确、实现十行,但复杂度 O(N×d):一万条毫秒级,百万条秒级,亿级想都别想。库一上规模,延迟和算力都扛不住。
ANN(Approximate Nearest Neighbor,近似最近邻)的思路是用一点召回率换数量级的速度。工业界事实标准是 HNSW(Hierarchical Navigable Small World,分层可导航小世界图):每条向量是图上一个节点,与近邻连边;边分多层,顶层稀疏、跨度大,底层稠密、步子小。查询从顶层入口贪心走向更近的节点、逐层下沉,到底层小范围内精确定位——像"高速转国道转小巷",绝大多数节点根本不碰。漏掉真正最近邻的概率存在但可调,Chroma 暴露了 hnsw:search_ef 这类参数控制搜索时考察的邻居数。
| 方案 | 结果 | 速度 | 适用规模 |
|---|---|---|---|
| 暴力计算 | 精确 top-k | O(N),慢 | 万级以下 |
| HNSW | 近似 top-k,召回可调 | 近似对数级 | 百万到亿级 |
Chroma 的默认索引就是 HNSW,默认距离平方 L2,第 4 节配的 cosine 正是它的元数据开关;FAISS 两条路都给:IndexFlatL2 暴力精确、IndexHNSWFlat 图索引。选型一句话:百万级以下、原型与中小生产,别纠结索引,默认值就是对的;真到亿级再研究向量量化与分片,那是部署阶段的话题。
6、成本与批量嵌入
Embedding 按输入 token 计费。small 每百万 token $0.02、large $0.13,直接换算成体感:中文粗略一个字一个 token 上下,一篇 3000 字的博客约 3000 token,把 1000 篇全量嵌入一遍约 300 万 token——六美分,一毛钱都不到。查询侧每次检索只嵌入用户一句话,成本忽略不计。RAG 里真正烧钱的是每轮对话的 LLM 生成 token,Embedding 是零头,别在这里省。
工程上三条纪律:
- 建库脚本要幂等:
add_texts传ids(内容哈希),重复运行是覆盖而不是重复入库、重复计费 - 大库分批:每批几百到一千条调
add_texts,失败可断点续传;OpenAIEmbeddings有chunk_size参数控制单次请求批量 - 换模型等于重建索引、重新计费一次,第 2 节的选型值得第一次就想清楚
提醒:调试期最常见的冤枉钱是"改一行、全量重跑"。库建好后只在新增文档时增量
add_texts,全量重建留给模型升级那天。
7、总结
本篇把 RAG 的地基打完了:Embedding 把文本变成向量,语义远近变成几何距离,三句话实验里"猫和狗"的相似度是"猫和股市"的四倍;模型选型 text-embedding-3-small 起步、中文可上 bge-m3,换模型必须重建索引;Chroma 最小链路是建库绑模型、add_texts 入库、similarity_search 检索,数据自动落盘;返回分数是距离、越小越近,余弦空间下配阈值过滤,宁可空手不凑数;底层是 HNSW 近似索引,规模不到百万不用操心;成本按 token 计费,全库嵌入通常不到一杯咖啡钱。
练习:把你自己的博客文章标题(或任何 30 条以上的文本)灌进 Chroma——add_texts 入库后写个 input() 循环接查询,similarity_search_with_score 打印 top-5 与距离;先找一句字面完全不同但意思相近的查询,体会语义命中的感觉,再逐步收紧阈值,观察结果怎么一条条被过滤掉。跑通这个小游戏,下一篇的 RAG 流水线就只剩"把零件接起来":文档加载、切分策略、索引、检索、生成,让 Agent 真正回答出你私有知识里的问题。


