关于RAG,我花了两周才彻底搞懂的那些事
本文不是教程,是一份完整的学习笔记。全文挺长的,代码示例涵盖从基础RAG到重排序、混合检索、HyDE、父子文档、评估体系的完整链路。适合对RAG技术很好奇或有基础概念但想理解底层实现细节的后端开发者阅读。
写在前面
我写了九年后端代码,大部分时间在跟MySQL和Redis打交道。接到公司第一个RAG相关需求时,我甚至不知道怎么开口跟产品经理解释"做不到"这件事——如果想把5000页的产品手册挂到公众号后台,让用户随便问,AI自动回答该怎么实现。
一开始我脑子里只有一种方案:把5000页全喂给大模型。结果显而易见,上下文窗口装不下(1m又能塞多少东西),就算装下了,模型也记不住每一页的内容。
从那时候开始,我花了两周时间,从零开始搞明白RAG是什么、怎么搭、怎么优化。这篇文章是我在这个过程中积累的所有笔记整理出来的,不是面面俱到的官方文档,是我自己撞过的墙和爬出来的坑。
RAG挺重要的,它即是现代AI agent 开发工程师必须掌握的开发技能,同时也是摆脱大模型上下文限制作为实现“长期记忆”的一种实现手段(姑且不论这种实现的好与坏,只学习该项技术本身),所以彻底搞懂它是十分有必要的!
第一部分:先搞清楚嵌入到底在干什么
1.1 我一开始的理解是错的
我对Embedding的第一印象是:"把文字转成向量,然后算距离。"这个理解在大方向上没错,但当时我认为Embedding是一种数据结构,类似于把红黑树的节点按某种规则排序。
实际上,嵌入模型是一个训练好的神经网络。输入是一段文本(或者图片、音频,后面会细说),输出是一个固定维度的浮点数数组——OpenAI的text-embedding-3-small输出1536维,开源的BGE系列一般是768维或1024维。
这个数组就是向量。关键性质只有一条:
语义上越接近的两段文本,它们对应向量的空间距离越近。
就这么简单,但只有真正把代码跑起来之后才体会到这句话的分量。下面是我的第一个测试用例。
// 测试文本
$texts = [
'年假申请需要提前三个工作日',
'带薪休假必须在三天前提交申请',
'公司食堂中午十二点开饭'
];
foreach ($texts as $t) {
$vec = get_embedding($t);
// 存进数组
}
// 用余弦相似度算一下:
// 文本1和文本2的相似度:0.87
// 文本1和文本3的相似度:0.31
前两句语义相近,相似度很高;第三句讲食堂,毫无关系。这个结果让我确认了Embedding的基本可靠性。
1.2 嵌入模型不是一个,是一大类
我整理了一下接触过的嵌入模型,分几类:
纯文本嵌入模型:
| 模型名称 | 开发者 | 输出维度 | 开源/闭源 | 特点 |
|---|---|---|---|---|
| text-embedding-3-small | OpenAI | 1536 | 闭源API | 性价比高,约$0.02/百万token |
| text-embedding-3-large | OpenAI | 3072 | 闭源API | 精度更高,价格也更高 |
| BGE-large-zh | 北京智源 | 1024 | 开源 | 中文效果最好的开源模型之一 |
| BGE-m3 | 北京智源 | 1024 | 开源 | 支持多语言,支持稠密+稀疏混合检索 |
| M3E | M3E团队 | 768 | 开源 | 针对中文RAG任务优化 |
| jina-embeddings-v3 | Jina AI | 768/1024 | 开源 | 支持多语言,可调输出维度 |
| Cohere embed-v4 | Cohere | 1024 | 闭源API | 支持多模态(文本+图像) |
多模态嵌入模型(后面专门讲):
| 模型名称 | 开发者 | 支持模态 | 特点 |
|---|---|---|---|
| CLIP | OpenAI | 图像+文本 | 最早的多模态嵌入模型之一 |
| Chinese-CLIP | 智源/阿里 | 图像+文本 | CLIP的中文版 |
| CLAP | LAION/Microsoft | 音频+文本 | 以文搜音 |
| ImageBind | Meta | 6种模态 | 图像、文本、音频、深度、热力、IMU |
| Jina CLIP | Jina AI | 图像+文本 | 针对RAG优化的多模态嵌入 |
选择哪种,主要看三个东西:数据隐私要求、中文支持程度、是否需要多模态。
企业内部数据敏感的,基本告别OpenAI这种外部API,BGE和M3E是主要方向。做通用的公开文档问答,OpenAI最省事。需要处理图片、音频的,CLIP系列或者ImageBind是必经之路。
1.3 把嵌入模型部署到本地
用API虽然省事,但涉及到企业数据,客户不太愿意把文档传到第三方服务。我试了在本地跑BGE,步骤如下:
# 这是Python,但我当时是开一个独立的Python服务,PHP通过HTTP调用
from sentence_transformers import SentenceTransformer
# 下载并加载BGE模型
model = SentenceTransformer('BAAI/bge-large-zh-v1.5')
# 编码文本
text = "年假申请需要提前三个工作日"
vector = model.encode(text)
print(vector.shape) # (1024,)
print(vector[:5]) # [-0.023, 0.456, -0.891, ...]
然后封装成一个HTTP服务:
from flask import Flask, request, jsonify
app = Flask(__name__)
model = SentenceTransformer('BAAI/bge-large-zh-v1.5')
@app.route('/embed', methods=['POST'])
def embed():
data = request.json
text = data.get('text')
vec = model.encode(text).tolist()
return jsonify({'vector': vec})
PHP这边直接用curl调用:
function get_embedding($text) {
$ch = curl_init('http://localhost:5000/embed');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['text' => $text]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$resp = curl_exec($ch);
return json_decode($resp, true)['vector'];
}
这样PHP代码也能使用本地嵌入模型,数据不出服务器。
第二部分:向量数据库,从全量扫描到索引
2.1 一开始我用MySQL存向量
这是最原始的RAG原型,完全不考虑性能,只验证流程是否正确:
// 建表
CREATE TABLE doc_chunks (
id INT AUTO_INCREMENT PRIMARY KEY,
doc_id VARCHAR(64),
content TEXT,
embedding JSON, -- 存向量
metadata JSON -- 存来源、页码等
);
// 插入
$embedding = get_embedding($chunk_text);
$stmt = $pdo->prepare("INSERT INTO doc_chunks (doc_id, content, embedding, metadata) VALUES (?, ?, ?, ?)");
$stmt->execute([$doc_id, $chunk_text, json_encode($embedding), json_encode($metadata)]);
// 查询:全量扫描算余弦相似度
$query_vec = get_embedding($user_question);
$rows = $pdo->query("SELECT id, content, embedding FROM doc_chunks")->fetchAll(PDO::FETCH_ASSOC);
$results = [];
foreach ($rows as $row) {
$vec = json_decode($row['embedding'], true);
$score = cosine_similarity($query_vec, $vec);
$results[] = ['id' => $row['id'], 'content' => $row['content'], 'score' => $score];
}
usort($results, fn($a, $b) => $b['score'] <=> $a['score']);
$top5 = array_slice($results, 0, 5);
这个方案在文档块少于5000条的时候能用。超过1万条,每次查询要算1万次浮点数点积,响应时间到了秒级。
2.2 迁移到Chroma
Chroma是轻量级向量数据库,本地运行,API简单。我把它当作向量检索的"SQLite"。
# Python端启动Chroma服务
import chromadb
from chromadb.config import Settings
client = chromadb.Client(Settings(
chroma_db_impl="duckdb+parquet",
persist_directory="./chroma_db"
))
collection = client.get_or_create_collection(
name="knowledge_base",
metadata={"hnsw:space": "cosine"} # 用余弦距离
)
# 批量插入
ids = []
embeddings = []
metadatas = []
documents = []
for idx, chunk in enumerate(chunks):
ids.append(str(idx))
embeddings.append(chunk['embedding'])
metadatas.append(chunk['metadata'])
documents.append(chunk['content'])
collection.add(
ids=ids,
embeddings=embeddings,
metadatas=metadatas,
documents=documents
)
# 查询
results = collection.query(
query_embeddings=[query_vec],
n_results=5,
include=["documents", "metadatas", "distances"]
)
Chroma底层用HNSW做索引,百万级数据量的查询响应在毫秒级。HNSW的全称是"分层可导航小世界图",原理是在向量空间里构建多层图结构,顶层节点稀疏,底层节点密集,搜索时从顶层快速定位到目标区域,再在底层精细查找。
2.3 其他的向量数据库选项
我列了一下接触到的几个:
Chroma:最适合入门,代码最简洁,但高并发和大规模场景下稳定性不如Milvus。
Milvus:功能最全,支持分布式部署,有完善的索引类型选择(HNSW、IVF_FLAT、IVF_PQ等)。缺点是部署复杂,学习曲线陡。Zilliz是它的云托管版。
Qdrant:Rust编写,性能优秀,支持WASM可以在浏览器里跑。RESTful API设计很舒服。
Pinecone:纯云服务,免费额度2GB存储,适合不想运维的团队。索引创建和查询都是HTTP请求,完全托管。
pgvector:PostgreSQL扩展,直接在现有数据库里加向量功能。索引用的是IVFFlat和HNSW。对于已经在用PG的项目,这是侵入性最小的方案。
选哪个跟数据规模直接相关:几千条用Chroma或pgvector;几万到几十万条Qdrant或Milvus单机版都行;百万级以上考虑Milvus集群或云服务。
2.4 元数据预过滤
向量检索找的是语义相似的内容。但实际业务中经常需要先按条件缩小范围,再在缩小后的集合里做语义搜索。
比如用户问"去年第四季度的销售数据",你希望先在"时间=去年Q4"这个范围内做向量检索,而不是在所有销售数据里搜"销售数据"然后碰运气。
// Chroma查询时带where条件
$results = $collection->query(
query_embeddings: [$query_vec],
n_results: 5,
where: [
'year' => 2025,
'quarter' => 4
],
include: ['documents', 'metadatas']
);
元数据预过滤能显著提升精度。向量检索只负责"语义相似",过滤条件负责"精确匹配",各司其职。
第三部分:分块策略,我踩过的坑
3.1 按段落分割的问题
一开始我用最简单的方式:按换行符分割。
def simple_chunk(text):
return [p.strip() for p in text.split('\n\n') if p.strip()]
结果遇到了几个问题:
- 一个段落可能太短(就一句话),缺乏上下文,大模型看不懂。
- 一个段落可能太长(几百字),向量被稀释,检索时很难命中具体的点。
- 表格、列表、代码块被胡乱切开,内容完全破碎。
3.2 固定长度加重叠
后来用了固定长度分割,加上重叠:
def fixed_size_chunk(text, chunk_size=500, overlap=50):
words = text.split()
chunks = []
for i in range(0, len(words), chunk_size - overlap):
chunk = ' '.join(words[i:i + chunk_size])
chunks.append(chunk)
return chunks
重叠的意义在于:如果某个关键词恰好在两个chunk的边界上,不加重叠可能会导致检索时两头都找不到。
3.3 语义分块
固定长度分割虽然稳定,但会从语义完整的地方切断。更合理的做法是让句子之间的连贯性决定分块边界。
# 伪代码,用嵌入模型判断句子间的语义连续性
def semantic_chunk(sentences, threshold=0.7):
chunks = []
current = [sentences[0]]
for i in range(1, len(sentences)):
# 计算句子i和句子i-1的语义相似度
sim = cosine_similarity(
get_embedding(sentences[i]),
get_embedding(sentences[i-1])
)
if sim < threshold:
# 语义断裂,产生一个新块
chunks.append(' '.join(current))
current = []
current.append(sentences[i])
if current:
chunks.append(' '.join(current))
return chunks
这个方案的问题是:需要调用嵌入模型算每两个相邻句子的相似度,分块本身就有不小的计算成本。对于离线构建索引的场景可以接受,实时分块不太现实。
3.4 父子文档检索
这是我目前见过最实用的方案,也是生产环境比较通用的做法。
核心思路:建索引时用小块(保证检索精度),取内容时返回大块(保证上下文完整)。
# 入库阶段
def index_document(doc):
# 先切成大块(父块),比如800字
parent_chunks = fixed_size_chunk(doc, chunk_size=800, overlap=50)
for parent in parent_chunks:
parent_id = insert_into_db(parent)
# 再把父块切成小块(子块),比如200字
child_chunks = fixed_size_chunk(parent, chunk_size=200, overlap=20)
for child in child_chunks:
vec = get_embedding(child)
# 存入向量库,同时记录parent_id
vector_db.insert(
text=child,
embedding=vec,
metadata={'parent_id': parent_id}
)
# 检索阶段
def search(query):
q_vec = get_embedding(query)
# 向量库检索,匹配到的是子块
results = vector_db.search(q_vec, top_k=10)
# 根据子块中的parent_id,从关系数据库取完整的父块
parent_ids = [r['metadata']['parent_id'] for r in results]
parent_texts = db.query("SELECT text FROM chunks WHERE id IN (?)", parent_ids)
return parent_texts # 返回大块文本给大模型
这样做的好处是:向量检索命中的是短文本,准;大模型看到的是长文本,全。
第四部分:重排序模型,我终于搞懂了
4.1 向量检索的精度瓶颈
我用向量检索跑了一段时间后,遇到一个典型情况:
知识库里有100篇关于"休假制度"的文档,其中只有3篇提到了"年假提前几天申请"。用户问"年假要提前几天",向量检索返回的Top-5里只有2篇命中的,另外3篇是"休假流程概述"“考勤管理办法”"请假审批权限"这类标题相似但内容无关的文档。
向量检索只看"这篇文档大概在讲什么",它不会仔细判断"这篇文档是否真的回答了这个问题"。这就是"语义相似但不相关"的典型问题。
4.2 重排模型的原理
重排模型(Reranker)解决的就是这个问题。它和嵌入模型是两回事,不要搞混。
嵌入模型(双塔结构):
问题 → 编码器A → 向量Q
文档 → 编码器B → 向量D
相似度 = cosine(Q, D)
Q和D是分别算出来的,两者在最终算相似度之前没有任何信息交换。
重排模型(交叉编码器结构):
[问题] + [文档] → 编码器 → 池化 → 分数(0~1)
问题和文档拼接在一起输入模型,模型内部每一层Attention都在做"问题里的每个词去看文档里的每个词"的交叉注意力计算。最后输出一个0~1的数字,表示"这段文档能够回答这个问题的概率"。
我画了一个简图帮助自己理解:
嵌入模型的工作方式:
用户问题 ────▶ 编码器 ────▶ Q向量
\ 余弦相似度 → 得分
文档片段 ────▶ 编码器 ────▶ D向量 /
重排模型的工作方式:
[用户问题 + 文档片段] ────▶ 交叉编码器 ────▶ 全连接层 ────▶ 0.84(相关性分数)
↑
每一层的Attention都在做
问题↔文档的交叉关注
这就是"交叉注意力"的含义。嵌入模型是"各看各的,最后对答案";重排模型是"两个人面对面深度交流,然后给出结论"。
4.3 加入重排后的完整检索代码
<?php
/**
* 完整的RAG检索流程(含重排)
* 数据流:
* 用户问题 → 向量检索(粗筛100条) → 重排模型(精筛取前5) → 返回给LLM
*/
// Step 1: 用户问题转向量
$question = "年假要提前几天申请?";
$q_vec = get_embedding($question);
// Step 2: 向量数据库粗筛,取Top-100
$candidates = $vector_db->search(
query: $q_vec,
top_k: 100,
// 可选:元数据预过滤
filter: ['doc_type' => 'hr_policy', 'year' => 2026]
);
// Step 3: 批量调用重排模型
// 把100个候选文档和问题一起提交给Reranker
$pairs = [];
foreach ($candidates as $doc) {
$pairs[] = [
'query' => $question,
'document' => $doc['text']
];
}
$scores = call_reranker_batch($pairs); // 返回100个0~1的分数
// Step 4: 按重排分数重新排序,取Top-5
foreach ($candidates as $idx => &$doc) {
$doc['rerank_score'] = $scores[$idx];
}
usort($candidates, function($a, $b) {
return $b['rerank_score'] <=> $a['rerank_score'];
});
$final_docs = array_slice($candidates, 0, 5);
// Step 5: 组装提示词
$context = implode("\n\n", array_column($final_docs, 'text'));
$prompt = "参考以下资料回答用户问题:\n\n" . $context . "\n\n用户问题:" . $question;
// Step 6: 调用大模型生成回答
$answer = call_llm($prompt);
echo $answer;
4.4 重排模型的API调用实现
重排模型的API调用方式跟Embedding不同。Embedding是一次传一个文本,返回一个向量。Reranker是一次传"问题+文档"对,返回分数。
/**
* 调用开源的BGE-Reranker模型(本地部署)
* 或者使用云服务商的Rerank API
*/
function call_reranker_batch($pairs) {
// 方式一:本地BGE-Reranker服务(Python Flask提供HTTP接口)
// $response = curl_post('http://localhost:8080/rerank', ['pairs' => $pairs]);
// 方式二:调用云服务商的Rerank API
// 以Jina Reranker为例
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://api.jina.ai/v1/rerank');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . JINA_API_KEY,
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'model' => 'jina-reranker-v2-base-multilingual',
'query' => $pairs[0]['query'], // 问题只有一个
'documents' => array_column($pairs, 'document')
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$resp = curl_exec($ch);
$data = json_decode($resp, true);
// 返回按原始顺序排列的分数数组
$scores = array_fill(0, count($pairs), 0.0);
foreach ($data['results'] as $item) {
$scores[$item['index']] = $item['relevance_score'];
}
return $scores;
}
4.5 重排模型和Embedding模型的关系
我当时把这两个模型的关系想复杂了,其实可以用一个简单的类比来理解:
- 嵌入模型:图书管理员,根据索书号把一箱相关主题的书搬过来。
- 重排模型:资深编辑,快速翻阅这箱书,挑出最匹配的那几本。
前者追求"快"和"全",后者追求"准"和"精"。前者面向百万级数据做粗筛,后者面向百级数据做精筛。
两者不是替代关系,是流水线上的上下道工序。
4.6 什么时候可以不用重排?
不是所有场景都需要重排。我根据实际使用总结了几条经验:
- 知识库小于1万条:向量检索直接取前5条,效果基本够用。因为候选集本身不大,噪声有限。
- 搜索目标是精确名称/代码/ID:用BM25关键词检索比向量检索+重排更直接。
- 实时性要求极高(每请求<200ms):重排模型增加了额外延迟,需要权衡。有些场景可以接受精度下降换取速度。
但反过来,如果知识库在10万条以上,且用户问的是语义模糊的自然语言问题,重排带来的精度提升非常明显。我实测过一次,加了重排后Top-5命中率从62%提升到了89%。
第五部分:混合检索,向量+关键词两条腿走路
5.1 向量检索的盲区
向量检索在处理语义相似时很强大,但遇到精确匹配的场景就捉襟见肘。
举个例子:用户搜"民法典第114条"。向量检索会把"民法典"“第114条"映射成向量,然后在语义空间里找相似的——很可能返回一些关于"民法典”"条款"的其他内容,而不是精确的第114条。
原因很简单:向量检索不理解"114"这个数字的精确含义,它只知道"114"和"一百一十四"的向量相近,但不知道用户要的是"条款编号=114"。
5.2 BM25:传统的关键词检索
BM25是信息检索领域一个经典的评分函数。它不"理解"语义,但它在精确匹配上可靠:
BM25(文档D, 查询Q) = Σ IDF(词t) × (freq(t,D) × (k1+1)) / (freq(t,D) + k1 × (1 - b + b × |D|/avgDL))
我不打算在这篇文章里深入推导这个公式。只需要记住两个关键点:
- 词频越高分越高:文档里出现关键词的次数越多,得分越高。
- 文档越长惩罚越大:同样出现3次关键词,短文档比长文档得分高。
5.3 混合检索的实现
混合检索的做法是:向量检索跑一路,BM25检索跑一路,然后把两路结果合并成一个最终列表。
<?php
/**
* 混合检索:向量 + BM25
* 使用RRF算法融合两路结果
*/
function hybrid_search($question, $top_k = 100) {
// 第一路:向量检索
$q_vec = get_embedding($question);
$vector_results = $vector_db->search($q_vec, top_k: 200);
// 第二路:BM25关键词检索
$bm25_results = bm25_search($question, top_k: 200);
// 第三路:RRF融合
$scores = [];
$k = 60; // RRF常量
foreach ($vector_results as $idx => $doc) {
$doc_id = $doc['id'];
$rank = $idx + 1;
if (!isset($scores[$doc_id])) {
$scores[$doc_id] = 0;
}
$scores[$doc_id] += 1 / ($k + $rank);
}
foreach ($bm25_results as $idx => $doc) {
$doc_id = $doc['id'];
$rank = $idx + 1;
if (!isset($scores[$doc_id])) {
$scores[$doc_id] = 0;
}
$scores[$doc_id] += 1 / ($k + $rank);
}
// 按融合分数排序
arsort($scores);
$top_ids = array_keys(array_slice($scores, 0, $top_k));
return $top_ids;
}
RRF(倒数排名融合)的优点是:不需要对两路分数做归一化处理。向量检索和BM25的分数量纲完全不同,直接相加没有意义。RRF只看"排名",不看"分数",避免了这个麻烦。
5.4 BGE-M3:原生支持混合检索的嵌入模型
传统混合检索需要维护两套系统:向量库+ES(Elasticsearch)。后来我发现BGE-M3这个模型可以直接生成两种类型的向量:
- 稠密向量:跟其他Embedding一样,768维浮点数。
- 稀疏向量:类似于BM25的词权重表示。
from FlagEmbedding import BGEM3FlagModel
model = BGEM3FlagModel('BAAI/bge-m3')
# 一次编码同时得到稠密和稀疏向量
output = model.encode(
["年假申请需要提前三个工作日"],
return_dense=True,
return_sparse=True
)
dense_vec = output['dense_vecs'] # 768维浮点数向量
sparse_vec = output['lexical_weights'] # 词权重字典,如 {'年假': 0.89, '提前': 0.76}
# 检索时同时用稠密和稀疏向量做搜索
# 向量数据库需要同时支持这两种索引
这样一套模型同时处理两路检索,减少了系统复杂度。
第六部分:查询改写,用户不一定知道怎么搜
6.1 用户的问题通常不适合直接检索
真实场景中,用户问得很随意:
“那个年假的事情怎么弄的”
“我想知道我还能休几天”
“请假找谁批”
这些口语化的表述直接拿去向量库搜索,效果往往不好。原因是知识库里的文本是"年假申请流程"“带薪年假余额查询”"请假审批权限说明"这样规范化的文档语言。
解决方案是在检索之前,先让大模型把用户的问题"翻译"成更适合检索的格式。
6.2 HyDE:用生成的答案去搜
HyDE(Hypothetical Document Embeddings)的做法是:
- 让大模型根据用户问题生成一段"假设性的答案"。
- 用这段生成的答案去向量库搜索,而不是用用户的原始问题。
<?php
/**
* HyDE 查询改写
*/
function hyde_rewrite($user_question) {
// Step 1: 让大模型生成假设性答案
$prompt = "假设你是一份关于公司制度的文档,用文档的正式语言风格回答下面的问题。\n";
$prompt .= "只回答,不要加额外解释。\n\n";
$prompt .= "问题:" . $user_question;
$hypothetical_answer = call_llm($prompt);
// Step 2: 用生成的答案做向量检索
$vec = get_embedding($hypothetical_answer);
$results = $vector_db->search($vec, top_k: 10);
return $results;
}
HyDE的原理是:用户的口语化问题在向量空间里的位置,和知识库里正式文档的位置不一定接近。但"假设性答案"的语言风格跟知识库文档更接近,用它做检索,命中率更高。
6.3 Multi-Query:多角度扩张
另一种做法是让大模型把用户问题扩张成多个不同角度的表述,分别检索,合并结果。
<?php
/**
* Multi-Query 查询扩张
*/
function multi_query_search($user_question) {
// Step 1: 让大模型扩写出3个不同角度的问题
$prompt = "将以下问题从3个不同角度改写,返回3个不同表述方式的问题:\n" . $user_question;
$variants = call_llm($prompt);
// 返回类似:
// 1. "年假提前几天申请"
// 2. "申请年假需要多少提前量"
// 3. "休假申请的提前时间要求"
// Step 2: 分别检索
$all_results = [];
foreach ($variants as $v) {
$vec = get_embedding($v);
$results = $vector_db->search($vec, top_k: 30);
$all_results = array_merge($all_results, $results);
}
// Step 3: 按文档ID去重并合并
$unique = [];
foreach ($all_results as $doc) {
if (!isset($unique[$doc['id']])) {
$unique[$doc['id']] = $doc;
} else {
// 相同文档,取最高分
$unique[$doc['id']]['score'] = max(
$unique[$doc['id']]['score'],
$doc['score']
);
}
}
// 按分数排序返回
usort($unique, fn($a, $b) => $b['score'] <=> $a['score']);
return array_slice($unique, 0, 50);
}
Multi-Query的成本是多次向量检索,但检索覆盖面更广,不容易遗漏信息。
第七部分:多模态RAG,处理图片和音频
7.1 为什么需要多模态嵌入?
传统的RAG只能处理文本。但实际业务中有大量非文本数据:产品图片、监控录像、客服录音、设计稿截图。如果能把它们也纳入检索体系,应用场景会广得多。
多模态嵌入模型解决的问题是:把不同模态的数据映射到同一个向量空间。
以CLIP为例,它有两个编码器:图像编码器和文本编码器。两者的输出维度相同(比如512维或768维)。训练过程中,模型学习的是:匹配的图文对在向量空间里距离近,不匹配的图文对距离远。
训练完成后的效果是:
- 一张"猫"的图片 → 向量V_img
- 一段"猫"的文字 → 向量V_text
- V_img和V_text的余弦相似度很高
7.2 以文搜图的实现
# Python端,使用Chinese-CLIP
from transformers import CLIPProcessor, CLIPModel
from PIL import Image
import torch
model = CLIPModel.from_pretrained("OFA-Sys/chinese-clip-vit-base-patch16")
processor = CLIPProcessor.from_pretrained("OFA-Sys/chinese-clip-vit-base-patch16")
# 入库:图片→向量
def image_to_vector(image_path):
img = Image.open(image_path)
inputs = processor(images=img, return_tensors="pt")
with torch.no_grad():
vec = model.get_image_features(**inputs)
return vec.numpy()[0].tolist()
# 查询:文字→向量
def text_to_vector(text):
inputs = processor(text=text, return_tensors="pt", padding=True)
with torch.no_grad():
vec = model.get_text_features(**inputs)
return vec.numpy()[0].tolist()
# 以文搜图流程
query_text = "年会合影"
query_vec = text_to_vector(query_text)
results = vector_db.search(query_vec, top_k=10)
# 返回10张最相关的图片
// PHP调用Python的CLIP服务
function search_images_by_text($text) {
$vec = call_clip_text($text); // 调用CLIP文本编码API
return $vector_db->search($vec, top_k: 10);
}
7.3 处理音频的CLAP模型
音频的处理逻辑完全一样,只是换了个模型CLAP(Contrastive Language-Audio Pretraining):
import laion_clap
model = laion_clap.CLAP_Module(enable_fusion=False)
model.load_ckpt()
# 音频→向量
audio_vec = model.get_audio_embedding_from_filelist(['sound.wav'])
# 文本→向量(同一个向量空间)
text_vec = model.get_text_embedding(["雨声", "敲击键盘", "鸟叫"])
# 以文搜音:输入"雨声",返回最匹配的音频文件
similarity = np.dot(audio_vec, text_vec.T) # 余弦相似度矩阵
音频文件的检索流程跟图片完全一致,只是嵌入模型的输入从图片换成了音频波形。
7.4 ImageBind:真正的"万物统一"
Meta的ImageBind是我觉得最有趣的多模态模型。它支持6种模态:图像、文本、音频、深度图、热力图、IMU传感器数据。关键是它用"图像"作为各种模态之间的桥梁,即使没有"音频-IMU"的配对训练数据,也能通过"音频-图像"和"图像-IMU"的关系间接建立"音频-IMU"的关联。
import imagebind
model = imagebind.imagebind_model.imagebind_huge()
# 6种模态的输入同时编码
embeddings = model({
'image': imagebind.data.load_and_transform_vision_data([img_path], device),
'text': imagebind.data.load_and_transform_text_data([text], device),
'audio': imagebind.data.load_and_transform_audio_data([audio_path], device),
# ... 其他模态
})
# 所有模态的向量在同一空间,可以互相检索
目前多模态RAG的落地场景集中在电商(以图搜商品)、安防(监控画面检索)、音效库(以文搜音)。整体成熟度不如纯文本RAG,但发展速度很快。
第八部分:关于RAG的评估和监控
8.1 没有评估,就不知道系统好不好
RAG系统搭起来之后,怎么知道效果是60分还是90分?靠人工看几个样本肯定不行,数据多了以后根本看不过来。
8.2 RAGAS的三个核心指标
RAGAS(RAG Assessment)是目前比较成熟的评估框架,定义了三个定量指标:
忠实度(Faithfulness):
回答是否完全基于检索到的上下文,有没有额外的"创作"?简单的检查方式是让一个"评估大模型"把答案拆成多个"主张",逐一验证每个主张是否能在上下文中找到依据。
答案:"年假需要提前3个工作日申请,超过5天的年假需要提前2周申请。"
上下文:"年假需提前3个工作日提交申请。"
拆解主张:
1. "年假需要提前3个工作日申请" → 在上下文中找到依据 → 成立
2. "超过5天的年假需要提前2周申请" → 上下文中没有依据 → 不成立(幻觉)
忠实度 = 成立的主张数 / 总主张数 = 1/2 = 0.5
上下文相关性(Context Relevancy):
检索回来的文档里有多少是跟问题无关的"废话"?如果Top-5里只有2篇有用,3篇是无关的,相关性得分就是2/5=0.4。
这个指标的背后逻辑是:检索系统如果召回精度高,Top-K里应该绝大部分都是相关的。
答案相关性(Answer Relevancy):
最终的答案是否直接针对用户问题,有没有答非所问?
# 伪代码:用大模型评估答案相关性
def evaluate_answer_relevancy(question, answer):
prompt = "请判断以下回答是否直接回答了问题,用0-1打分:\n"
prompt += f"问题:{question}\n回答:{answer}\n分数:"
score = call_llm(prompt)
return float(score)
8.3 构建测试集
评估需要测试集,即一组预先准备好的"(问题,标准答案,标准上下文)"三元组。
我的做法是:
- 从知识库里随机抽取一批文档片段。
- 为每个片段写一个相关问题。
- 把片段本身作为"标准上下文"。
- 把这个三元组存下来作为测试数据。
[
{
"question": "年假需要提前几天申请?",
"ground_truth": "需提前3个工作日提交申请",
"ground_truth_context": "年假申请需提前3个工作日通过OA系统提交..."
},
{
"question": "工资什么时候发?",
"ground_truth": "每月15号",
"ground_truth_context": "公司于每月15日发放上月薪资..."
}
]
测试集一般在200-500条之间,覆盖不同场景。准备好之后跑一轮评估,得到基线数据。每次修改系统(换模型、调整分块大小、改检索参数),再跑一轮评估,对比分数变化。
第九部分:我目前的完整RAG架构图
学到这里,我可以画出完整的RAG架构了:
┌─────────────────────────────────────────────────────────────────────────────┐
│ 索引阶段 │
│ │
│ 原始文档 → 文档加载器 → 分块(父子/语义/固定) → 嵌入模型 → 向量库 │
│ │ │
│ ├→ 稠密向量 → 向量索引(HNSW/IVF) │
│ │ │
│ ├→ 稀疏向量(BM25) → 倒排索引 │
│ │ │
│ └→ 元数据(日期/作者/部门) → 过滤索引 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 查询阶段 │
│ │
│ 用户问题 → 查询改写(HyDE/Multi-Query) → 向量化 │
│ │ │
│ ├→ 向量库检索 → 200条 │
│ │ │
│ ├→ BM25检索 → 200条 │
│ │ │
│ └→ 元数据过滤 (按条件预筛选) │
│ │ │
│ ▼ │
│ RRF融合 → 100条 │
│ │ │
│ ▼ │
│ 重排模型 → Top-5 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 生成阶段 │
│ │
│ Top-5文档 + 用户问题 → 提示词构建 → 大语言模型 → 最终回答 │
│ │
│ 旁路:RAGAS评估(忠实度/上下文相关性/答案相关性) │
│ 旁路:语义缓存(相同语义问题直接返回缓存答案) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
这套架构里每个环节都可以独立替换和优化。我在实际项目中不会每个环节都加,根据需求裁剪。
第十部分:我还没搞懂的问题
这篇文章写到这里,我已经把从零开始学RAG的所有知识点都过了一遍。但有些问题还没想明白:
-
长上下文模型对RAG架构的影响。GPT-4o的上下文窗口已经到了100万token级别。如果上下文足够装下整个知识库,RAG的检索环节还有必要吗?还是一种做法是把整个知识库作为提示词的一部分塞进去?我试了一下,几万字确实塞得下,但模型在处理长文本时的注意力分配不是均匀的,中间部分的内容容易被忽略。所以目前我还是倾向"检索+生成"的架构,但这个平衡点在哪里,我没找到结论。
-
Embedding模型的微调。我尝试在自己的领域数据上微调BGE模型,效果提升不明显,可能是微调数据量不够,也可能是我方法有问题。这部分还需要专门花时间研究。
-
流式实时RAG。目前我处理的是静态文档库。如果知识库每小时在更新,索引的重建策略、增量更新的可靠性,这些我还没在生产环境验证过。
-
评估成本的把控。用大模型做自动化评估,本身也在调用大模型,评估成本可能赶上回答成本。每次迭代跑全量测试集是否划算,不同项目需要权衡。
写在最后
这篇文章挺长的。从头到尾梳理了一遍,也是对我自己学习过程的总结。
如果让我给刚开始学RAG的人一个建议,我会说:不要一开始就上LangChain或LlamaIndex这些框架。先用你最熟悉的语言,从"Embedding + 向量数据库 + 大模型"这三样东西开始,手写一个最简单的检索+生成流程。跑通了,再一个个往里加重排、加混合检索、加查询改写。每一步你都能看到效果变化,才能真正理解每个组件在干什么。
我一开始就是直接用框架,调了半天参数也不知道问题在哪。后来全部拆掉从头自己写了一遍,才知道每一步的细节。
最后,所有代码片段都是我从实际项目中脱敏后让AI重写生成的伪代码,命名和逻辑尽量保持清晰。如果你想按照这些示例想跑通一个本地版本,需要实际调试和完善我的伪代码。
最后的最后,真想搭建rag知识库AI站点客服推荐去尝试下开源项目agent-desk,这是官网链接:
https://agent-desk.huabei.pro/zh/
git地址:
https://github.com/huabeitech/agent-desk
这个作者的开源项目就是搭建rag知识库AI站点客服
更多推荐


所有评论(0)