检索系统
检索Retrieval是RAG系统中至关重要的一环,也是降低AI幻觉,提高准确率的重要手段。
RAG系统的目标是通过外挂知识库,每次向大模型提问之前,先在知识库搜索相关的知识,带着【参考资料】向AI提问,得出来的答案自然更加靠谱。
为什么需要外挂知识库?
- 大模型的内部参数虽然丰富,但无法更新,Deepseek的知识截止至2024年7月
- 知识库的更新完全与大模型解耦
- 可以通过自动化手段自行更新
- 适用于各种领域的知识更新需求
但是外挂知识库就引入了新问题:如何搜索?
显而易见地,用户的提问prompt就是搜索关键词,那么随之而来的:如何搜索到准确的知识?如何保证所需要的知识能够被完全搜索出来?
如果我遗漏的那一条,恰好就是我最需要的答案呢?
于是检索手段至关重要。
主要检索方法
业界通用的检索技术主要是密集检索和稀疏检索:
- 密集检索:能够将文档转化成高纬向量,数学上我们认为向量空间中位置相近的两个点具备相似的特征,因此语义相近的文本都会聚集。
- DPR:双编码器结构,一个负责查询,一个负责向量化,它会将文档中的每个段落进行编码
- ColBERT:同样是双编码器结构,但它会对段落的每个词或词片段进行编码,它更消耗性能但能够捕捉更精准的语义信息。
- 稀疏检索:稀疏检索是传统的基于词频进行统计的算法。
- BM25:是基于词频-逆文档频率(TF-IDF)框架的排名函数,可以计算查询词和文档之间的相关性得分。
- TF-IDF:是一种词频统计方法
- 逆文档频率:是指关键词在很多文档中出现,说明它和搜索目标的内容贡献不大,比较接近于通用词;反之则证明它有较好的类别区分能力,相对比较重要。
使用elasticsearch库进行BM25检索:
# 连接到 Elasticsearch
es = Elasticsearch(
"http://localhost:9200"
)
# 索引名称
index_name = "search-bpwz"
documents = [
{"id": 1, "text": "Python 是一种广泛使用的高级编程语言,由 Guido van Rossum 创建。"},
{"id": 2, "text": "Java 是一种由 Sun Microsystems 公司推出的计算机编程语言。"},
{"id": 3, "text": "C++ 是一种由 Bjarne Stroustrup 开发的编程语言。"},
{"id": 4, "text": "机器学习是人工智能的一个分支,涉及算法和统计模型。"},
{"id": 5, "text": "深度学习是机器学习的一个子领域,使用人工神经网络。"}
]
# 创建索引(如果不存在)
if not es.indices.exists(index=index_name):
es.indices.create(index=index_name)
# 将文档数据索引到 Elasticsearch 中
for doc in documents:
es.index(index=index_name, id=doc["id"], body=doc)
def bm25_search(query, index_name, top_k=3):
"""
使用 BM25 算法进行检索
:param query: 检索查询
:param index_name: 索引名称
:param top_k: 返回的文档数量
:return: 检索结果
"""
# BM25 检索
body = {
"query": {
"match": {
"text": query
}
},
"size": top_k
}
result = es.search(index=index_name, body=body)
return result["hits"]["hits"]
query = "编程语言"
results = bm25_search(query, index_name, top_k=3)
文件分块
大文本构建知识库,是无法带着所有内容,也不需要带上所有内容供给大模型进行推理,所以文件必须要切分进行向量化。语义相近的放在一起,搜索性能也会成倍提升。
传统的分块方法
- 按固定长度分块
def split_by_fixed_length(text, chunk_size):
"""
按固定长度分块
:param text: 输入文本
:param chunk_size: 每块的长度
:return: 分块后的文本列表
"""
return [text[i:i+chunk_size] for i in range(0, len(text), chunk_size)]
- 按句子分块
import nltk
nltk.download('punkt')
def split_by_sentence(text):
"""
按句子分块
:param text: 输入文本
:return: 分块后的句子列表
"""
sentences = nltk.sent_tokenize(text)
return sentences
- 按段落分块
def split_by_paragraph(text):
"""
按段落分块
:param text: 输入文本
:return: 分块后的段落列表
"""
paragraphs = text.split('\n\n')
return paragraphs
- 按关键词分块
def split_by_keyword(text, keywords):
"""
按关键词分块
:param text: 输入文本
:param keywords: 关键词列表
:return: 分块后的文本列表
"""
import re
pattern = '|'.join(re.escape(keyword) for keyword in keywords)
chunks = re.split(pattern, text)
return chunks
- 使用滑动窗口分块
def split_by_sliding_window(text, window_size, stride):
"""
使用滑动窗口分块
:param text: 输入文本
:param window_size: 窗口大小
:param stride: 滑动步长
:return: 分块后的文本列表
"""
chunks = []
for i in range(0, len(text), stride):
chunk = text[i:i+window_size]
chunks.append(chunk)
return chunks
- 使用预训练模型(例如BERT)分块
from transformers import BertTokenizer
def split_by_bert_tokenizer(text, max_length):
"""
使用BERT分词器分块
:param text: 输入文本
:param max_length: 每块的最大长度
:return: 分块后的文本列表
"""
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
tokens = tokenizer.tokenize(text)
chunks = []
for i in range(0, len(tokens), max_length):
chunk = tokens[i:i+max_length]
chunks.append(tokenizer.convert_tokens_to_string(chunk))
return chunks
LangChain文本分块
LangChain也提供了多种文本分块类型
分块器类型 | 特点 | 适用场景 |
---|---|---|
CharacterTextSplitter | 基于字符数量的简单分割 | 通用文本,结构简单的文档 |
RecursiveCharacterTextSplitter | 递归尝试不同分隔符进行分割 | 大多数文本文档,通用场景 |
MarkdownTextSplitter | 保留Markdown结构的分割 | Markdown文档 |
PythonCodeTextSplitter | 基于Python语法的分割 | Python代码文件 |
LatexTextSplitter | 保留LaTeX结构的分割 | LaTeX文档 |
HTMLTextSplitter | 基于HTML标签的分割 | HTML文档 |
TokenTextSplitter | 基于token数量的分割 | 需要精确控制token数的场景 |
SentenceTransformersTokenTextSplitter | 使用SentenceTransformers的tokenizer | 使用特定模型的场景 |
分块注意事项
- 使用chunk_overlap避免语义丢失
- 一般为了避免因为文件切分丢失连续的语义从而导致召回准确率的下降,会提供一个chunk_overlap,分块与分块之间内容存在一定的重合,避免语义丢失。
- 基于预训练模型分块可保证语义完整性
- 基于预训练模型进行分块的方法可以保证语义完整性,但是预训练模型也存在Token的限制。
- 需要针对具体场景调整分块策略
- 尝试多种组合后形成的自定义分块策略。没有完美的分块,只有最适合场景的案例。
基于LangChain构建简易RAG
- 大文本文档 → 分块 → 向量化存储
- 用户提问 → 检索向量数据库 → 并发检索(DPR+BM25) → 合并结果 → 重排序 → 向大模型提问
langchain构建简单的RAG:
model = ChatOllama(model='llama3.1', temperature=0.7)
embedding = OllamaEmbeddings(model='nomic-embed-text')
# 加载文档
loader = WebBaseLoader(
web_path="https://lilianweng.github.io/posts/2023-06-23-agent/",
bs_kwargs=dict(
parse_only=bs4.SoupStrainer(
class_=("post-content", "post-title", "post-header")
)
),
)
docs = loader.load()
# 文档分块
# chunk_size:分块大小
# chunk_overlap:分块重叠的区间
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, chunk_overlap=200)
splits = text_splitter.split_documents(docs)
# 使用Chroma向量化存储
vectorsotre = Chroma.from_documents(documents=splits, embedding=embedding)
# 向量存储的检索器
retriever = vectorsotre()