开篇:大模型为什么需要 RAG?
你问 ChatGPT “计算机一班有多少人”,它只能瞎编。
不是因为它不够聪明,而是因为它没见过你的数据。大模型的训练数据截止于某个时间点,对私有文档、企业内部数据、个人笔记一无所知。
RAG(Retrieval-Augmented Generation,检索增强生成) 就是解决这个问题的标准方案。
1 2 3 4 5 6 7 8 9 10
| 传统方式: RAG 方式: 用户提问 用户提问 ↓ ↓ LLM 直接回答 ① 先检索知识库 (可能胡编) ↓ ② 找到相关文档片段 ↓ ③ 把片段 + 问题一起给 LLM ↓ LLM 基于真实数据回答 ✅
|
核心思想:让 LLM 带着参考答案作答,而不是凭空想象。
一、RAG 核心流程
一个完整的 RAG 系统分两个阶段:
阶段一:离线入库(Indexing)
1 2 3 4 5 6 7 8 9
| 原始文档(Word/PDF/TXT) ↓ ① 文档解析 纯文本 ↓ ② 智能切割 N 个 Chunk(文本块) ↓ ③ 向量嵌入(Embedding) N 个 向量(float 数组) ↓ ④ 存入向量数据库 pgvector / Milvus / Pinecone
|
阶段二:在线查询(Querying)
1 2 3 4 5 6 7 8 9
| 用户提问 ↓ ① 问题向量化 查询向量 ↓ ② 向量检索 + 关键词检索 Top-K 个相关 Chunk ↓ ③ 组装增强 Prompt 带上下文的完整提问 ↓ ④ 发送给 LLM 基于真实数据的回答
|
入库慢无所谓,查询一定要快。 入库通常走异步任务(@Async),查询是同步的,毫秒级响应。
二、文档切割——Chunk 策略
为什么切割?
- 上下文窗口有限——LLM 一次只能看几千字,整个文档塞不下
- 检索精度——小块更容易精准命中,大块噪音多
- Token 成本——多余的无关内容都是钱
切割策略
| 策略 |
做法 |
适用场景 |
| 固定长度 |
每 500 字切一块 |
通用场景 |
| 语义切割 |
按段落/标题/标点分割 |
结构清晰的文档 |
| 滑动窗口 |
块间重叠 10-20%,防止信息断裂 |
长文档、小说 |
| 层级切割 |
保留标题-段落层级关系 |
知识库、手册 |
| 父子检索 (Small-to-Big) |
小块检索 + 大块返回 |
全面覆盖(推荐) |
进阶技巧:父子文档检索(Small-to-Big)
这是目前 RAG 公认的最佳切分实践之一:
1 2 3 4 5 6 7 8
| 原始文档 ↓ 切成大块(父文档,1000~2000 tokens) 父块 A 父块 B 父块 C ↓ 再切成小块(子文档,200~500 tokens) 子块A1 A2 A3 子块B1 B2 B3 子块C1 C2 C3
检索阶段: 用"小块"做向量匹配 → 命中率高、噪音少 返回阶段: 返回小块对应的"父块" → LLM 看到的上下文完整
|
小块负责检索精度,大块负责上下文完整度。
切分实战
1 2 3 4 5 6 7 8
| from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter( chunk_size=512, chunk_overlap=50, separators=["\n\n", "\n", "。", ".", " ", ""] ) chunks = splitter.split_text(document_text)
|
经验: 小文档(<5000 字)用固定长度即可;大文档务必用语义切割 + 滑动窗口 + 父子检索,否则检索时大概率丢上下文。
三、向量检索基础
什么是文本向量?
把一段文字变成一个固定长度的数字数组:
1 2 3
| "计算机一班有多少人" → [0.03, -0.12, 0.45, 0.08, ..., -0.11] ↑ ↑ ↑ ↑ ↑ 第0维 第1维 第2维 第3维 ... 第511维
|
这个 512 维的数组编码了文本的语义特征。
向量索引算法:ANN
数据量到百万级后,暴力遍历比对太慢。向量数据库用近似最近邻(ANN) 算法加速:
| 索引类型 |
原理 |
特点 |
适用场景 |
| Flat(暴力) |
逐一比对 |
100% 精确,极慢 |
<10 万,精度优先 |
| IVF_Flat |
K-Means 聚类后搜簇 |
速度快,精度略降 |
中大规模 |
| IVF_PQ |
聚类 + 乘积量化压缩 |
极省内存 |
超大规模,内存受限 |
| HNSW |
多层跳跃图网络 |
速度最快,精度高(推荐) |
最常用首选 |
| ScaNN |
Google 出品 |
优化吞吐量 |
高并发生产环境 |
HNSW 是目前最主流的 ANN 算法——它构建多层图结构(顶层稀疏、底层密集),查询时像”跳格游戏”一样逐层下探,大幅减少需要比较的节点数,时间复杂度近似 O(log n)。
三种相似度计算方法
1 2 3 4 5 6 7 8 9 10 11 12 13
| import numpy as np
def cosine_similarity(a, b): """余弦相似度:衡量方向,最常用于文本""" return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
def euclidean_distance(a, b): """欧氏距离:衡量绝对位置差异""" return np.linalg.norm(a - b)
def dot_product(a, b): """点积:结合方向和长度,推荐系统常用""" return np.dot(a, b)
|
| 方法 |
结果范围 |
适用场景 |
| 余弦相似度 |
-1 ~ 1 |
文本语义搜索(最常用) |
| 欧氏距离 |
0 ~ ∞ |
图像检索、地理空间 |
| 点积 |
-∞ ~ ∞ |
推荐系统(归一化后等价余弦) |
向量数据库选型决策树
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 你的情况是什么? │ ├── 已有 PostgreSQL,且数据量 < 500 万 │ └──> pgvector,无缝集成,零额外运维 │ ├── 做 AI/LLM 应用原型,快速验证 │ └──> Chroma,几行代码搞定 │ ├── 需要生产级部署,性能优先,500 万 ~ 1 亿 │ └──> Qdrant,Rust 实现,性能强 │ ├── 超大规模(> 1 亿),有 K8s 运维能力 │ └──> Milvus,分布式,功能最全 │ └── 团队没有运维能力,愿意付费 └──> Pinecone / Zilliz Cloud,开箱即用
|
Chroma 快速上手
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
| import chromadb from chromadb.utils import embedding_functions
client = chromadb.PersistentClient(path="./my_vector_db")
openai_ef = embedding_functions.OpenAIEmbeddingFunction( api_key="your-api-key", model_name="text-embedding-3-small" )
collection = client.get_or_create_collection( name="my_documents", embedding_function=openai_ef, metadata={"hnsw:space": "cosine"} )
collection.add( documents=["Python 是一种面向对象的编程语言", "机器学习是 AI 的子领域"], ids=["doc_0", "doc_1"] )
results = collection.query(query_texts=["如何用 Python 做 AI"], n_results=3)
|
四、Embedding 模型深入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 第一代:词袋模型(BoW / TF-IDF) "我爱北京天安门" → {"我":1, "爱":1, "北京":1, "天安门":1} ❌ 没有语序信息,不擅长近义词
第二代:Word2Vec / GloVe 每个词一个固定向量,句子 = 词向量平均 ⚠️ 上下文无关,"苹果"(水果)和"苹果"(公司)向量一样
第三代:BERT / Sentence-BERT 整个句子过 Transformer 网络,输出句子级向量 ✅ 理解上下文,理解词序,理解同义词
第四代:BGE / E5 / Jina 专门为检索优化的嵌入模型,加入指令微调 ✅ 区分 query 和 document,检索精度再提升
|
主流模型对比
| 模型 |
维度 |
大小 |
中文效果 |
推理速度 |
适用场景 |
| bge-small-zh-v1.5 |
512 |
90MB |
⭐⭐⭐⭐⭐ |
快 |
中文 RAG 首选 |
| bge-large-zh-v1.5 |
1024 |
1.3GB |
⭐⭐⭐⭐⭐ |
中 |
高精度中文 |
| bge-m3 |
1024 |
2.2GB |
⭐⭐⭐⭐⭐ |
中 |
中英文多语言 |
| text-embedding-3-small |
1536 |
API |
⭐⭐⭐⭐ |
API |
多语言,性价比高 |
| text-embedding-3-large |
3072 |
API |
⭐⭐⭐⭐⭐ |
API |
精度最高,成本较高 |
| paraphrase-multilingual-MiniLM |
384 |
120MB |
⭐⭐⭐ |
最快 |
50+语言通用 |
| all-MiniLM-L6-v2 |
384 |
90MB |
⭐⭐ |
最快 |
纯英文轻量 |
嵌入模型的选择原则
| 需求 |
推荐模型 |
| 中英文文本(高质量) |
OpenAI text-embedding-3-small |
| 中文文本(本地离线) |
BAAI/bge-large-zh-v1.5 |
| 中文文本(轻量离线) |
BAAI/bge-small-zh-v1.5 |
| 多语言通用 |
paraphrase-multilingual-MiniLM-L12-v2 |
| 图文多模态 |
OpenAI CLIP 系列 |
BGE 模型的特殊设计:指令感知
BGE 系列在训练时用了对比学习 + 指令微调:
1 2 3 4 5 6
| query = "为这个句子生成表示以用于检索相关文章:" + "计算机一班有多少人" doc = "学生总数: 2人,作业总数: 2份"
loss = contrastive_loss(query_embedding, pos_doc_embedding, neg_doc_embeddings)
|
所以使用时也要严格区分:
1 2 3 4 5 6 7
| embeddingService.embedQuery("计算机一班有多少人");
embeddingService.embedDocument("学生总数: 2人");
|
五、混合检索——为什么单路不够?
语义向量的盲区
| 盲区类型 |
例子 |
为什么失效 |
| 数字/代码 |
i%5==0, int a[5] |
模型没见过这些 token 组合 |
| 缩写 |
“CS2”, “JDK” |
低频 token,模型理解弱 |
| 精确匹配 |
人名、地名、产品型号 |
语义相近但不相等 |
关键词检索的盲区
1 2 3 4 5
| 用户: "怎么交换两个变量的值?" 文档: "t=x;x=y;y=t;"
关键词检索: ❌ 没命中(没有"交换"、"变量"这些词) 语义检索: ✅ 理解语义 → 命中
|
混合检索 = 取长补短
1 2 3 4 5 6 7 8 9
| 查询 ├── 语义路:理解"交换变量"≈"变量互换"≈"swap values" │ └── 结果: chunk_A, chunk_B, chunk_C (按余弦距离排序) │ ├── 关键词路:精确命中 "int a[5]", "i%5==0" │ └── 结果: chunk_D, chunk_C, chunk_E (按关键词命中数排序) │ └── RRF 融合 └── 最终: chunk_C(双路命中🥇), chunk_A, chunk_D, ...
|
RRF(Reciprocal Rank Fusion)原理
核心公式:
1 2 3 4 5
| RRF_score(chunk) = Σ ───────── k + rank_i
k = 60 (标准常数,Elasticsearch / Weaviate 都在用) rank_i = 该 chunk 在第 i 路检索中的排名
|
为什么用排名而不是原始分数?
1 2 3 4 5
| 语义分数: 0.85 (chunk_A), 0.12 (chunk_B) 关键词分数: 5 (chunk_C), 1 (chunk_A)
直接相加: 0.85+1 = 1.85 vs 0.12+? = 无意义! 排名融合: rank=1 和 rank=5 → 1/61 + 1/65 = 0.0318 ✅ 量纲统一
|
实现代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| Set<String> allChunkIds = new LinkedHashSet<>(); allChunkIds.addAll(semanticRanks.keySet()); allChunkIds.addAll(keywordRanks.keySet());
return allChunkIds.stream() .map(id -> { double rrfScore = 0.0; Double semRank = semanticRanks.get(id); Double kwRank = keywordRanks.get(id); if (semRank != null) rrfScore += 1.0 / (60 + semRank); if (kwRank != null) rrfScore += 1.0 / (60 + kwRank); return new RrfResult(id, rrfScore); }) .sorted((a, b) -> Double.compare(b.rrfScore, a.rrfScore)) .limit(topK) .toList();
|
Elasticsearch 8.x 内置了 RRF,开箱即用:
1 2 3 4 5 6 7 8 9 10 11 12
| { "retriever": { "rrf": { "retrievers": [ { "standard": { "query": { "match": { "content": "..." } } } }, { "knn": { "field": "embedding", "query_vector": [...], "k": 20 } } ], "rank_window_size": 20, "rank_constant": 60 } } }
|
六、Advanced RAG —— 进阶架构
基础架构(Naive RAG)常面临检索不准确、冗余信息多等问题。Advanced RAG 通过 预检索优化 → 检索融合 → 后检索优化 三段式架构予以解决。
1. 预检索:查询优化
查询改写(Query Rewriting)
用户的原始问题往往表达不够精确,用 LLM 改写为规范化的检索词:
1 2 3
| 原始提问: "那个一班的作业怎么样了" ↓ LLM 改写 改写后: "计算机一班 学生作业 完成情况 成绩统计"
|
改写后的查询包含更多精确的关键词,检索命中率大幅提升。
HyDE(假设文档嵌入)
这是目前最巧妙的预检索技术之一:
1 2 3 4 5
| 用户提问: "计算机一班有多少人" ↓ Step 1: LLM 盲猜一个答案(不需要是真的) 假设答案: "计算机一班共有30名学生,其中男生15人,女生15人" ↓ Step 2: 用假设答案生成向量去检索(而不是用原问题) 向量搜索 → 召回真实文档
|
为什么有效? LLM 生成的假设答案往往比原问题包含更多专业术语和完整的句式结构,与知识库中真实文档的向量分布更接近,检索命中率显著提升。
2. 检索融合:混合检索
已在第五章详述。将语义检索 + 关键词检索的结果通过 RRF 融合,在专有名词、代码片段等场景尤其重要。
3. 后检索:重排序(Reranking)
向量检索虽然快,但打分不够精确。重排序引入 Cross-Encoder 模型进行精排:
1 2 3 4 5
| 粗排阶段(快): bge-small-zh → Top-50 候选
精排阶段(精准): bge-reranker-v2-m3 → 50 个候选重新交叉打分 → Top-5
|
Cross-Encoder 将「问题」和「文档」成对输入模型进行联合推理打分,精度远高于向量距离,但运算量大,只负责精选 Top-N。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
candidates = vector_store.similarity_search(query, k=50)
pairs = [[query, doc.page_content] for doc in candidates] scores = reranker.predict(pairs)
ranked_docs = sorted(zip(scores, candidates), reverse=True) final_docs = [doc for _, doc in ranked_docs[:5]]
|
4. CRAG:修正式 RAG
CRAG(Corrective RAG) 在拿到检索结果后,先由 LLM 充当”评委”打分:
1 2 3 4 5
| 检索结果 ↓ LLM 评估质量 ├── 高质量 → 直接回答 ✅ ├── 模糊 → 补充 Web Search 🔍 └── 完全不相关 → 纯 Web Search 回答 🌐
|
如果本地知识库查无此文或质量极低,系统会自动触发 Web Search(如 Google API)作为补充,大幅降低幻觉。
5. GraphRAG:知识图谱 + 检索融合
传统 RAG 将知识库当作独立的文本碎片,无法回答跨文档、多跳推理的复杂问题。GraphRAG 引入知识图谱,将实体和关系显式建模:
1 2 3 4 5 6 7 8 9
| 传统 RAG: Q: "找到所有市值超千亿且由创始人担任 CEO 的公司" → 片段的文本检索无法完成多条件关联推理 → ❌
GraphRAG: Q: 同上 ├── 向量检索: 召回相关公司描述 ├── 图遍历: Neo4j 中查询 (公司)-[CEO]->(创始人) 路径 └── 融合生成: LLM 拿到文本片段 + 图结构 → 精准回答 ✅
|
核心步骤:
- 知识构建:离线阶段用 LLM 从文档提取三元组(主体、关系、客体),写入 Neo4j 等图数据库
- 双路检索:向量检索 + 图遍历
- 图文融合:文本片段 + 关系路径 → LLM
七、ONNX 模型部署
为什么需要 ONNX?
| 问题 |
说明 |
| 部署重 |
PyTorch 运行时 ≈ 2GB+,不适合容器 |
| 依赖多 |
Python + CUDA + PyTorch,环境地狱 |
| 跨平台难 |
Java 调 Python 要么走 HTTP 服务,延迟高 |
ONNX(Open Neural Network Exchange) 解决这些:
1 2 3 4 5
| PyTorch 模型 ↓ torch.onnx.export() model.onnx (单一文件,~90MB) ↓ ONNX Runtime (任何语言都能调) Java / C++ / Rust / Go / Node.js
|
八、评估与优化
RAGAS 评估框架
RAG 系统不能凭直觉评估,业界标准是 RAGAS 框架,从检索和生成两个维度自动化量化:
| 指标 |
衡量 |
说明 |
| Context Recall |
检索 |
标准答案中的信息有多少比例被检索到?越高越好 |
| Context Precision |
检索 |
检索到的文档中有多少比例是真正相关的?越高越好 |
| Faithfulness |
生成 |
生成的答案是否都有检索文档支撑?(检测幻觉) |
| Answer Relevance |
生成 |
答案是否真正回答了用户问题?(防止答非所问) |
性能优化技巧
| 技巧 |
做法 |
效果 |
| 批量插入 |
collection.add(documents=all_docs, ids=all_ids) |
10x+ 提升 |
| 向量归一化 |
入库前 L2 归一化,查询时用点积等价余弦 |
加速计算 |
| 合理 n_results |
RAG 场景 3~10 条足够,别设太大 |
减少 token 成本 |
| 元数据过滤 |
配合 where={"category":"tech"} 缩小搜索范围 |
提升精度 |
| 定期重建索引 |
数据量增长后适时重建 HNSW 索引 |
维持查询性能 |
1 2 3 4 5 6
| collection.add(documents=docs_list, ids=ids_list)
|
常见坑大全
| 坑 |
现象 |
解决 |
| 维度不匹配 |
pgvector expected 384, not 512 |
ALTER TABLE ... TYPE vector(512) |
| 嵌入模型不统一 |
插入和查询用了不同模型 |
在配置文件中固定模型版本 |
| 噪音淹没 |
99 个不相关 chunk 压住 1 个相关 chunk |
管好入库质量,删掉不相关文档 |
| Chunk 太小 |
检索到碎片,LLM 看不明白 |
增大 chunk + 加 overlap |
| Chunk 太大 |
语义被稀释,相似度不准 |
按段落或固定长度切分 |
| 模型未区分 query/doc |
检索精度比预期低 |
BGE 加 query 前缀 |
| 文本过长 |
模型 token 限制(512~8192) |
先分块再嵌入 |
| Java Text Block 空格 |
SQL 拼接 WHEREContent |
用 \s 保留行尾空格 |
| LIMIT 类型错误 |
Pg LIMIT 必需 bigint |
List<Object> 别用 String.valueOf() |
| 冷启动慢 |
数据量大时首次加载索引耗时 |
提前预热,使用持久索引 |
九、技术栈选择指南
1 2 3 4 5 6 7 8 9
| 你的需求: ├── 纯中文、小规模 → bge-small-zh + pgvector + RRF ✅ ├── 多语言、中等规模 → paraphrase-multilingual + Qdrant + RRF ├── 企业级、大规模 → bge-large-zh + Milvus + 重排序 ├── 不想管运维 → Pinecone / Zilliz Cloud + Cohere Rerank + GPT-4o ├── 需要专有名词匹配 → Elasticsearch / Weaviate(内置 BM25+向量混合) ├── 没 GPU、Java 项目 → ONNX Runtime + DJL ├── 有 GPU、Python 项目 → sentence-transformers + PyTorch └── 需要多跳推理 → Neo4j + GraphRAG
|
十、我们的实践
以「作业批改系统」为例:
架构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| QQ 消息 ↓ OpenClaw OneBot 插件(Node.js) ↓ POST /api/onebot/rag Spring Boot RAG Controller ↓ embedQuery() EmbeddingService (bge-small-zh-v1.5 / ONNX Runtime) ↓ ├── pgvector cosine search (语义路) ├── ILIKE keyword search (关键词路) └── RRF 融合 → Top-6 chunks ↓ 组装增强 prompt OpenClaw Agent (deepseek-v4-flash) ↓ 生成回复 QQ 消息
|
效果对比
| 阶段 |
嵌入方式 |
检索结果 |
| 初版 |
哈希伪向量 + 硬阈值 0.75 |
hasContext: false,什么都搜不到 |
| 中期 |
哈希伪向量 + RRF |
hasContext: true,能搜到但精度差 |
| 当前 |
bge-small-zh ONNX + RRF |
可精准命中仪表盘数据 |
总结
RAG 不是一个单一技术,而是一个技术栈:
1
| 文档切割 + 向量嵌入 + 向量数据库 + 混合检索 + Advanced RAG + LLM 生成
|
每个环节都有多种选择,根据实际场景权衡:
- 快速起步:pgvector + bge-small-zh ONNX + 简单切割 + RRF
- 追求精度:父子检索 + HyDE + 重排序 + 混合检索 + 更大的嵌入模型
- 企业级高可用:Milvus + bge-large-zh + 多路召回 + CRAG + 缓存层
- 复杂推理:Neo4j + GraphRAG + 文本检索双路融合
核心经验只有一条:知识库的干净程度比模型精度更重要。 100 个不相关的 chunk 足以毁掉最好的嵌入模型——管好入库质量,比死磕调参有效得多。
参考资源