招聘网站上搜"RAG",跳出来的 JD 长得都差不多。
熟悉 Retrieval-Augmented Generation。了解向量数据库(FAISS、Pinecone、Chroma)。掌握 Embedding 模型(OpenAI、BGE、M3E)。有 LLM 应用开发经验,熟悉 LangChain / LlamaIndex。
我一开始也以为这是在招算法工程师——那种能从头训练 Embedding 模型、能设计新的向量索引结构、能在论文里挂名的人。
面了几轮才发现,大部分公司要的不是这个。他们想要的是:给你一个文档库,你能用 LangChain 搭一个问答系统,调调参数,让回答别那么离谱。至于向量数据库选 FAISS 还是 Chroma,Embedding 模型选 OpenAI 还是开源的,很多时候只是配置项的问题。
JD 里写的 RAG、向量数据库、Embedding,翻译成人话就是:你会不会用 LangChain 调参。
向量数据库不需要 SQL boys 和 SQL girls,但又不能说不是一回事。它说的就是数据库,只是存的东西不是行和列,而是坐标。没有 JOIN,没有 WHERE。只有"给我找离这个点最近的几个邻居"。
这篇文章从调参的视角,把 RAG pipeline 里每个环节的参数过一遍。不重复基础概念,只讲实际写代码时会遇到的选择。
切
chunk_size 设多少?
RAG 的第一步是把文档切成块。LangChain 里最常用的工具是 RecursiveCharacterTextSplitter。
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
separators=["\n\n", "\n", "。", ",", " ", ""]
)
chunk_size
这个参数的单位是 token 数,不是字符数。设得太小,一个完整的句子被拦腰截断,语义支离破碎。设得太大,单个块包含太多主题,检索时容易引入无关信息。
- 通用文档(论文、报告):500-1000 tokens
- 代码:200-400 tokens,按函数或类切分更合理
- 对话记录:300-500 tokens,保留完整回合
一个容易踩的坑是盲目追求大 chunk。“大一点,信息多一点,模型回答更完整”——听起来对,但检索精度会下降。一个 2000 token 的块可能包含五个段落,用户的问题只匹配其中一段,其余四段都是噪声。
chunk_overlap
相邻块之间的重叠 token 数。作用是防止关键信息落在切分边界上。
设成 0,一个定义刚好被切成两半,检索时只召回一半,回答必然残缺。设得太大,存储成本上升,检索时重复内容增多。
经验值是 chunk_size 的 10%-20%。chunk_size=1000 时,overlap 设 100-200 比较合理。
separators
LangChain 默认按 ["\n\n", "\n", " ", ""] 切分——先找空行,再找换行,最后找空格。这个顺序对 Markdown 和纯文本很友好。PDF 转出来的文本常常丢格式,段落之间只有换行没有空行,需要把 "\n" 的优先级降低,或者增加标点符号作为分隔符。
代码文件更特殊。按 "\ndef "、"\nclass " 切分比按空行切分更合理。LangChain 提供了 Language 枚举做语法感知切分。
from langchain.text_splitter import RecursiveCharacterTextSplitter, Language
python_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON,
chunk_size=500,
chunk_overlap=50
)
埋
模型选哪个,batch_size 设多大?
切完块,下一步是把文本变成向量。LangChain 的 Embeddings 接口屏蔽了底层差异,无论用 OpenAI 还是开源模型,调用方式都一样。
from langchain_openai import OpenAIEmbeddings
from langchain_community.embeddings import HuggingFaceEmbeddings
# OpenAI
openai_emb = OpenAIEmbeddings(model="text-embedding-3-small")
# 开源模型
bge_emb = HuggingFaceEmbeddings(model_name="BAAI/bge-large-zh-v1.5")
模型选择
OpenAI 的 text-embedding-3-small 和 text-embedding-3-large 是省心之选。维度分别是 1536 和 3072,在大多数基准测试上排名靠前,按 token 计费,不需要自己部署。
开源模型里,建议上 C-MTEB 榜单逛一逛,本地部署,没有调用成本,数据不上云。
一个实际的权衡:文档库不大(几万条以内),OpenAI 的 API 成本可以忽略,省心。数据敏感不能出内网,或者调用量巨大,开源模型是必选项。
batch_size
Embedding 接口通常支持批量处理。batch_size 设多大,取决于两个因素:API 的并发限制,和本地显存。
OpenAI 的 Embedding API 没有严格的 batch_size 限制,但单次请求 token 总数有上限(通常是 8192 或更多,取决于模型)。如果 chunk_size=1000,一个 batch 放 8-16 条比较安全。
本地跑 HuggingFace 模型时,batch_size 受显存制约。bge-large 在 24G 显存上,batch_size=32 通常没问题。设得太小,GPU 利用率低,处理速度慢。设得太大,OOM。
维度
OpenAI 的 text-embedding-3 系列支持维度缩减。text-embedding-3-large 默认 3072 维,可以通过 dimensions 参数降到 256 维。
emb = OpenAIEmbeddings(
model="text-embedding-3-large",
dimensions=1024
)
维度降低意味着存储减少、检索加快,但信息损失也随之增加。256 维对于简单分类任务够用,对于需要精细语义区分的 RAG 场景可能太糙。经验上,1024 维是一个性价比不错的平衡点。
LangChain 支持十几种向量数据库。FAISS 和 Chroma 是最常见的选择。
from langchain_community.vectorstores import FAISS, Chroma
# FAISS
vectorstore = FAISS.from_documents(docs, embeddings)
# Chroma
vectorstore = Chroma.from_documents(docs, embeddings, persist_directory="./chroma_db")
FAISS
纯内存操作,速度快。数据量上万之后,IndexFlatL2 暴力搜索开始吃力,需要换近似索引。IndexIVFFlat 先聚类再搜索,通过 nlist(聚类中心数)和 nprobe(搜索时访问的聚类数)控制速度与精度的权衡。
import faiss
# 创建 IVF 索引
nlist = 100 # 聚类中心数,通常设为 sqrt(数据量)
quantizer = faiss.IndexFlatL2(dim)
index = faiss.IndexIVFFlat(quantizer, dim, nlist)
index.train(vectors)
index.add(vectors)
index.nprobe = 10 # 搜索时访问 10 个最近的聚类
nlist 设得太小,每个聚类里的向量太多,搜索没加速。设得太大,聚类本身的开销上升,且小聚类里的向量太少,量化误差增大。经验上,nlist = sqrt(N) 是一个合理的起点。
nprobe 是速度和精度的旋钮。设成 1,只搜最近的聚类,最快但可能漏掉正确答案。设成 nlist,退化成暴力搜索。实际中 10-50 之间取值,根据对精度的要求调整。
(这两个参数我调了很久才找到感觉。一开始 nprobe 设得太小,召回率惨不忍睹;设得太大,又失去了近似索引的意义。最后发现 20 左右对我的数据量比较合适。)
Chroma
数据存在本地文件,不需要额外服务,适合原型开发。Chroma 自动处理索引构建,暴露的参数不多,主要是距离度量:cosine、l2、ip。Embedding 向量通常已归一化,此时 cosine 和 ip 等价。
Pinecone / Milvus
云端托管方案,适合生产环境。对于大部分应用,FAISS 的 IVF 索引够用。除非数据量达到千万级别或需要分布式部署,没必要引入 Milvus 的复杂度。
网
k 不是越大越好。
核心问题是:给定用户 query,从向量库里召回多少个最相关的 chunk?
retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": 4}
)
k 值
k=1 是最激进的策略——只召回最相似的一个 chunk。优点是噪声少,缺点是如果 top-1 刚好是个边缘相关的内容,回答就会跑偏。
k=4 到 k=10 是常见范围。更多的 chunk 给 LLM 提供更丰富的上下文,但也引入更多噪声。LLM 的上下文窗口有限(虽然现在已经扩展到 128K 甚至更多),且模型对长上下文中的信息有"中间遗忘"倾向——放在 prompt 中间的内容,被注意到的概率低于开头和结尾。
我的习惯是先设 k=4,观察回答质量。如果发现遗漏关键信息,逐步增加到 6 或 8。很少需要超过 10。
(我试过 k=20,想着多给点上下文总没错。结果 LLM 被大量无关信息干扰,回答质量反而下降。)
score_threshold
相似度阈值过滤,只保留分数高于阈值的 chunk。
retriever = vectorstore.as_retriever(
search_type="similarity_score_threshold",
search_kwargs={"score_threshold": 0.7, "k": 10}
)
阈值设得太高,可能一个 chunk 都过不了,LLM 拿到空上下文,只能瞎编。设得太低,等于没过滤。
不同 Embedding 模型的分数分布差异很大。OpenAI 的 cosine similarity 通常在 0.7-0.9 之间。阈值需要根据具体模型和数据集调试,没有 universal 的 magic number。
MMR
标准相似度搜索只考虑 query 和 chunk 的相似度,不考虑 chunk 之间的冗余。召回的 top-k 可能高度相似,信息重复。
MMR 在相似度和多样性之间做权衡:
retriever = vectorstore.as_retriever(
search_type="mmr",
search_kwargs={"k": 4, "fetch_k": 20, "lambda_mult": 0.5}
)
fetch_k:先召回 20 个候选,再从中选 4 个lambda_mult:多样性权重。0 只考虑多样性,1 只考虑相似度,0.5 是平衡
fetch_k 需要明显大于 k,否则没有筛选空间。lambda_mult 根据场景调整:如果文档库主题分散,需要覆盖多个方面,设低一点(0.3-0.5)。如果主题集中,只需要最相关的内容,设高一点(0.7-0.9)。
桥
检索到的 chunk 要怎么和 LLM 串起来?
LangChain 的 RetrievalQA 和 create_retrieval_chain 封装了"检索-填充 prompt-调用 LLM"的标准流程。
from langchain.chains import RetrievalQA
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-3.5-turbo")
qa = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=retriever,
return_source_documents=True
)
chain_type
stuff:把所有检索到的 chunk 直接塞进 prompt。最简单,但受限于 LLM 的上下文窗口。map_reduce:每个 chunk 单独问 LLM,再把答案汇总。适合 chunk 很多的情况,但调用次数多,成本高。refine:迭代式,先问第一个 chunk,再把答案和第二个 chunk 一起问,逐步 refine。效果通常比 map_reduce 好,但调用次数更多。map_rerank:每个 chunk 单独问,同时让 LLM 打分,最后选分数最高的答案。
大部分场景 stuff 够用。如果 k 值设得合理(比如 4-6 个 chunk),总 token 数通常在上下文窗口范围内。只有处理超长文档(一本书、一份几百页的报告)时,才需要考虑 map_reduce 或 refine。
temperature
LLM 的采样温度。RAG 场景下,temperature 应该设低(0-0.3),因为回答需要基于检索到的文档,而不是 LLM 的"创意"。
temperature=0 是贪婪解码,每次输出确定性的结果。temperature=0.7 适合开放式创作,但用于 RAG 会让回答变得飘忽,甚至引入幻觉。
有一次我把 temperature 设成 0.8 做测试,结果 LLM 开始自由发挥,把检索到的内容和自己的"知识"混在一起,生成了一段看起来很有道理但实际上是编造的答案。
从那以后,RAG 场景的 temperature 我再也不敢设高。(但也需要根据模型的参数大小和量化方式反复调整。小模型很擅长梦到哪句说哪句。)
用魔法打败魔法
基础 RAG 的问题是:用户 query 和文档 chunk 的表述方式可能不同。用户问"怎么退款",文档里写的是"退货流程"。字面相似度低,检索失败。
Query Transformation 的思路是:不直接用原始 query 去检索,而是先把它改写或扩展成更适合检索的形式。
HyDE
让 LLM 先根据 query 生成一个假设的回答,然后用这个假设回答的 Embedding 去检索。假设回答和真实文档的语义空间更接近,检索效果更好。
LangChain 的实现:
from langchain.chains import HypotheticalDocumentEmbedder
hyde_embeddings = HypotheticalDocumentEmbedder.from_llm(
llm=llm,
base_embeddings=base_embeddings,
prompt_key="web_search" # 使用预设 prompt
)
代价是每次检索多一次 LLM 调用,延迟和成本都上升。是否值得,取决于基础检索的效果。如果基础检索已经能召回相关内容,HyDE 的边际收益可能不大。
Multi-Query
让 LLM 从原始 query 生成多个不同表述的查询,分别检索,最后合并结果。
from langchain.retrievers.multi_query import MultiQueryRetriever
multi_retriever = MultiQueryRetriever.from_llm(
retriever=base_retriever,
llm=llm
)
生成 3-5 个变体查询,能覆盖不同的表述方式。代价是检索次数乘以查询数量,但向量检索很快,通常不是瓶颈。适合本地模型吞吐慢的场景。
(这两个进阶技巧我目前用得不多。不是因为它们不好,而是基础调参做好之后,大部分问题已经能解决。HyDE 和 Multi-Query 更像是锦上添花,而不是雪中送炭。)
调参清单
搭一个 RAG 系统时,我通常按这个顺序检查和调整参数:
Text Splitting
- chunk_size 是否匹配文档类型(通用 500-1000,代码 200-400)
- chunk_overlap 是否足够防止边界截断(chunk_size 的 10%-20%)
- separators 是否适配文档格式
Embedding
- 模型是否匹配语言和应用场景
- batch_size 是否充分利用了 API 限额或 GPU 显存
- 维度是否平衡了精度和存储
向量数据库
- 数据量是否适合当前索引类型(FAISS IVF 适合万到百万级)
- nlist / nprobe 是否经过调优
- 距离度量是否与 Embedding 模型匹配
Retrieval
- k 值是否在 4-10 的合理范围内
- 是否尝试了 MMR 来改善多样性
- score_threshold 是否过滤掉了低质量结果
Generation
- chain_type 是否匹配上下文长度
- temperature 是否足够低(0-0.3)
- prompt 是否明确指示 LLM 基于检索内容回答
经验值是给英文参考的。中英文的 tokenize 方式差异很大:英文按空格切分,每个词 0.75 个 token 左右;中文没有空格,按字或词切分,一个汉字通常 1-2 个 token,词组可能更复杂。换句话说,同样的 chunk_size,中文切出来的块数可能比英文少很多。得根据目标语言的实际 token 分布调整,不是直接套用英文的经验数字。
结语
RAG 系统的优化是一层层递进的。先把基础 pipeline 跑通,再逐个环节调参。很多时候,chunk_size 调对了一半的问题就解决了,不需要急着上 HyDE 或重排序。
JD 里写的"熟悉 RAG",落地之后就是这一套:切分、向量化、建索引、检索、生成。每个环节都有参数可调,每个参数都有 trade-off。
唯一的方法是控制变量,一次调一个,看回答质量的变化。
(面试的时候被问到"RAG 怎么优化",先讲 chunk_size 和 k 值,再提 MMR 和 Query Transformation,基本够用。至于 Embedding 模型的内部结构——除非面的是算法岗,否则面试官大概率也不关心。)
调参这件事,和调试其他东西没什么两样。在不确定中试,在混乱里摸出一点规律。参数调对了,系统能跑起来。
跑得好不好,另说。
附录:向量数据库是什么
文章里一直在说"向量数据库",但这个东西到底是什么?JD 里写得那么吓人,好像是什么高深的新发明。
拆开来,其实就两件事:向量和数据库。
向量
把文字变成坐标
Embedding 模型做的事情,是把一段文字变成一个数字数组。比如"今天天气不错"变成 [0.12, -0.34, 0.56, ...],一共 1024 个数字。
这 1024 个数字就是 1024 维空间里的一个坐标。
你可以想象一个二维平面,每个文档是一个点。“今天天气不错"和"明天可能下雨"这两个点的坐标离得近,因为它们都谈论天气。“今天天气不错"和"Python 的装饰器"这两个点的坐标离得远,因为语义上毫无关系。
Embedding 模型的训练目标,就是让语义相似的文本在向量空间里距离近,语义无关的文本距离远。就这么简单。
数据库
在坐标里找最近的邻居
有了坐标,问题就变成了:给定一个 query 的坐标,怎么在所有文档坐标里找到距离最近的那几个?
这就是最近邻搜索(Nearest Neighbor Search)。最直接的方法叫暴力搜索——把 query 的坐标和每一个文档坐标算一遍距离,排个序,取 top-k。
数据量小的时候(几百到几千条),暴力搜索完全够用。FAISS 的 IndexFlatL2 就是这个。
数据量大了之后(几万到几百万),暴力搜索就开始慢了。100 万个文档,每个 query 要算 100 万次距离。如果每条文档要 1ms,一个 query 就要 16 分钟。生产环境里不可接受。
换成近似最近邻搜索(Approximate Nearest Neighbor,ANN)。不找"绝对最近"的,找"大概最近"的,但速度快很多。
FAISS 的 IVF 索引就是 ANN 的一种。它的思路是:先把所有点聚成 100 个簇(nlist),来了一个 query,先算它在哪个簇附近,然后只搜那附近几个簇(nprobe)。不用搜全部,自然就快了。
距离度量
向量空间里有三种常用的距离度量:
| 度量 | 数学定义 | 适用场景 |
|---|---|---|
| L2(欧氏距离) | $\|\mathbf{x} - \mathbf{y}\|_2$ | 关注绝对位置差异 |
| cosine(余弦相似度) | $\frac{\mathbf{x} \cdot \mathbf{y}}{\|\mathbf{x}\| \|\mathbf{y}\|}$ | 关注方向(语义),不关注长度 |
| IP(内积) | $\mathbf{x} \cdot \mathbf{y}$ | 归一化后等价于 cosine |
大部分 Embedding 模型输出的向量已经做过归一化,此时 cosine 和 IP 完全等价。如果没归一化,用 cosine 更稳妥——因为它不受向量模长影响,只关心"方向"是否一致。
一个直觉理解:两个向量的方向一致(cosine = 1),意味着它们指向同一个语义主题,不管哪个向量"更长”(模长更大)。
索引类型速览
| 索引 | 搜索方式 | 精度 | 速度 | 适用场景 |
|---|---|---|---|---|
| FlatL2 | 暴力搜索 | 100% | 慢 | 小数据量(< 10K) |
| IVFFlat | 聚类后搜索 | 可调节 | 快 | 中等数据量(10K-1M) |
| HNSW | 分层导航图 | 高 | 很快 | 大数据量(> 1M),对精度要求高 |
| IVFPQ | 聚类+量化 | 中 | 很快 | 大数据量+内存受限 |
HNSW(Hierarchical Navigable Small World)是目前最流行的 ANN 算法之一。它构建了一个多层图结构,从高层粗搜到低层细搜,像从世界地图逐步放大到街道地图一样。Milvus 默认用的就是 HNSW。
回到正文
理解了这些,正文里的参数就很好懂了:
nlist:聚类中心数。多了少了好?sqrt(N) 是一个经验值。nprobe:搜几个簇。多了慢但准,少了快但可能漏。cosinevsl2:Embedding 归一化了吗?归一化了就用 cosine。- HNSW 的
ef_construction和ef_search:类似nlist和nprobe的角色。
向量数据库不是什么黑魔法。它只是一个在 N 维空间里找最近邻居的搜索引擎。Embedding 把语义问题变成了距离问题,向量数据库负责快速算距离。