Post

RAG 技术深度解析:从 Embedding 原理到工程实践

RAG 技术深度解析:从 Embedding 原理到工程实践

什么是 RAG?

RAG(Retrieval-Augmented Generation,检索增强生成)是一种将信息检索大语言模型生成相结合的技术架构。核心思想:在让 LLM 生成回答之前,先从外部知识库中检索相关信息,将其作为上下文注入到 prompt 中,从而提升回答的准确性和时效性。

基本工作流程

1
用户提问 → 向量化查询 → 检索相关文档 → 拼接为 Prompt → LLM 生成回答

三个阶段:

  1. 索引阶段(Indexing):将文档切分为 chunks → Embedding 模型转为向量 → 存入向量数据库
  2. 检索阶段(Retrieval):用户问题转为向量 → 向量库中相似性搜索 → 取回最相关的片段
  3. 生成阶段(Generation):检索到的片段 + 用户问题拼成 prompt → LLM 生成回答

RAG 解决的核心问题

问题说明
知识时效性LLM 训练数据有截止日期,RAG 可接入实时更新的知识库
幻觉(Hallucination)基于真实文档生成,减少”编造”
领域专业性无需微调,即可回答企业内部、法律、医疗等专业问题
可追溯性回答可附带来源引用,便于验证

RAG vs Fine-tuning:RAG 适合”注入知识”(让模型知道新东西),Fine-tuning 适合”改变行为”(让模型用特定风格/格式回答)。两者不冲突,可以组合使用。


Retrieval 阶段:远不止字符串匹配

RAG 的检索阶段不是简单的关键词搜索,现代 RAG 系统的检索技术形成了一个从简单到复杂的光谱:

1
字符串匹配 → 关键词检索(BM25) → 向量语义检索 → 混合检索 → Agentic 检索

BM25 关键词检索(Sparse Retrieval)

BM25 不是简单的字符串匹配,它考虑了 TF(词频)、IDF(逆文档频率)和文档长度归一化:

1
2
3
# 查询:"Python 内存泄漏"
# ✅ 命中:"Python 程序中常见的内存泄漏原因"(关键词重叠)
# ❌ 漏掉:"程序运行越来越慢,占用资源不断增长"(语义相关但无关键词重叠)

BM25 在精确术语匹配场景下依然很强,比如搜索错误码 ERR_CONNECTION_REFUSED,向量检索反而可能不如它。

向量语义检索(Dense Retrieval)

1
2
3
query    = embed("如何提升代码质量")         # → [0.12, -0.34, 0.56, ...]
doc_1    = embed("代码审查的最佳实践")        # → [0.11, -0.31, 0.58, ...]  ✅ 相似度高
doc_2    = embed("今天天气不错")             # → [-0.78, 0.42, -0.15, ...] ❌ 相似度低

没有任何共同关键词,但语义检索能找到 doc_1。核心优势:理解语义,而非匹配字符

Bi-Encoder 与 Cross-Encoder

在深入混合检索和 Reranking 之前,需要先理解两种核心的文本匹配架构:

Bi-Encoder(双塔模型)— 用于初检索

Bi-Encoder 将 query 和 document 分别独立编码为向量,然后用余弦相似度等指标比较:

1
2
3
4
5
6
7
Bi-Encoder 工作方式:

  "如何优化数据库"  ──→ [Encoder] ──→ vec_q = [0.2, -0.4, ...]
                                                    ↓ 余弦相似度
  "MySQL索引调优指南" ──→ [Encoder] ──→ vec_d = [0.3, -0.3, ...]

  关键:query 和 doc 的编码是独立的,互不影响

优点:document 的向量可以预计算并缓存。查询时只需编码 query(一次推理),然后在预计算好的百万向量中搜索,毫秒级返回。这是向量检索能做到大规模实时检索的根本原因。

缺点:因为 query 和 doc 是独立编码的,模型无法看到两者之间的细粒度交互,精度有限。

Cross-Encoder(交叉编码器)— 用于重排序

Cross-Encoder 将 query 和 document 拼接在一起作为输入,让 Transformer 的 Self-Attention 同时看到两段文本:

1
2
3
4
5
6
7
8
9
Cross-Encoder 工作方式:

  输入:"[CLS] 如何优化数据库 [SEP] MySQL索引调优指南 [SEP]"
          ↓
    整个 Transformer 编码(query 和 doc 的每个词互相 attend)
          ↓
    输出:相关性得分 0.95

  关键:query 和 doc 在 Attention 层中深度交互

优点:精度远高于 Bi-Encoder,因为模型能捕捉 query 和 doc 之间的细粒度语义关系。

缺点:无法预计算。每个 query-doc 对都需要一次完整的 Transformer 前向推理。如果有 100 万篇文档,就需要推理 100 万次——根本不可行。

两者配合:漏斗架构

1
2
3
4
5
百万文档
    ↓ Bi-Encoder 初检索(毫秒级)
Top-50 候选
    ↓ Cross-Encoder 精排(几百毫秒)
Top-5 最终结果
 Bi-EncoderCross-Encoder
编码方式query 和 doc 分别编码query 和 doc 拼接编码
能否预计算✅ doc 向量可预存❌ 每对都要实时算
速度极快(毫秒级搜百万)慢(每对约 10ms)
精度中等
用途初检索(召回阶段)重排序(精排阶段)

混合检索 + Reranking(生产环境主流)

1
2
3
4
5
用户查询
   ├── BM25 关键词检索 ──→ 候选集 A(擅长精确匹配)
   ├── Bi-Encoder 向量检索 ──→ 候选集 B(擅长语义理解)
   ├── RRF 融合 ──→ Top-50 候选
   └── Cross-Encoder Reranking ──→ Top-5 最终结果
场景BM25向量检索
搜索错误码 NullPointerException✅ 精确命中⚠️ 可能匹配到其他异常
搜索”程序跑着跑着就崩了”❌ 无关键词✅ 语义匹配到 crash 相关文档

Reranking 的具体实现

Reranking 使用 Cross-Encoder 对初检索结果进行精细打分和重新排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from sentence_transformers import CrossEncoder

# 加载 Cross-Encoder 重排模型
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")

def rerank(query: str, candidates: list[str], top_k: int = 5):
    # 构造 [query, doc] 对
    pairs = [[query, doc] for doc in candidates]

    # Cross-Encoder 为每对打分(query 和 doc 拼接后一起编码)
    scores = reranker.predict(pairs)

    # 按得分降序排列
    ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)

    return ranked[:top_k]

# 示例
query = "如何解决Python内存泄漏"
candidates = [
    "Python垃圾回收机制与内存管理",        # 高度相关
    "Python Web框架性能对比",              # 部分相关
    "Java JVM内存调优指南",               # 不同语言
    "使用tracemalloc追踪Python内存分配",   # 高度相关
]

results = rerank(query, candidates, top_k=2)
# → [("使用tracemalloc追踪Python内存分配", 0.97),
#    ("Python垃圾回收机制与内存管理", 0.93)]

Reranking 为什么能提升效果?因为初检索(Bi-Encoder)相当于”快速扫一眼标题”,而 Reranking(Cross-Encoder)相当于”仔细读一遍内容再判断相关性”。

常用的 Reranker 模型:

模型特点
BAAI/bge-reranker-v2-m3多语言,效果好,开源
cross-encoder/ms-marco-MiniLM-L-12-v2英文,轻量快速
Cohere Rerank API云服务,开箱即用
Jina Reranker v2多语言,支持长文本

Embedding 的实现原理

核心直觉:把文字变成数字

Embedding 就是把文本映射到数学空间中的函数。目标:语义相近的词/句子,在向量空间中距离也近。

1
2
3
"猫"  → [0.21, -0.45, 0.78, 0.12, ...]   # 一个高维向量
"狗"  → [0.19, -0.42, 0.81, 0.09, ...]   # 和"猫"很接近
"汽车" → [-0.56, 0.33, -0.12, 0.67, ...]  # 和"猫"很远

演进历程

One-Hot 编码(最原始)

1
2
3
""   [1, 0, 0, 0]
""   [0, 1, 0, 0]
# 问题:任意两个词距离都相同,完全没有语义信息

Word2Vec(2013,里程碑)

核心思想:一个词的含义由它周围的词决定

Word2Vec 本质是一个浅层神经网络。以 Skip-gram 为例:给定中心词”猫”,训练网络去预测周围的词(”可爱”“喵”“宠物”)。因为”猫”和”狗”出现在相似的上下文中,训练后它们的向量自然就接近——不是被”告知”相似,而是梯度下降自然把它们推到了相近的位置

1
2
输入层(one-hot) → 隐藏层(embedding!) → 输出层(softmax预测周围词)
    V × 1            V × d 权重矩阵         V × 1

训练完成后,隐藏层的权重矩阵 W 就是所有词的 Embedding 查找表。每行对应一个词的向量,使用时直接查表,O(1) 操作。

著名的向量算术:vec("国王") - vec("男人") + vec("女人") ≈ vec("女王")

但 Word2Vec 有局限:一个词只有一个固定向量,无法处理一词多义。”苹果很好吃”和”苹果发布新品”中的”苹果”会得到相同向量。

Transformer-based Embedding(现代主流)

Self-Attention 让每个词的向量取决于整句话的上下文:

1
2
embed("苹果很好吃")      # "苹果" → 偏向水果语义的向量
embed("苹果发布新品")    # "苹果" → 偏向科技公司语义的向量

流程:输入文本 → Token Embedding + Position Embedding → 多层 Transformer 编码器 → Pooling → 句子向量

相似度度量

有了向量之后,衡量相似度就是纯数学问题:

余弦相似度(最常用):衡量方向是否一致,忽略长度

1
cos(θ) = (A · B) / (‖A‖ × ‖B‖)    范围 [-1, 1]

为什么余弦最适合文本? 文本长度不同会导致向量大小不同,但语义方向是稳定的。余弦相似度忽略大小只看方向,恰好符合需求。


Embedding 模型的本质:计算函数,不是关联度表

一个常见的误解是认为 Embedding 模型存了一张”词与词的关联度表”。实际上:

 Word2VecTransformer Embedding
存储内容W 矩阵(词→向量 查找表)神经网络权重(多层 Transformer)
使用方式查表,O(1)前向推理,需要计算
同词不同语境返回相同向量 ❌返回不同向量 ✅
没见过的词无法处理 ❌子词拆分后可处理 ✅

Transformer Embedding 模型存的是网络权重(计算函数),每次使用都需要运行前向推理。使用 Embedding API 时,模型在服务端 GPU 上实时计算。

工程优化:预计算 + 缓存

1
2
离线阶段:100万篇文档 → Embedding 模型逐篇计算 → 向量存入数据库(只做一次)
在线阶段:用户提问 → Embedding 模型计算 1 个向量 → 向量数据库搜索 → top-K

这就是向量数据库存在的意义:文档向量只需算一次然后存起来。


大语言模型 ≠ Embedding 相似度搜索

另一个常见误解:大模型是不是把输入转换为 Embedding,然后找到最相近的下一个词?

不是。 Embedding 模型和 LLM 是完全不同的东西:

 Embedding 模型LLM 大语言模型
目的文本 → 向量文本 → 文本
架构Encoder(编码器)Decoder(解码器)
输出一个向量下一个词的概率分布
能力不会说话能生成内容

LLM 不是在向量空间里”搜索相似的词”,而是经过几十层 Transformer 运算后,输出整个词表上的概率分布,然后从中采样:

1
2
3
4
5
6
7
8
输入:"中国的首都是"
  → 96 层 Transformer 计算
  → 输出概率分布:
      "北京": 0.92  ← 最高概率
      "上海": 0.03
      "南京": 0.02
      ...
  → 采样得到 "北京"

Embedding 层在 LLM 内部只是输入层,占参数量极小(~1-2%),真正的”智能”来自后面的 Transformer 层。类比:Embedding 层相当于”视网膜”(信号转换),Transformer 层相当于”大脑皮层”(理解和思考)。


模型更新与向量空间兼容性

模型更新后向量会变吗?

完全不同,不可混用。 每个模型版本有不同的网络权重,同一段文本经过不同模型计算出的向量不在同一个空间:

1
2
3
# 同一句话,不同模型的输出完全不同
text_embedding_ada_002("深度学习")   [0.21, -0.45, 0.78, ...]  # 1536 维
text_embedding_3_large("深度学习")   [-0.34, 0.15, 0.52, ...]  # 3072 维

这意味着升级 Embedding 模型 = 必须重新编码所有文档

生产环境的应对策略

  1. 锁定模型版本:不轻易升级,除非有明确的质量收益
  2. 版本化存储:新旧 collection 隔离,灰度切换验证
  3. 自托管开源模型:如 BGE、E5,版本完全由自己控制
  4. 元数据管理:记录每个向量的模型版本

为什么 Embedding 模型无法统一?

  • 数学本质:向量空间有旋转不变性,每次训练都产生不同空间(像不同的地图坐标系)
  • 设计目标不同:不同场景需要不同的优化方向(见下表)
  • 技术快速迭代:统一标准会冻结进步
  • 商业竞争:各厂商有动力做差异化

主流 Embedding 模型对比

不同模型针对不同场景做了专门优化,没有”万能 Embedding”:

模型厂商维度最大 Token侧重点适用场景
text-embedding-3-largeOpenAI30728191通用语义检索开放域问答、文档搜索
text-embedding-3-smallOpenAI15368191性价比成本敏感的通用场景
voyage-code-3Voyage AI102416000代码理解代码搜索、技术文档检索
voyage-law-2Voyage AI102416000法律领域法律条文、判例检索
BAAI/bge-large-zh-v1.5智源1024512中文优化中文文档、中文问答
BAAI/bge-m3智源10248192多语言+多粒度跨语言检索(中↔英↔日等)
intfloat/e5-mistral-7bMicrosoft409632768长文本整篇论文、长文档检索
jina-embeddings-v3Jina AI10248192多任务检索+分类+聚类一模多用
nomic-embed-text-v2-moeNomic7688192轻量高效资源受限的本地部署
Cohere embed-v4Cohere1024512多模态文本+图片混合检索

选型决策树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
你的主要语言?
├── 中文为主 → bge-large-zh 或 bge-m3
├── 英文为主 → text-embedding-3-large 或 e5-mistral
└── 多语言混合 → bge-m3 或 jina-embeddings-v3

你的内容类型?
├── 代码/技术文档 → voyage-code-3
├── 法律文书 → voyage-law-2
├── 超长文档(>4K tokens)→ e5-mistral-7b 或 jina-v3
└── 通用文本 → text-embedding-3-large 或 bge-m3

你的部署方式?
├── 云 API(省心) → OpenAI / Cohere / Voyage
└── 本地部署(可控) → bge 系列 / e5 系列 / nomic

各模型不仅向量空间不同,连维度都不统一(768/1024/1536/3072/4096),根本无法互通。选定一个模型后,所有文档和查询都必须用同一个模型编码。

Embedding 模型需要持续改进

语言是活的,Embedding 模型面临:

  • 新词新概念:”ChatGPT”“内卷”“Agentic RAG”——训练时不存在的词
  • 词义漂移:”PUA”从”搭讪艺术家”变为”精神控制”;”元宇宙”从科幻术语变为科技热词再变为”概念炒作”
  • 领域知识演化:新药名、新法规、新技术术语

应对方式包括子词分词(BPE)缓解新词问题、领域微调、定期升级模型版本。而 RAG 本身就是一种弥补——把”知识更新”和”模型更新”解耦,更新知识库即可覆盖大部分场景。


RAG 完整实现路径

Step 1:文档解析(Document Loading)

1
2
3
4
5
6
7
8
9
from langchain_community.document_loaders import (
    PyPDFLoader,           # PDF
    Docx2txtLoader,        # Word
    UnstructuredHTMLLoader,# HTML
    TextLoader,            # 纯文本
)

loader = PyPDFLoader("company_report.pdf")
pages = loader.load()  # 每页一个 Document,含 page_content 和 metadata

PDF 解析坑最多:扫描件需 OCR、表格需专门提取、多栏排版可能错乱。数据质量在 RAG 中的重要性远超模型选择。

Step 2:文本清洗(Text Cleaning)

1
2
3
4
5
6
7
import re

def clean_text(text: str) -> str:
    text = re.sub(r'(?<!\n)\n(?!\n)', ' ', text)   # 单换行 → 空格
    text = re.sub(r' +', ' ', text)                 # 去多余空白
    text = re.sub(r'\n{3,}', '\n\n', text)          # 去多余空行
    return text.strip()

Step 3:文本分块(Chunking)— 最关键的环节之一

为什么需要分块:

  • Embedding 模型有长度限制:bge-large 最大 512 tokens,text-embedding-3 最大 8191 tokens
  • 文档太长会”稀释”语义:一整篇 50 页文档压缩成一个向量,等于什么都没表达
  • LLM 上下文窗口有限:检索到的 chunk 要拼入 prompt,chunk 太大则能放的条数就少

策略 1:固定大小分块

最简单粗暴的方式,按字符数或 token 数直接切分:

1
2
3
4
5
6
7
8
from langchain.text_splitter import CharacterTextSplitter

splitter = CharacterTextSplitter(
    chunk_size=500,       # 每块最大 500 字符
    chunk_overlap=100,    # 相邻块重叠 100 字符
    separator="\n"        # 优先在换行处切分
)
chunks = splitter.split_text(long_text)

重叠区域(overlap)的作用:

1
2
3
4
5
6
7
8
9
10
11
原文:[========A========][========B========][========C========]

chunk_size=500, overlap=100:

  chunk1: [========A========]
  chunk2:            [=A_tail=B===============]
  chunk3:                      [=B_tail=C===============]

  重叠区保证跨块边界的信息不丢失
  比如一个关键结论刚好在 A 和 B 的交界处,
  没有 overlap 就会被切断,两个 chunk 各拿到一半

优点:实现简单,速度快,行为可预测。

缺点:完全不考虑语义,可能在句子中间、甚至词中间切断。一段完整的论述可能被分到两个 chunk 中,各自都不完整。

策略 2:递归字符分块(通用推荐)

按优先级尝试不同的分隔符,尽量在语义边界处切分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=100,
    separators=[
        "\n\n",    # 1. 最优先:按段落分(两个换行 = 段落分隔)
        "\n",      # 2. 其次:按行分
        "",      # 3. 再次:按中文句号分
        ".",       # 4. 按英文句号分
        "", "", "!", "?",  # 5. 按感叹号/问号分
        "", ";",  # 6. 按分号分
        " ",       # 7. 按空格分
        ""         # 8. 兜底:逐字符分
    ]
)

工作原理:先尝试用 \n\n 分,如果分出来的块仍然超过 chunk_size,就降级用 \n 分,依此类推。这样保证了段落完整 > 句子完整 > 词完整的优先级。

优点:在简单和智能之间取得了很好的平衡。大多数情况下能在合理的语义边界处切分。

缺点:依赖分隔符启发式规则。对于没有明显段落分隔的文本(如 OCR 结果、日志)效果退化。中文文本需要自定义 separators(默认是英文优化的)。

策略 3:语义分块(基于 Embedding)

用 Embedding 模型检测话题转换点,在语义断裂处切分:

1
2
3
4
5
6
7
8
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings

splitter = SemanticChunker(
    embeddings=OpenAIEmbeddings(),
    breakpoint_threshold_type="percentile",
    breakpoint_threshold_amount=75
)

工作原理:

1
2
3
4
5
6
7
8
9
10
11
1. 按句子切分文本
2. 计算每对相邻句子的 Embedding 余弦相似度

   句子1 ↔ 句子2: 0.92(谈同一话题)
   句子2 ↔ 句子3: 0.89(谈同一话题)
   句子3 ↔ 句子4: 0.45 ← 相似度骤降!话题转换!
   句子4 ↔ 句子5: 0.87(新话题内部)

3. 在相似度骤降处切分
   → chunk1 = [句子1, 句子2, 句子3](话题 A)
   → chunk2 = [句子4, 句子5, ...]  (话题 B)

断裂点检测有三种模式:

  • percentile:相似度低于全局 P25 分位数时切分
  • standard_deviation:相似度低于均值减一个标准差时切分
  • interquartile:使用四分位距检测异常低值

优点:真正基于语义切分,每个 chunk 内部话题高度一致,检索时噪声最少。

缺点:需要对每个句子调用 Embedding 模型,分块速度慢得多(尤其是大文档)。chunk 大小不固定,可能出现极小或极大的 chunk。依赖 Embedding 模型质量。

策略 4:按文档结构分块

利用文档自身的标题、章节等结构信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from langchain.text_splitter import MarkdownHeaderTextSplitter

splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[
        ("#",    "h1"),
        ("##",   "h2"),
        ("###",  "h3"),
    ]
)
chunks = splitter.split_text(markdown_text)

# 每个 chunk 自动继承父标题作为 metadata:
# chunk.content = "具体内容..."
# chunk.metadata = {"h1": "用户手册", "h2": "安装指南", "h3": "Windows"}

对 HTML 文档也有类似的处理:按 <h1><h2><section> 等标签切分。

优点:充分利用作者的原始组织意图,每个 chunk 天然是一个完整的小节。metadata 中的标题层级可以用于过滤和检索增强(如搜索时加上”出自安装指南章节”的约束)。

缺点:依赖文档格式规范。不规范的文档(没有标题、标题层级混乱)则无法使用。

策略 5:父子 chunk(Parent-Child / Small-to-Big)

一种进阶策略:用小 chunk 检索,但返回大 chunk 给 LLM:

1
2
3
4
5
6
原始文档
    ↓ 先切成大块(Parent chunk, 2000字)
    ↓ 每个大块再切成小块(Child chunk, 200字)

检索时:用 child chunk 的向量做搜索(小块语义集中,检索准)
返回时:把命中的 child 对应的 parent chunk 返回给 LLM(大块上下文完整)

优点:兼顾检索精度(小 chunk)和上下文完整性(大 chunk)。

缺点:实现复杂度高,需要维护 parent-child 映射关系。

各策略全面对比

策略适用场景分块质量速度实现复杂度推荐度
固定大小快速原型、日志类文本极快最低⭐⭐
递归字符大多数通用场景中高⭐⭐⭐⭐⭐
语义分块高质量检索需求⭐⭐⭐⭐
结构分块Markdown/HTML/PDF有目录⭐⭐⭐⭐
父子 chunk对上下文完整性要求高最高⭐⭐⭐⭐

关键参数选择

  • chunk_size300-800 字符是常见范围
    • < 200:chunk 太碎,缺乏上下文,LLM 看到后可能无法理解
    • 300-500:适合短问答、FAQ 类场景
    • 500-800:适合段落级的文档检索(推荐起步值)
    • > 1000:适合长文档摘要场景,但检索噪声增大
  • chunk_overlap:chunk_size 的 10%-20%
    • 保证跨块边界的信息不丢失
    • 过大会导致大量重复内容,浪费存储和检索资源

Step 4:批量向量化(Batch Embedding)

API 方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from openai import OpenAI

client = OpenAI()

def batch_embed(texts, model="text-embedding-3-small", batch_size=100):
    all_embeddings = []
    for i in range(0, len(texts), batch_size):
        batch = texts[i : i + batch_size]
        response = client.embeddings.create(input=batch, model=model)
        batch_embeddings = [
            item.embedding
            for item in sorted(response.data, key=lambda x: x.index)
        ]
        all_embeddings.extend(batch_embeddings)
    return all_embeddings

生产级需处理:并发控制、速率限制(429 重试)、指数退避、断点续传。

本地模型方式

1
2
3
4
5
6
7
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
embeddings = model.encode(
    texts, batch_size=64, show_progress_bar=True,
    normalize_embeddings=True, device="cuda"
)

Step 5:存入向量数据库

主流选择:

数据库特点
Chroma轻量级,适合原型和小规模
FAISSMeta 开源,高性能向量检索库
Milvus开源,支持私有部署,适合大规模
Pinecone全托管云服务,开箱即用
pgvectorPostgreSQL 插件,适合已有 PG 的团队
1
2
3
4
5
6
7
8
9
10
11
12
13
import chromadb

client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(
    name="company_docs",
    metadata={"hnsw:space": "cosine"}
)
collection.add(
    ids=[f"doc_{i}" for i in range(len(chunks))],
    embeddings=embeddings,
    documents=[c.page_content for c in chunks],
    metadatas=[{"source": c.metadata["source"], "page": c.metadata.get("page", 0)} for c in chunks]
)

FAISS 的 IVF 索引原理:先用 K-Means 把所有向量聚成 N 个簇,查询时只在最近的几个簇内搜索,速度提升 10-100 倍。PQ(Product Quantization)进一步压缩向量,支持亿级检索。

Step 6:在线检索

1
2
3
4
5
6
7
8
9
10
11
# 混合检索 + RRF 融合
class HybridRetriever:
    def retrieve(self, query, top_k=5):
        # BM25 关键词检索 → 候选集 A
        bm25_results = self.bm25.get_top_n(query, n=20)
        # 向量语义检索 → 候选集 B
        vec_results = self.collection.query(query_embeddings=[embed(query)], n_results=20)
        # RRF 融合(只用排名,不依赖分数)
        fused = self.rrf_merge(bm25_results, vec_results)
        # Cross-Encoder 重排序
        return self.rerank(query, fused[:20])[:top_k]

RRF(Reciprocal Rank Fusion)score(d) = Σ 1/(k + rank),在两个列表中都排在前面的文档得分最高。

Step 7:Prompt 构造 + LLM 生成

1
2
3
4
5
6
7
8
9
prompt = f"""基于以下参考资料回答用户问题。
规则:只基于资料回答,不编造;标注来源;资料不足时明确说明。

参考资料:
{context}

用户问题:{query}"""

response = llm.chat(messages=[{"role": "user", "content": prompt}], temperature=0.1)

Step 8:评估

1
2
3
检索质量:Recall@K、MRR、NDCG
生成质量:Faithfulness(忠实度)、Relevance(相关性)
端到端:  RAGAS 框架自动评估

总结:影响 RAG 效果的因素排名

1
2
数据质量(清洗+解析) > Chunking 策略 > Embedding 模型选择
> 检索策略(混合+重排) > Prompt 设计 > LLM 选择

很多人把精力放在最后一步选 LLM,其实前面几步的 ROI 更高。一个最小可用的 RAG 系统可以在 50 行 Python 内搭建完成——先跑通再优化,不要一开始就追求完美架构

This post is licensed under CC BY 4.0 by the author.