LLM 有三个内生缺陷,RAG 是最主流的解法:
RAG 的核心思路:不是让 LLM「记住」所有知识,而是「用时查阅」。把知识存在外部数据库,用户提问时先检索最相关的片段,再把这些片段塞进 prompt 让 LLM 基于它们回答。
Embedding 模型把一段文字(句子、段落)映射成一个高维向量(如 1536 维),语义相近的文字在向量空间里距离也近。这是整个 RAG 的基础。
1. 为什么向量能表达语义?
2. 常用 Embedding 模型
| 模型 | 维度 | 特点 | 适合场景 |
|---|---|---|---|
| text-embedding-3-small OpenAI | 1536 | 中英文均衡,API 直接调用 | 通用场景,快速上手 |
| BGE-large-zh BAAI 开源 | 1024 | 中文最强之一,可本地部署 | 中文文档检索 |
| Sentence-BERT HuggingFace | 768 | 英文表现好,轻量 | 英文语料、低资源环境 |
| M3E MokaAI 开源 | 768 | 中英双语,免费 | 中英混合文档 |
3. Embedding 的本质:对比学习训练
Embedding 模型通过对比学习训练:给定一对正样本(语义相似的句子对),让它们的向量靠近;给定负样本,让向量远离。训练完成后,模型就学会了把「语义」编码进向量距离。
正样本对: (「今天天气如何?」, 「北京今天晴,25°C」) → 相似度最大化
负样本对: (「今天天气如何?」, 「Python 如何排序列表?」) → 相似度最小化
# 推理时:把查询和文档都变成向量,计算余弦相似度
similarity = cosine(embed("用户问题"), embed("文档片段"))
文档通常很长,直接向量化整篇文章有两个问题:① 超出 Embedding 模型最大输入长度;② 一个向量混合了太多信息,检索精度下降。Chunking 就是把文档切成合适大小的片段。
1. 五种 Chunking 策略对比
| 策略 | 方法 | 优点 | 缺点 |
|---|---|---|---|
| 固定长度切割 | 每 512 token 切一段 | 简单快速 | 可能从句子中间断开,语义不完整 |
| 重叠滑窗切割 | 512 token,重叠 64 token | 缓解边界截断问题 | 重复存储,索引变大 |
| 句子/段落边界切割 | 按「。」「\n\n」等自然边界切 | 语义完整 | 长度不均匀 |
| 递归字符切割 LangChain 默认 |
按 ['\n\n', '\n', '. ', ' '] 优先级递归切 | 兼顾长度和语义边界 | 实现略复杂 |
| 语义切割 ⭐ | 用 Embedding 相似度检测「语义断点」 | 最贴近自然段落 | 慢,需要额外计算 |
2. Chunk 大小的选择
检索到的是孤立句子。
大多数场景的最优区间。
塞入 prompt 占用太多 token。
3. Parent-Child Chunk 策略(进阶)
存储时用小 chunk(128 token),检索时返回其对应的大 chunk(512 token)作为上下文。这样检索精度高(小 chunk 语义纯净),LLM 上下文也充足(大 chunk 信息完整)。
数据库里存了几百万个向量,用户来一个查询向量,怎么快速找到最相似的 Top-K 个?暴力遍历太慢(O(N×D)),需要专门的近似最近邻(ANN)索引算法。
1. 暴力搜索的瓶颈
100 万个 1536 维向量,暴力遍历每次需要计算 100 万次余弦相似度,延迟不可接受。ANN 算法用空间换时间,接受「可能漏掉极少数真正最近邻」来换取 10~100 倍加速。
2. IVF(倒排文件索引)——FAISS 的核心
IVF 的思路:先用 K-means 把所有向量聚成 N 个簇,每个向量属于离它最近的簇。查询时:先找查询向量所属的最近几个簇,只在这些簇里做暴力搜索。
参数 nlist(簇数):越大精度越高但建索引越慢。nprobe(查询时探索的簇数):越大召回越高但查询越慢。一般设 nlist=1024, nprobe=16~64。
3. HNSW(分层可导航小世界图)——速度和精度最均衡
HNSW 把向量组织成一个多层图结构:上层是稀疏的「高速公路」,下层是密集的「普通道路」。查询时从上层快速定位大致区域,再逐层向下精确搜索。
4. 常用向量数据库对比
| 工具 | 类型 | 优点 | 适合 |
|---|---|---|---|
| FAISS | 本地库 | 速度极快,支持 GPU 加速,Meta 出品 | 研究、离线批量检索 |
| Chroma | 轻量 DB | 零配置,内嵌 Python,开发友好 | 原型开发、小规模 |
| Qdrant | 向量 DB | Rust 实现,高性能,支持 payload 过滤 | 生产环境,中大规模 |
| Milvus | 分布式 DB | 水平扩展,支持亿级向量 | 大规模生产部署 |
| Pinecone | 云服务 | 全托管,无需运维 | 快速上线,预算充足 |
1. Dense Retrieval(稠密检索)
就是上面讲的:用 Embedding 模型把查询和文档都变成向量,计算相似度。擅长语义理解,能处理换个说法但意思一样的情况。弱点:对专有名词(如 SKU ID、型号)不敏感——「iPhone 15 Pro Max」和「iPhone 15」的向量可能很近。
2. Sparse Retrieval(稀疏检索)
以 BM25 为代表,基于词频统计(TF-IDF 思想)。擅长精确词匹配,对专有名词、型号、代码变量名很敏感。弱点:不理解语义——「汽车」和「轿车」是两个词,BM25 认为它们不相关。
score(q, d) = Σ IDF(qi) × tf(qi, d) × (k1 + 1) / (tf(qi, d) + k1 × (1-b+b×|d|/avgdl))
# IDF:词越稀有,权重越高
# tf:词频,但用平滑处理避免词频过大的文档占优
# |d|/avgdl:文档长度归一化
3. Hybrid Retrieval(混合检索)⭐ 推荐
同时运行 Dense 和 Sparse,把两路结果合并。常用合并方式是 RRF(Reciprocal Rank Fusion,倒数排名融合):
RRF_score(d) = 1 / (rank_dense(d) + 60) + 1 / (rank_sparse(d) + 60)
# 举例:
文档A:Dense 排名第1, Sparse 排名第3 → 1/61 + 1/63 ≈ 0.0323
文档B:Dense 排名第2, Sparse 排名第1 → 1/62 + 1/61 ≈ 0.0325
# 文档B 综合得分更高
向量检索召回 Top-50,但真正相关的可能只有 3~5 篇。Reranker 对候选结果做精排,用更强的模型重新打分,确保最终传给 LLM 的文档质量最高。
1. 为什么需要两阶段?
方法:向量检索,快(毫秒级),返回 Top-50~100
缺点:精度不够,有噪声
方法:Cross-Encoder 对每个(问题, 文档)对打分
缺点:慢(对每对都要做推理),只适合对小候选集做
2. Cross-Encoder vs Bi-Encoder
| Bi-Encoder(向量检索用) | Cross-Encoder(Reranker 用) | |
|---|---|---|
| 输入 | 问题和文档分别编码 | 问题 + 文档一起输入 |
| 速度 | 快(向量可预计算) | 慢(每对都要推理) |
| 精度 | 中等(无法建模交互) | 高(能建模问题和文档的细粒度交互) |
| 典型模型 | BGE, text-embedding-3 | BGE-Reranker, Cohere Rerank |
3. Reranker 的工作流
离线阶段(一次性)
docs = load_documents("./knowledge_base/") # PDF, Word, Markdown...
# 2. 切块
chunks = RecursiveCharacterTextSplitter(chunk_size=512, overlap=64).split(docs)
# 3. 向量化
embeddings = embed_model.encode([c.text for c in chunks]) # shape: (N, 1536)
# 4. 存入向量库
vector_db.upsert(ids=[c.id for c in chunks], vectors=embeddings, payloads=chunks)
在线阶段(每次查询)
query_vec = embed_model.encode(user_query) # shape: (1536,)
# 2. 向量检索(可选:加 BM25 混合)
candidates = vector_db.search(query_vec, top_k=50)
# 3. Reranker 精排
reranked = reranker.rank(query=user_query, docs=candidates, top_k=5)
# 4. 拼接 Prompt
prompt = f"""基于以下文档回答问题:
{'\n'.join([d.text for d in reranked])}
问题:{user_query}"""
# 5. LLM 生成
answer = llm.generate(prompt)
1. Query 改写(Query Rewriting)
用户问题有时很模糊,直接检索效果差。用 LLM 先把问题改写成更适合检索的形式:
| 技术 | 做法 | 效果 |
|---|---|---|
| HyDE 假设文档嵌入 | 让 LLM 先生成一个假想答案,用假想答案的向量去检索 | 检索向量更接近文档空间 |
| Multi-Query | 让 LLM 把问题改写成 3~5 个变体,分别检索后合并 | 召回率显著提升 |
| Step-Back Prompting | 先让 LLM 提炼出「这个问题背后的抽象概念」,用抽象概念检索 | 对需要背景知识的问题效果好 |
2. 元数据过滤(Metadata Filtering)
给每个 chunk 打上元数据标签(作者、时间、文档类型、产品线),检索时先用 WHERE 条件过滤,再做向量搜索。大幅提升精准度,避免跨领域干扰。
results = vector_db.search(
query_vec,
filter={"doc_type": "产品手册", "year": {"$gte": 2024}},
top_k=20
)
3. Self-RAG(检索判断)
不是每个问题都需要检索。Self-RAG 让 LLM 先判断「这个问题需要外部知识吗?」,只有需要时才触发检索,减少不必要的延迟和噪声。
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 检索到的文档不相关 | Chunk 太大/太小,或问题太短 | 调整 chunk size;用 Query 改写扩充问题 |
| 答案正确但没引用来源 | prompt 没要求 LLM 标注 | 在 prompt 里明确要求「请注明引用自第几段」 |
| 检索到正确文档但答案还是错 | 文档里的答案在 chunk 边界被截断 | 增加 chunk overlap;或用 Parent-Child 策略 |
| 同一知识库不同版本冲突 | 旧文档没及时更新 | 加 metadata 版本号 + 过滤旧版本 |
| 中文检索精度差 | 通用 Embedding 模型中文表现不好 | 换成 BGE-large-zh 或 M3E-large |
| 第一次检索就没发现问题 | 没有评估指标 | 用 RAGAS 框架评估 Faithfulness、Answer Relevancy |
自动化评估 RAG 系统质量,核心指标:
• Faithfulness:答案是否只来自检索到的文档(防幻觉)
• Answer Relevancy:答案是否回答了用户的问题
• Context Precision:检索到的文档是否都相关
• Context Recall:相关文档是否都被检索到了