为什么有RAG技术

我们先来看一下当前AI大模型和搜索引擎会有什么问题:

  1. AI善于总结知识和语义理解,但是知识有限,它的训练还是有截止日期的
  2. 搜索引擎时效性很好,但是信息分散,对语义理解困难
  3. AI结合搜索引擎可以弥补一部分时效性问题,然而对于本地知识和内网资料等非公开资料难以获取

因此我们需要文档加载和检索技术来增强AI的生成能力。

这样的技术,便是我们所要技术的RAG技术

RAG流程

我们来了解一下RAG的流程,之后我们再一步步介绍

  • 文档加载 (Document Loading):加载多种不同来源加载文档。LangChain 提供了 100 多种不同的
    文档加载器,包括 PDF 在内的非结构化的数据、SQL 在内的结构化的数据,以及 Python、Java
    之类的代码等。
  • 文本分割 (Splitting):文本分割器按一定规则把 Documents 切分为指定大小的块。
  • 存储 (Storage):存储涉及到两个环节,分别是:
    • 向量转换:将切分好的文档块进行嵌入(Embedding),即将文档块转换成向量的形式。
    • 向量存储:将 Embedding 后的向量数据,存储到向量数据库中。
  • 检索 (Retrieval):数据存入向量数据库后。当我们需要进行数据检索时,会通过某种检索算法找到与输入问题相似的文档块。
  • 输出 (Output):把问题以及检索出来的文档块一起提交给 LLM,LLM 会通过问题和检索出来的提示一起来生成更加合理的答案

1.文档加载器

首先我们从第一步开始,也就是文档加载器,首先认识下文档类

文档类

在langchain中使用langchain_core.documents.base.Document表示文档类,通常情况下,一个文档对象表示一个较大文档的一部分内容,描述被切分出来的文本块,每个文档类有以下几个关键参数/属性/成员

  • id :可选的文档标识符。理想情况下,这应该在整个文档集合中是唯一的,并格式化为UUID,但不会强制执行。
  • page_content :字符串文本
  • metadata :与内容关联的任意元数据。类型为 dict [Optional]

加载pdf文档

这里用加载pdf文档举例,他来自from langchain_community.document_loaders.pdf import PyPDFLoader,我们来看看接口

  • __init()__,传入位置形参file_path
  • load() → list[Document]:将文件内容载入到文档列表中

我从网上找了一个在gitee开源的markdown文件,转换成了pdf文档,我们来把它载入,并打印一些数据看看

1
2
3
4
5
6
7
8
9
10
11
from langchain_community.document_loaders.pdf import PyPDFLoader

file_path = "./docs/面经.pdf"
loader = PyPDFLoader(file_path)

docs = loader.load()

print("第一页的元数据")
print(docs[0].metadata)
print("第一页的内容")
print(docs[0].page_content)
1
2
3
4
5
6
7
8
9
10
D:\program_software\AnaConda\envs\langchain313\python.exe D:\codes\code_pycharm\langChainTool\document.py 
第一页的元数据
{'producer': 'Aspose.Words for .NET 25.7.0', 'creator': 'PyPDF', 'creationdate': '', 'source': './docs/面经.pdf', 'total_pages': 13, 'page': 0, 'page_label': '1'}
第一页的内容
项目
1.
grpc是基于http的流传输有包大小限制,grpc为什么能传输突破这个限制大小的

流式传输、分帧、编码压缩
....

可以看到,打印出的元数据也包含了不少内容

加载md文档

相比于pdf有明显的一页一页的分页,markdown则是一个较为完整的一大份块文本,我们来看看它是怎么加载成文档的,相应的文档加载器名字也有了变化,有了个无结构unstructed的描述,它的init()参数多了一个mode,用于控制分页方式:

  • single:(默认)将一整个文档作为文档对象返回
  • elements: 会将整个文档按Titile和NarrativeText等不同类型的元素。

我们来对比下两种模式的差别

1
2
3
4
5
6
7
8
9
10
11
from langchain_community.document_loaders.markdown import UnstructuredMarkdownLoader

file_path = "./docs/面经.md"
loader = UnstructuredMarkdownLoader(file_path, mode="element")

docs = loader.load()

print("查看页数")
print(len(docs))
print("第一页的元数据")
print(docs[0].metadata)

single模式

1
2
3
4
5
6
查看页数
1
第一页的元数据
{'source': './docs/面经.md'}

Process finished with exit code 0

element模式

1
2
3
4
查看页数
105
第一页的元数据
{'source': './docs/面经.md', 'category_depth': 3, 'languages': ['zho'], 'file_directory': './docs', 'filename': '面经.md', 'filetype': 'text/markdown', 'last_modified': '2024-09-15T16:28:31', 'category': 'Title', 'element_id': '07ecec887623ab3b15dd46e3bcaec62b'}

2.文档分割器

文档加载器直接加载进来的文档有时候太大了,为了保证每一个文档对象都足够小到易于被管理和搜索,我们需要用到文本分割器。毕竟大块文本更难搜索且不适合上下文有窗口限制的LLM,拆分之后才能利于搜索和精确匹配。

基于文档长度分割

其实这种分割方式还能细分成两种:基于字符拆长度拆分和基于Token长度拆分,前面介绍切割消息列表的时候已经介绍过了,我们在这里就不细说区别了

我们要用到的组件来自from langchain_text_splitters import CharacterTextSplitter

它有如下参数:

  • separator:分隔符
  • chunk_size: 块大小
  • chunk_overlap: 块重叠大小,即分割后的文档会重叠多少
  • length_function:长度计算函数,如果是len,就是按字符长度拆分
  • is_separator_regex:分隔符是否是正则表达式

我们来分一波文档看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from langchain_community.document_loaders.markdown import UnstructuredMarkdownLoader
from langchain_text_splitters import CharacterTextSplitter

file_path = "./docs/面经.md"
loader = UnstructuredMarkdownLoader(file_path)

docs = loader.load()

text_spliter = CharacterTextSplitter(
separator="\n\n" , # 选择分隔符
chunk_size=100,
chunk_overlap=20,
length_function=len,
is_separator_regex=False
)

docs = text_spliter.split_documents(docs)

for doc in docs[:10]:
print('*'*10)
print(doc)

前几行输出如下

为什么会爆红呢?因为langchain为了保证语义完整性,有时会创建超过规定长度的文档块,这是在预期行为范围内的,不用担心。

如果想减少爆红,我们可以把chunk_size改大一点,这样就会减少爆红了

基于Token长度分割

这里还有一种分割方法,就是使用tiktoken分词器来拆分,示例代码如下,就是改了下分词器的获取方式,使用了from_tiktoken_encoder()方法

1
2
3
text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
encoding_name="cl100k_base", chunk_size=200, chunk_overlap=50
)

硬性约束长度拆分

如果我们想硬性约束块大小,我们可以用from langchain_text_splitters import RecursiveCharacterTextSplitter,或者RecursiveCharacterTextSplitter.from_tiktoken_encoder

特殊文档结构拆分

像是代码文本(Python,C++…)以及Json、markdown这些有特殊结构的文档,可以用特定的分割器进行更好地分割

3.文本向量

在完成分割文档的任务后,我们就该着手解决文本向量化的事了

说明:我们之前一直用的大语言模型是生成式模型。它理解输入并生成新的文本(回答问题、写文章)。它内部实际上也使用嵌入技术来理解输入,但最终目标是“创造”。而嵌入模型(Embedding Models)是表示型模型。它的目标不是生成文本,而是为输入的文本创建一个最佳的、富含语义的数值表示(向量)。

特别的,在这些向量组成的数学上的向量空间里,有两个特别有意义的参数:

• 欧氏距离(Euclidean Distance):就是我们高中几何学的两点之间的直线距离。距离越短,相似度越高。
• 余弦相似度(Cosine Similarity):它忽略向量的绝对长度(大小),只关注两个向量在方向上的差异。在文本和语义的世界里,“方向”代表“含义”,而“长度”往往只代表“文本的长度”或“词汇的多少”。换句话说,余弦相似度关注的是 “你们是否指向同一个方向” / “你们是否代表同一个含义”

嵌入文本向量

我们来实践一下把文档转换为向量,这次我们依然要用到嵌入模型,这里用的是免费的from langchain_huggingface import HuggingFaceEmbeddings,不同的是我们要使用它的方法接口:

  • .embed_documents() : 用于处理文档 Documents 。它的输入是字符串列表。例如要将一个知识库里的所有段落都转换成向量后存入数据库,就会使用这个方法。
  • .embed_query() : 用于处理查询 Query 。它的输入是单个文本(一个字符串,str)。例如,当用户提出一个问题时,需要将这个问题转换成向量
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
from langchain_community.document_loaders.markdown import UnstructuredMarkdownLoader
from langchain_text_splitters import CharacterTextSplitter
from langchain_huggingface import HuggingFaceEmbeddings


file_path = "./docs/面经.md"
loader = UnstructuredMarkdownLoader(file_path)

docs = loader.load()

text_spliter = CharacterTextSplitter(
separator="\n\n" , # 选择分隔符
chunk_size=100,
chunk_overlap=20,
length_function=len,
is_separator_regex=False
)

docs = text_spliter.split_documents(docs)

embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-mpnet-base-v2")

# 嵌入文档列表,生成向量列表
# 注意这里需要提取文档内容为字符串列表,才能传递给嵌入模型
str_arr = [doc.page_content for doc in docs]
doc_vector = embeddings.embed_documents(str_arr)

print(f"文档数量为:{len(docs)},生成了{len(doc_vector)}个向量的列表")
print(f"第一个文档向量维度:{len(doc_vector[0])}")
print(f"第二个文档向量维度:{len(doc_vector[1])}")

部分输出如下

1
2
3
文档数量为:99,生成了99个向量的列表
第一个文档向量维度:768
第二个文档向量维度:768

4.向量存储

数据库的存储功能大差不差,但是对于搜索功能,相比于普通关系型数据库的精确搜索,对于向量我们更需向量数据库提供的内容相似性搜索

向量数据库则提供了专门用于高效存储、管理和检索高维向量的能力。其核心就是 “高效地组织和检索这些数据”

对于这一特殊功能,向量数据库有着专门的优化:

  • 专门的索引–例如近似最近邻(ANN)搜索
    • 常见的方法有近似最近邻(ANN)搜索:为了追求极致的速度,它愿意牺牲一点点精度。它不会保证找到绝对最相似的向量(即最近邻),但能以极高的概率找到非常相似的向量。通过聚类、分层、压缩等算法技术,将搜索范围从“整个数据库”缩小到“几个最可能的候选集”。
  • 向量相似度计算优化–使用并行计算能力
    • 向量数据库底层使用高度优化的库来进行向量运算。如 FAISS 向量数据库,它是 Facebook AI 研究院开发的一种高效的相似性搜索和聚类的库。它能够快速处理大规模数据,并且支持在高维空间中进行相似性搜索。这些库充分利用了 CPU 的 SIMD 指令集和 GPU 的并行计算能力,让大规模的向量计算速度极快。
  • 数据库管理功能
    • 除了CRUD等基础操作,还支持元数据过滤、可扩展性、可持久化、易于集成等优点

LangChain 框架则通过与这些向量数据库集成,让开发者无需手动处理向量生成、存储和比较的复杂性,只需关注业务逻辑本身,极大地提高了开发效率和应用性能。

内存存储

我们先来使用一些简单易部署的内存数据库,特别的,大部分内存存储实例在初始化时需要传入指定的向量模型作为参数。

1
2
3
4
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore
embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-mpnet-base-v2")
store = InMemoryVectorStore(embedding=embeddings)

接下来我们往里面添加文档

我们可以使用 add_documents 方法,向内存存储中去添加文档。没错,直接添加分割好的文档对象列表就行。要注意的是,该方法会为添加的文档编排索引,索引列表随着该方法返回。

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
from langchain_community.document_loaders.markdown import UnstructuredMarkdownLoader
from langchain_text_splitters import CharacterTextSplitter
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore


file_path = "./docs/面经.md"
loader = UnstructuredMarkdownLoader(file_path)

docs = loader.load()

text_spliter = CharacterTextSplitter(
separator="\n\n" , # 选择分隔符
chunk_size=100,
chunk_overlap=20,
length_function=len,
is_separator_regex=False
)

docs = text_spliter.split_documents(docs)

embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-mpnet-base-v2")
store = InMemoryVectorStore(embedding=embeddings)

ids = store.add_documents(documents=docs)

print(f"共编排了{len(ids)}个文档索引")
print(f"前3个文档的索引是:{ids[:3]}")

末尾的输出如下

1
2
共编排了99个文档索引
前3个文档的索引是:['594e2b3c-d679-4df5-8b77-87bb95dff7d4', 'e88a24f2-2306-4977-b1a6-58e71c648661', 'cf97bae9-6d6a-4bb5-a22c-82159b5734d8']

5. 向量搜索

相似性搜索

我们使用similarity_search方法来执行基于语义的相似性搜索,即根据向量的余弦相似性进行搜索

它有几个主要参数:

  • query:查询请求,用于参考语义相似性的
  • k: 查询出的文档的最大数量
  • filter:过滤器,传入def func(doc:Document)->bool类型的函数即可完成过滤
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
from langchain_community.document_loaders.markdown import UnstructuredMarkdownLoader
from langchain_text_splitters import CharacterTextSplitter
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore


file_path = "./docs/面经.md"
loader = UnstructuredMarkdownLoader(file_path)

docs = loader.load()

text_spliter = CharacterTextSplitter(
separator="\n\n" , # 选择分隔符
chunk_size=100,
chunk_overlap=20,
length_function=len,
is_separator_regex=False
)

docs = text_spliter.split_documents(docs)

embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-mpnet-base-v2")
store = InMemoryVectorStore(embedding=embeddings)

ids = store.add_documents(documents=docs)

results = store.similarity_search(query="Linux的特性",k=2)

for doc in results:
print("*"*40)
print(doc.page_content)

输出如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
****************************************
语言

1. 内联函数的缺点

代码膨胀,编译时间增加,降低维护性,寄存器压力

Linux命令

1. 两台服务器,从一台服务器下载另一台上面的zip文件,用linux什么命令
****************************************
隔离性:虚拟机提供了系统级别的隔离,每个虚拟机都拥有独立的操作系统和硬件资源,彼此之间完全隔离。

Docker

6.检索

实践样例:RAG样例

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
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_openai import ChatOpenAI
from langchain_community.document_loaders.markdown import UnstructuredMarkdownLoader
from langchain_text_splitters import CharacterTextSplitter
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-mpnet-base-v2")
model = ChatOpenAI(model="gpt-4o-mini")

# 1.载入文件
loader = UnstructuredMarkdownLoader("docs/面经.md")
data =loader.load()

# 2.分割文件
text_spliter = CharacterTextSplitter(
separator="\n\n" , # 选择分隔符
chunk_size=200,
chunk_overlap=20,
length_function=len,
is_separator_regex=False
)
docs = text_spliter.split_documents(data)

# 3. 存储向量
store = InMemoryVectorStore(embedding=embeddings)
store.add_documents(docs)

# 4. 检索向量
retriever = store.as_retriever()

## 提示词模板
prompt_template = ChatPromptTemplate.from_messages(
[
("human","你是问答助手,必须使用检索到的上下文片段来回答问题,如果你不知道答案,就说不知道答案,最多回复三句话的结果,回答简明扼要"),
(
"human",
"""
Question:{question},
Context:{context}
Answer:
"""
)
]
)

def format_docs(docs):
return "\n\n".join( doc.page_content for doc in docs)

# 将消息传给LLM
chain = (
{"context": retriever| format_docs,"question": RunnablePassthrough()}
| prompt_template
| model
| StrOutputParser()
)

while True:
question = input("\n请输入您的问题").strip()

if not question:
continue

print("回答:")
for chunk in chain.stream(question):
print(chunk,end="", flush=True)

print()