一、RAG 基础概念

1. 什么是 RAG?


  • 定义:检索增强生成(Retrieval-Augmented Generation)通过结合外部知识检索与生成模型,提升回答的准确性和事实性,解决大模型幻觉问题。
  • 核心流程
    1. 检索:从外部知识库(如向量数据库、知识图谱)中查找相关文档;
    2. 生成:将检索结果作为上下文输入生成模型(如 GPT-4),生成最终回答。

2. 为什么需要 RAG?


  • 大模型的局限性:知识更新滞后、专业领域知识不足、易产生 “幻觉”。

  • RAG 的优势:动态知识注入、可解释性强、降低训练成本。

二、RAG 的工作流程

  1. 整体流程图

(图片来源 RAG 流程

  1. 待查找的资料(内部文档、数据库等资料)存入向量数据库
  2. 在数据库中查询最符合问题的资料
  3. 将资料和问题,一起交给大模型生成答案

召回符合用户预期的资料内容,合并问题,一起交给大模型,生成合理的答案。

  1. 详细解释

  2. 非结构化加载

上传的文档类型有 docs、PDF、数据库、xlsx 等,需要借助不同的文档加载器,将内部的内容提取出来

  1. 数据切片

在向量化的时候,输入的 token 数量是有限制的,通常的 500token 左右(也有专门长文本的 Embedding 模型), 我们从文档中召回内容的时候,也是以当前切割单元作为召回的基本单位

1
2
3
4
5
6
7
不同的 Embedding 模型对文本块长度的支持能力不同。比如,BERT 及其变体通常支持最多 512 个tokens,处理长文本时则需要将文本分成更小的块,意味着需要更加精细化的分块策略。而 Jina AI 的 Embedding 模型和 bge-m3 模型则支持 8K 的 tokens 输入,适合处理长文本块。通用 Embedding 模型在特定垂直领域(如医学、法律和金融等)可能不如专用模型有效。这些领域通常需要专门训练 Embedding 模型来捕捉特定的专业术语和语境。为特定业务需求优化的 Embedding 模型能够显著提升检索和生成的质量。例如,通过结合向量检索和重排序(reranking)技术,可以进一步优化结果。

//1. 不同的 Embedding 模型对文本块长度的支持能力不同
//2. 比如,BERT 及其变体通常支持最多 512 个tokens,处理长文本时则需要将文本分成更小的块,意味着需要更加精细化的分块策略
//3. 而 Jina AI 的 Embedding 模型和 bge-m3 模型则支持 8K 的 tokens 输入,适合处理长文本块。通用 Embedding 模型在特定垂直领域(如医学、法律和金融等)可能不如专用模型有效
//4. 这些领域通常需要专门训练 Embedding 模型来捕捉特定的专业术语和语境
//5. 为特定业务需求优化的 Embedding 模型能够显著提升检索和生成的质量。例如,通过结合向量检索和重排序(reranking)技术,可以进一步优化结果。

不同的数据切片方式,对召回有着关键的影响,不同领域,不同场景切片方案也不一样

方案一: 固定大小分块

(图片来源 RAG 中的 5 种文档切分策略

按预定义的字符数、单词数或 Token 数量对文本进行切分,同时保留一定的重叠部分。

这种方法实现简单,但可能会将句子截断,从而导致信息分散在不同的块中。

方案二:语义分块

(图片来源 RAG 中的 5 种文档切分策略

使用 NLP 模型(如 BERT、Sentence-BERT、RoBERTa)计算文本中句子或段落的语义嵌入(Embedding),通过向量相似度(如余弦相似度)检测语义连贯性变化点。若相邻段落的相似度低于阈值,则视为分块边界。

优势

  1. 保留上下文完整性
    • 避免固定长度分块导致的上下文断裂(如拆散核心论点),提升检索内容的相关性。
  2. 提升检索精度
    • 语义连贯的分块更可能匹配用户查询意图,减少噪声干扰。

局限性

  1. 计算成本高
    • 语义分析依赖深度学习模型,处理长文档时可能增加延迟和资源消耗。
  2. 依赖模型质量
    • 若语义模型在特定领域(如法律、医学)表现不佳,分块准确性会下降。
  3. 处理复杂文本的挑战
    • 对多语言、口语化或非结构化文本(如社交媒体内容)的分块效果可能不稳定。

方案三:递归分块

(图片来源 RAG 中的 5 种文档切分策略

基于内在分隔符(如段落或章节)进行分块。

如果某个块的大小超过限制,则将其进一步分割为更小的块。

这种方法能够保持语言的自然流畅性。

方案四:基于文档结构的分块

(图片来源 RAG 中的 5 种文档切分策略

利用文档的内在结构(如标题、章节或段落)进行分块。

这种方法能够保持文档的自然结构,但前提是文档具有清晰的结构。

方案五:基于 LLM 的分块

(图片来源 RAG 中的 5 种文档切分策略

  1. 存储和查询

向量化(Vectorization)

向量化是将文本(如单词、句子或文档)转换为数值向量(即嵌入向量)的过程。

嵌入环节的目标是将文本转换为高维向量,使语义相似的文本在向量空间中距离更近。

索引(Indexing)

索引是构建高效数据结构以存储和检索向量化数据的过程。在 RAG 中,通常使用向量索引(如 FAISS、Annoy)加速相似性搜索

3.1 向量化 (嵌入):

流程:

Embedding(嵌入):也就是向量化,是有专门的模型来处理的,使用预训练模型(如 BERT、Sentence-BERT、RoBERTa)生成文本的向量表示 (https://ollama.com/search)

示例流程

  1. 输入文本:“什么是机器学习?”
  2. 通过 BERT 模型生成一个 768 维的向量(如 [0.23, -0.45, …, 0.67])。

(向量的维度越高,越精确,但是消耗的计算资源越大)

相似度计算:

(图片来源 Embedding 模型的选择和微调

余弦相似度(Cosine Similarity):衡量两个向量在向量空间中方向一致性的指标,广泛应用于自然语言处理(NLP)、推荐系统、图像检索等领域。它通过计算两个向量夹角的余弦值来反映它们的相似性,核心思想是忽略向量长度,关注方向差异

举个例子:

老铁很 6(坐标:[100,100,0])

东北和华北以及南部地区的老铁们,普遍反馈都很 6(坐标:[399,398,9])

通过计算 老铁很 6 和 东北和华北以及南部地区的老铁们,普遍反馈都很 6   这两句话在相似度上非常接近,因此搜索老铁很 6 的时候东北和华北以及南部地区的老铁们,普遍反馈都很 6  会被召回。

与其他相似度指标的对比
指标公式特点适用场景
余弦相似度(\frac{A \cdot B}{|A| |B|})忽略长度,关注方向;适合高维、长度不敏感场景NLP、推荐系统、嵌入模型检索
欧氏距离(\sqrt{\sum (a_i - b_i)^2})反映向量绝对距离;对长度敏感低维稠密向量(如图像像素)
点积(Dot Product)(A \cdot B)受向量长度影响大;未归一化时可能放大长向量的相似性向量已归一化时等价于余弦相似度
曼哈顿距离(\sum |a_i - b_i|)对异常值敏感;计算效率高路径规划、稀疏特征

3.2 索引:

快速检索与用户问题相关的知识库文档

索引构建使用的库 FAISS 和 Pinecone

特性FAISSPinecone
类型开源库(Meta 开发)托管服务(商业产品)
部署方式本地部署,需自行管理云端托管,无需基础设施维护
扩展性需手动分片和扩展自动水平扩展(根据负载动态调整)
持久化存储需自行处理(如保存到磁盘)内置持久化存储,自动备份
多租户支持不支持支持(企业级权限管理)
易用性需要编码实现索引构建和查询REST API/SDK 开箱即用
成本免费按用量付费(存储、查询次数)
适用场景中小规模数据、本地 / 实验环境大规模生产环境、需快速上线的项目

3.3 问答:

用户问题 –> 向量化 –> 向量索引 –> 检索 Top-K 相关文档 –> 输入 LLM 生成答案

  1. 简单的案例

实现从多段内容中,召回符合预期的内容 (faiss_example.py)

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
import faiss
from sentence_transformers import SentenceTransformer

# --- 1. 向量化 ---
model = SentenceTransformer('all-MiniLM-L6-v2')
texts = ["我们团队是一个比较厉害的技术团队", "都是爱吃肉的人,愿意一起吃肉", "科学家们都是爱学问的人"]
# 创建一个字典来存储文档内容
document_dict = {i: doc for i, doc in enumerate(texts)}
vectors = model.encode(texts).astype('float32') # 输出形状: (3, 384)

# --- 2. 构建索引 ---
dim = vectors.shape[1]
index = faiss.IndexFlatL2(dim) # 精确搜索
index.add(vectors)

# --- 3. 查询相似文档 ---
def query_similar_documents(query):
query_vector = model.encode([query]).astype('float32') # 向量化查询
k = 2 # 返回最相似的前k个文档
distances, indices = index.search(query_vector, k)
print("最相似的索引:", indices[0]) # 输出: [1, 0]
print("距离:", distances[0])

# 获取具体的文档内容
results = {index: document_dict[index] for index in indices[0]}
print("相关文本:", results)

# 测试查询
query_similar_documents("本地生活客户端")

三、本地问答知识库案例

  1. 要求:

  2. 支持上传 PDF、csv 等多种类型的文档

  3. 使用 streamlit 进行可视化界面操作

  4. 使用 deepseek-r1 模型进行问题

  5. 步骤:

    1. 本地下载 Ollama
1
2
3
4
# 直接点击Mac下载并安装
https://ollama.com/search# 直接点击Mac下载并安装
https://ollama.com/search# 直接点击Mac下载并安装
https://ollama.com/search下载deepseek-r1模型并运行
  1. 下载 deepseek-r1 模型并运行
1
2
3
4
# 下载 deepseek-r1模型,默认应该是7B,大了电脑也带不起来(4G左右)
ollama pull deepseek-r1
# 使用ollama 启动deepseek-r1 (本地可以直接问题)
ollama run deepseek-r1
  1. 需要下载的 Python 库
1
2
3
4
5
6
7
8
9
10
11
12
13
pip install openpyxl
pip install -U langchain langchain-community
pip install langchain
pip install langchain_experimental
pip install streamlit
pip install pdfplumber
pip install semantic-chunkers
pip install open-text-embeddings
pip install ollama
pip install prompt-template
pip install sentence-transformers
pip install faiss
pip install faiss-cpu
  1. 编写并启动文档能力(文档上传、向量化、索引、问题召回)
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
import streamlit as st
from langchain_community.document_loaders import PDFPlumberLoader, CSVLoader
from langchain_experimental.text_splitter import SemanticChunker
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_community.llms import Ollama
from langchain.prompts import PromptTemplate
from langchain.chains.llm import LLMChain
from langchain.chains.combine_documents.stuff import StuffDocumentsChain
from langchain.chains import RetrievalQA

# Color palette
primary_color = "#1E90FF"
secondary_color = "#FF6347"
background_color = "#F5F5F5"
text_color = "#4561e9"

# Custom CSS
st.markdown(f"""
<style>
.stApp {{
background-color: {background_color};
color: {text_color};
}}
.stButton>button {{
background-color: {primary_color};
color: white;
border-radius: 5px;
border: none;
padding: 10px 20px;
font-size: 16px;
}}
.stTextInput>div>div>input {{
border: 2px solid {primary_color};
border-radius: 5px;
padding: 10px;
font-size: 16px;
}}
.stFileUploader>div>div>div>button {{
background-color: {secondary_color};
color: white;
border-radius: 5px;
border: none;
padding: 10px 20px;
font-size: 16px;
}}
</style>
""", unsafe_allow_html=True)

# Streamlit app title
st.title("Build a RAG System with DeepSeek R1 & Ollama")

# Initialize an empty list to store documents
documents = []

# Load the files
uploaded_files = st.file_uploader("Upload PDF or CSV files", type=["pdf", "csv"], accept_multiple_files=True)

if uploaded_files:
for uploaded_file in uploaded_files:
# Save the uploaded file to a temporary location
with open(uploaded_file.name, "wb") as f:
f.write(uploaded_file.getvalue())

# Load the file based on its type
if uploaded_file.type == "application/pdf":
loader = PDFPlumberLoader(uploaded_file.name)
docs = loader.load()
elif uploaded_file.type == "text/csv":
loader = CSVLoader(uploaded_file.name) # Load CSV files
docs = loader.load()

documents.extend(docs) # Add loaded documents to the list

# Split into chunks only if documents are available
if documents:
text_splitter = SemanticChunker(HuggingFaceEmbeddings())
documents = text_splitter.split_documents(documents)

# Instantiate the embedding model
embedder = HuggingFaceEmbeddings()

# Create the vector store and fill it with embeddings
vector = FAISS.from_documents(documents, embedder)
retriever = vector.as_retriever(search_type="similarity", search_kwargs={"k": 3})

# Define llm
llm = Ollama(model="deepseek-r1")

# Define the prompt for QA
prompt = """
1. Use the following pieces of context to answer the question at the end.
2. If you don't know the answer, just say that "I don't know" but don't make up an answer on your own.\n
3. Keep the answer crisp and limited to 3,4 sentences.
Context: {context}
Question: {question}
Helpful Answer:"""

QA_CHAIN_PROMPT = PromptTemplate.from_template(prompt)

llm_chain = LLMChain(
llm=llm,
prompt=QA_CHAIN_PROMPT,
callbacks=None,
verbose=True)

document_prompt = PromptTemplate(
input_variables=["page_content", "source"],
template="Context:\ncontent:{page_content}\nsource:{source}",
)

combine_documents_chain = StuffDocumentsChain(
llm_chain=llm_chain,
document_variable_,
document_prompt=document_prompt,
callbacks=None)

qa = RetrievalQA(
combine_documents_chain=combine_documents_chain,
verbose=True,
retriever=retriever,
return_source_documents=True)

# User input
user_input = st.text_input("Ask a question related to the uploaded documents:")

# Process user input
if user_input:
with st.spinner("Processing..."):
response = qa(user_input)["result"]
st.write("Response:")
st.write(response)
else:
st.write("Please upload PDF or CSV files to proceed.")
  1. 使用 streamlit 进行启动
1
treamlit run xxxx.py

成功后可以进行问答:

四、Query Translation 提高召回能力

我们不能指望所有的用户都能清楚地描述自己的 Query,如果用户写了一句模棱两可的 Query 或者是一个复杂的 Query,那么检索到的文档也将是模棱两可或者难以准确检索,进而导致 LLM 的回答就不准确

方案一:Re-written

Re-written 是指对原始查询进行语义重写,保持核心意思不变,但调整语言表述,使得问题更易被知识库或检索系统处理。

我们将原始 Question 改写成三种表述方式 Q1、Q2 和 Q3,然后分别检索与这三种问题相关的文档:

示例:本地生活是什么?

改写:

  1. 本地生活是一个行业,他主要运营方向是什么,根据美团、快手等本地生活业务,介绍本地生活是什么?
  2. 本地生活按照业务解释是什么?根据行业解读应该是什么样子?

(图片来源查询翻译

**方案二:Decomposition(**分解)

也就是 sub-question,是将复杂的问题拆解为多个独立的子问题,每个子问题可以单独处理。最终通过聚合子问题的答案来生成完整的回答。

应用场景:

多跳问题(需要跨多个知识点回答)。

包含逻辑操作或条件限制的问题。

示例:

原始问题:E14 和 E6 的职级区别是什么?

Sub-question:

  1. E14 的职级要求是什么?
  2. E6 的直接要求是什么?
  3. 根据召回的 E14 和 E6 的职级要求,生成两者之间的差别

(串行拆分)(图片来源查询翻译

(并行拆分)(图片来源查询翻译

方案三:Step-back Question

在检索和回答过程中,当系统意识到当前的问题太具体或难以直接回答时,退一步改问一个更广泛、更概括性的问题,从而获取更大的上下文信息。

  1. 第一步:抽象(Abstraction):在这一步,问题的基础原理和概念先被提取出来。例如,面对理化问题,先问 “解决这个任务涉及哪些物理或化学原理和概念?”,从而让模型先确定这些原理和概念。

  2. 第二步:推理(Reasoning):有了基础原理后,再进行问题的解答。例如,根据气体定律来计算压力如何变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
原题:如果一辆汽车以100公里/小时的速度行驶,行驶200公里,需要多长时间?
选项:
1.4小时
2.2小时
3.1小时
4.3小时

原答案[不正确]:正确答案是4)3小时。

后退提示:给定速度和距离,计算时间的基本公式是什么?
原则:为了计算时间,我们使用以下公式:时间=距离/速度
使用公式,时间=200公里/100公里/小时=2小时。
修正后答案[正确]:正确答案是2)2小时。

方案四:HyDE(Hypothetical Document Embedding)(基于假设)

根据问答,生成一个假设性回答,再拿着假设性答案,再次去搜索文档。

  1. 通过假设性文档生成和后续的向量相比,HyDE 能够捕捉查询深层意图和复杂内容,不仅限于关键词匹配。

示例:

1
2
3
4
5
当我们搜索“感冒的治疗手段”
这个时候通过RAG召回的信息有限。

先生成假设性答案:
包含治疗手段、药物治疗、物理治疗、手术治疗、不同医院治疗方案等(即便这些信息在文档实际数据中并不存在),然后寻找与假设文档相似的实际文档。

五、当前比较流行的 RAG 概念还有哪些

  1. Agentic RAG

(图片来源传统 RAG vs. Agentic RAG:动态图示清晰解析

  1. 资料、文档嵌入
  2. 问题嵌入,并进行知识库搜索
  3. 将问题和搜索的内容,一并提交给大模型生成答案

(图片来源传统 RAG vs. Agentic RAG:动态图示清晰解析

第 1-2 步)Agent 会重写查询(如纠正拼写错误等)。

第 3-8 步)Agent 决定是否需要更多上下文信息:

如果不需要,重写后的查询直接发送给 LLM。

如果需要,智能代理会找到最佳的外部来源以获取上下文,并将其传递给 LLM。

第 9 步)系统生成响应。

第 10-12 步)智能代理检查答案是否相关:

如果相关,则返回响应。

如果不相关,则返回第 1 步重新开始。