本文不是教程,是一份完整的学习笔记。全文挺长的,代码示例涵盖从基础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-smallOpenAI1536闭源API性价比高,约$0.02/百万token
text-embedding-3-largeOpenAI3072闭源API精度更高,价格也更高
BGE-large-zh北京智源1024开源中文效果最好的开源模型之一
BGE-m3北京智源1024开源支持多语言,支持稠密+稀疏混合检索
M3EM3E团队768开源针对中文RAG任务优化
jina-embeddings-v3Jina AI768/1024开源支持多语言,可调输出维度
Cohere embed-v4Cohere1024闭源API支持多模态(文本+图像)

多模态嵌入模型(后面专门讲):

模型名称开发者支持模态特点
CLIPOpenAI图像+文本最早的多模态嵌入模型之一
Chinese-CLIP智源/阿里图像+文本CLIP的中文版
CLAPLAION/Microsoft音频+文本以文搜音
ImageBindMeta6种模态图像、文本、音频、深度、热力、IMU
Jina CLIPJina 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()]

结果遇到了几个问题:

  1. 一个段落可能太短(就一句话),缺乏上下文,大模型看不懂。
  2. 一个段落可能太长(几百字),向量被稀释,检索时很难命中具体的点。
  3. 表格、列表、代码块被胡乱切开,内容完全破碎。

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))

我不打算在这篇文章里深入推导这个公式。只需要记住两个关键点:

  1. 词频越高分越高:文档里出现关键词的次数越多,得分越高。
  2. 文档越长惩罚越大:同样出现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)的做法是:

  1. 让大模型根据用户问题生成一段"假设性的答案"。
  2. 用这段生成的答案去向量库搜索,而不是用用户的原始问题。
<?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 构建测试集

评估需要测试集,即一组预先准备好的"(问题,标准答案,标准上下文)"三元组。

我的做法是:

  1. 从知识库里随机抽取一批文档片段。
  2. 为每个片段写一个相关问题。
  3. 把片段本身作为"标准上下文"。
  4. 把这个三元组存下来作为测试数据。
[
    {
        "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的所有知识点都过了一遍。但有些问题还没想明白:

  1. 长上下文模型对RAG架构的影响。GPT-4o的上下文窗口已经到了100万token级别。如果上下文足够装下整个知识库,RAG的检索环节还有必要吗?还是一种做法是把整个知识库作为提示词的一部分塞进去?我试了一下,几万字确实塞得下,但模型在处理长文本时的注意力分配不是均匀的,中间部分的内容容易被忽略。所以目前我还是倾向"检索+生成"的架构,但这个平衡点在哪里,我没找到结论。

  2. Embedding模型的微调。我尝试在自己的领域数据上微调BGE模型,效果提升不明显,可能是微调数据量不够,也可能是我方法有问题。这部分还需要专门花时间研究。

  3. 流式实时RAG。目前我处理的是静态文档库。如果知识库每小时在更新,索引的重建策略、增量更新的可靠性,这些我还没在生产环境验证过。

  4. 评估成本的把控。用大模型做自动化评估,本身也在调用大模型,评估成本可能赶上回答成本。每次迭代跑全量测试集是否划算,不同项目需要权衡。


写在最后

这篇文章挺长的。从头到尾梳理了一遍,也是对我自己学习过程的总结。

如果让我给刚开始学RAG的人一个建议,我会说:不要一开始就上LangChain或LlamaIndex这些框架。先用你最熟悉的语言,从"Embedding + 向量数据库 + 大模型"这三样东西开始,手写一个最简单的检索+生成流程。跑通了,再一个个往里加重排、加混合检索、加查询改写。每一步你都能看到效果变化,才能真正理解每个组件在干什么。

我一开始就是直接用框架,调了半天参数也不知道问题在哪。后来全部拆掉从头自己写了一遍,才知道每一步的细节。

最后,所有代码片段都是我从实际项目中脱敏后让AI重写生成的伪代码,命名和逻辑尽量保持清晰。如果你想按照这些示例想跑通一个本地版本,需要实际调试和完善我的伪代码。

最后的最后,真想搭建rag知识库AI站点客服推荐去尝试下开源项目agent-desk,这是官网链接:
https://agent-desk.huabei.pro/zh/
git地址:
https://github.com/huabeitech/agent-desk
这个作者的开源项目就是搭建rag知识库AI站点客服

Logo

脑启社区是一个专注类脑智能领域的开发者社区。欢迎加入社区,共建类脑智能生态。社区为开发者提供了丰富的开源类脑工具软件、类脑算法模型及数据集、类脑知识库、类脑技术培训课程以及类脑应用案例等资源。

更多推荐