logo

LLM 应用--知识库构建

王哲峰 / 2024-08-03


目录

词向量

词向量简介

在机器学习和自然语言处理(NLP)中,词向量(Embeddings)是一种将非结构化数据, 如单词、句子或者整个文档,转化为实数向量的技术。这些实数向量可以被计算机更好地理解和处理。 嵌入背后的主要想法是,相似或相关的对象在嵌入空间中的距离应该很近。

词向量的优势

在 RAG(Retrieval Augmented Generation,检索增强生成)方面词向量的优势主要有两点:

构建词向量的方法

在搭建 RAG 系统时,可以通过使用嵌入模型来构建词向量,可以选择:

向量数据库

向量数据库简介

向量数据库是用于高效计算和管理大量向量数据的解决方案。 向量数据库是一种专门用于存储和检索向量数据(embedding)的数据库系统。 它与传统的基于关系模型的数据库不同,它主要关注的是向量数据的特性和相似性

在向量数据库中,数据被表示为向量形式,每个向量代表一个数据项。 这些向量可以是数字、文本、图像或其他类型的数据。 向量数据库使用高效的索引和查询算法来加速向量数据的存储和检索过程。

向量数据库原理及优势

向量数据库中的数据以向量作为基本单位,对向量进行存储、处理及检索。 向量数据库通过计算与目标向量的余弦距离、点积等获取与目标向量的相似度。 当处理大量甚至海量的向量数据时,向量数据库索引和查询算法的效率明显高于传统数据库。

主流向量数据库

调用 Embedding API

为了方便 Embedding API 调用,应将 API key 填入 .env 文件,代码将自动读取并加载环境变量。

OpenAI API

GPT 有封装好的接口,使用时简单封装即可。目前 GPT Embedding model 有三种,性能如下

模型 每美元页数 MTEB得分 MIRACL得分
text-embedding-3-large 9,615 64.6 54.9
text-embedding-3-small 62,500 62.3 44.0
text-embedding-ada-002 12,500 61.0 31.4

其中:

从以上三个 Embedding model 可以看出:

import os

from openai import OpenAI
from dotenv import load_dotenv, find_dotenv

_ = load_dotenv(find_dotenv())

os.environ["HTTPS_PROXY"] = "http://127.0.0.1:7890"
os.environ["HTTP_PROXY"] = "http://127.0.0.1:7890"


def openai_embedding(text: str, model: str = None):
    # 获取环境变量 OPENAI_API_KEY
    api_key = os.environ["OPENAI_API_KEY"]
    client = OpenAI(api_key = api_key)
    # embedding model
    if model == None:
        model = "text-embedding-3-small"
    # 模型调用    
    response = client.embeddings.create(
        input = text,
        model = model,
    )

    return response


response = openai_embedding(text = "要生成 embedding 的输入文本,字符串形式。")
print(f"返回的 embedding 类型为:{response.object}")
print(f"embedding 长度为:{len(response.data[0].embedding)}")
print(f"embedding (前 10) 为:{response.data[0].embedding[:10]}")
print(f"本次 embedding model 为:{response.model}")
print(f"本次 token 使用情况为:{response.usage}")

API 返回的数据为 JSON 格式,除 object 向量类型外还有存放数据的 data、 embedding model 型号 model 以及本次 token 使用情况 usage 等数据, 具体如下所示:

{
  "object": "list",
  "data": [
    {
      "object": "embedding",
      "index": 0,
      "embedding": [
        -0.006929283495992422,
        ... (省略)
        -4.547132266452536e-05,
      ],
    }
  ],
  "model": "text-embedding-3-small",
  "usage": {
    "prompt_tokens": 5,
    "total_tokens": 5
  }
}

文心千帆 API

Embedding-V1 是基于百度文心大模型技术的文本表示模型,Access token 为调用接口的凭证, 使用 Embedding-V1 时应先凭 API Key、Secret Key 获取 Access token, 再通过 Access token 调用接口来 Embedding text。 同时千帆大模型平台还支持 bge-large-zh 等 Embedding model。

import json
import requests

def wenxin_embedding(text: str):
    # 获取环境变量 wenxin_api_key, wenxin_secret_key
    api_key = os.environ["QIANFN_AK"]
    secret_key = os.environp["QIANFAN_SK"]
    # 使用 API Key、Secret Key 向 https://aip.baidubce.com/oauth/2.0/token 获取 Access token
    url = f"https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id={api_key}&client_secret={secret_key}"
    payload = json.dumps("")
    headers = {
        "Content-Type": "application/json",
        "Accept": "application/json"
    }
    response = requests.request("POST", url, headers = headers, data = payload)
    # 通过获取的 Access token 来 embedding text
    url = f"https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/embeddings/embedding-v1?access_token={str(response.json().get('access_token'))}"
    input = []
    input.append(text)
    payload = json.dumps({"input": input})
    headers = {
        "Content-Type": "application/json"
    }
    response = requests.request("POST", url, headers = headers, data = payload)

    return json.loads(response.text)


# text 应为 List(str)
text = "要生成 embedding 的输入文本,字符串形式。"
response = wenxin_embedding(text = text)

print(f"本次 embedding id 为:{response["id"]}")
print(f"本次 embedding 产生的时间戳为:{response["created"]}")
print(f"返回的 embedding 类型为:{response["object"]}")
print(f"embedding 长度为:{response["data"][0]["embedding"]}")
print(f"embedding (前 10) 为:{response["data"][0]["embedding"][:10]}")

讯飞星火 API

未开放

智谱 API

智谱有封装好的 SDK,直接调用即可。

from zhipuai import ZhipuAI


def zhipu_embedding(text: str):
    api_key = os.environ["ZHIPUAI_API_KEY"]
    client = ZhipuAI(api_key = api_key)
    response = client.embeddings.create(
        model = "embedding-2",
        input = text,
    )
    
    return response

text = "要生成 embedding 的输入文本,字符串形式。"
response = zhipu_embedding(text = text)

print(f"response 类型为:{type(response)}")
print(f"embedding 类型为:{response.object}")
print(f"生成 embedding 的 model 为:{response.model}")
print(f"生成的 embedding 长度为:{len(response.data[0].embedding)}")
print(f"embedding(前 10)为: {response.data[0].embedding[:10]}")

数据处理

为构建本地知识库,需要对以多种类型存储的本地文档进行处理, 读取本地文档并通过 Embedding 方法本地文档的内容转化为词向量来构建向量数据库

数据选取

PDF 文档

使用 LangChain 的 PyMuPDFLoader 来读取知识库的 PDF 文件。 PyMuPDFLoader 是 PDF 解析器中速度最快的一种, 结果包含 PDF 及页面的详细元数据,并且每页返回一个文档。

from langchain.document_loaders.pdf import PyMuPDFLoader

# 创建一个 PyMuPDFLoader Class 实例,输入为待加载的 PDF 文档路径
loader = PyMuPDFLoader("data_base/knowledge_db/pumpkin_book/pumpkin_book.pdf")
# 调用 PyMuPDFLoader Class 的函数 load 对 PDF 文件进行加载
pdf_pages = loader.load()

文档加载后储存在 pdf_pages 变量中:

print(f"载入后的变量类型为:{type(pdf_pages)}, 该 PDF 一共包含 {len(pdf_pages)} 页。")

pdf_pages 中的每一个元素为一个文档,变量类型为 langchain_core.documents.base.Document, 文档变量类型包含两个属性:

pdf_page = pdf_pages[1]
print(
    f"每一个元素的类型:{type(pdf_page)}", 
    f"该文档的描述性数据:{pdf_page.metadata}", 
    f"查看该文档的内容:\n{pdf_page.page_content}",
    sep = "\n------\n"
)

Markdown 文档

可以按照读取 PDF 文档几乎一致的方式读取 Markdown 文档。

from langchain.document_loaders.markdown import UnstructureMarkdownLoader

loader = UnstructureMarkdownLoader("data_base/knowledge_db/prompt_engineering/1.简介 Instroduction.md")
md_pages = loader.load()

读取的对象和 PDF 文档读取出来是完全一致的:

print(f"载入后的变量类型为:{type(md_pages)}, 该 Markdown 一共包含 {len(md_pages)} 页。")
md_page = md_pages[0]
print(
    f"每一个元素的类型:{type(md_page)}", 
    f"该文档的描述性数据:{md_page.metadata}", 
    f"查看该文档的内容:\n{md_page.page_content[0:][:200]}", 
    sep = "\n------\n"
)

数据清洗

期望知识库的数据尽量是有序的、优质的、精简的,因此要删除低质量的、甚至影响理解文本数据。 可以看到上下文中读取的 PDF 文件不仅将一句话按照原文的分行添加了换行符 \n, 也在原本两个符号中插入了 \n,可以使用正则表达式匹配并删除掉 \n

import re
pattern = re.compile(r"[^\u4e00-\u9fff](\n)[^\u4e00-\u9fff]", re.DOTALL)
pdf_page.page_content = re.sub(
    pattern, 
    lambda match: match.group(0).replace("\n", ""), 
    pdf_page.page_content
)

进一步分析数据,发现数据中还有不少的 和空格,简单实用的 replace 方法即可。

pdf_page.page_content = pdf_page.page_content.replace("•", "")
pdf_page.page_content = pdf_page.page_content.replace(" ", "")
print(pdf_page.page_content)

上下文中读取的 Markdown 文件每一段中间隔了一个换行符,同样可以使用 replace 方法去除。

md_page.page_content = md_page.page_content.replace("\n\n", "\n")
print(md_page.page_content)

文档分割

文档分割简介

由于单个文档的长度往往会超过模型支持的上下文,导致检索得到的知识太长超出模型的处理能力, 因此,在构建向量知识库的过程中,往往需要对文档进行分割, 将单个文档按长度或者按固定的规则分割成若干个 chunk,然后将每个 chunk 转化为词向量, 存储到向量数据库中。在检索时,会以 chunk 作为检索的元单位, 也就是每一次检索到 k 个 chunk 作为模型可以参考来回答用户问题的知识, 这个 k 是可以自由设定的。

文档分割 API

Langchain 中文本分割器都根据 chunk_size(块大小)和 chunk_overlap(块与块之间的重叠大小)进行分割。 CharacterTextSpitter API 示例如下:

langchain.text_splitter.CharacterTextSpitter(
    separator: str = "\n\n",
    chunk_size = 4000,
    chunk_overlap = 200,
    length_function = <buildin function len>,
)

方法:

参数:

Langchain 提供多种文档分割方式,区别在怎么确定块与块之间的边界、 块由哪些字符/token 组成、以及如何测量块大小:

文档分割示例

''' 
* RecursiveCharacterTextSplitter 递归字符文本分割。
  将按不同的字符递归地分割(按照这个优先级 ["\n\n", "\n", " ", ""]),
  这样就能尽量把所有和语义相关的内容尽可能长时间地保留在同一位置
* RecursiveCharacterTextSplitter 需要关注的是4个参数:
    - separators: 分隔符字符串数组
    - chunk_size: 每个文档的字符数量限制
    - chunk_overlap: 两份文档重叠区域的长度
    - length_function: 长度计算函数
'''

# 导入文本分割器
from langchain.text_splitter import RecursiveCharacterTextSplitter

# 知识库中单段文本长度
CHUNK_SIZE = 500
# 知识库中相邻文本重合长度
OVERLAP_SIZE = 50

# 使用递归字符文本分割器
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size = CHUNK_SIZE,
    chunk_overlap = OVERLAP_SIZE,
)
text_splitter.split_text(pdf_page.page_content[0:1000])

split_docs = text_splitter.split_documents(pdf_pages)
print(f"切分后的文件数量:{len(split_docs)}")
print(f"切分后的字符数(可以用来大致评估 token 数):{sum([len(doc.page_content) for doc in split_docs])}")

如何对文档进行分割,其实是数据处理中最核心的一步,其往往决定了检索系统的下限。 但是,如何选择分割方式,往往具有很强的业务相关性——针对不同的业务、不同的源数据, 往往需要设定个性化的文档分割方式。因此,这里仅简单根据 chunk_size 对文档进行分割。

搭建并使用向量数据库

配置

import os
from dotenv import load_dotenv, find_dotenv
from langchain.document_loaders.pdf import PyMuPDFLoader
from langchain.document_loaders.markdown import UnstructuredMarkdownLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter

# 读取本地/项目的环境变量
_ = load_dotenv(find_dotenv())

# 如果需要通过代理端口访问,你需要如下配置
os.environ['HTTPS_PROXY'] = 'http://127.0.0.1:7890'
os.environ["HTTP_PROXY"] = 'http://127.0.0.1:7890'


# 获取 folder_path 下所有文件路径,储存在 file_paths 里
file_paths = []
folder_path = '../../data_base/knowledge_db'
for root, dirs, files in os.walk(folder_path):
    for file in files:
        file_path = os.path.join(root, file)
        file_paths.append(file_path)
print(file_paths[:3])

# 遍历文件路径并把实例化的loader存放在loaders里
loaders = []
for file_path in file_paths:
    file_type = file_path.split('.')[-1]
    if file_type == 'pdf':
        loaders.append(PyMuPDFLoader(file_path))
    elif file_type == 'md':
        loaders.append(UnstructuredMarkdownLoader(file_path))

# 下载文件并存储到text
texts = []
for loader in loaders: 
    texts.extend(loader.load())

# 查看数据
text = texts[1]
print(
    f"每一个元素的类型:{type(text)}.", 
    f"该文档的描述性数据:{text.metadata}", 
    f"查看该文档的内容:\n{text.page_content[0:]}", 
    sep="\n------\n"
)

# 切分文档
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size = 500, 
    chunk_overlap = 50
)
split_docs = text_splitter.split_documents(texts)

构建 Chroma 向量库

Langchain 集成了超过 30 个不同的向量存储库。选择 Chroma 是因为它轻量级且数据存储在内存中, 这使得它非常容易启动和开始使用。LangChain 可以直接使用 OpenAI 和百度千帆的 Embedding, 同时,也可以针对其不支持的 Embedding API 进行自定义。

# 使用 OpenAI Embedding
# from langchain.embeddings.openai import OpenAIEmbeddings

# 使用百度千帆 Embedding
# from langchain.embeddings.baidu_qianfan_endpoint import QianfanEmbeddingsEndpoint

# 使用我们自己封装的智谱 Embedding,需要将封装代码下载到本地使用
from zhipuai_embedding import ZhipuAIEmbeddings

# 定义 Embeddings
# embedding = OpenAIEmbeddings() 
# embedding = QianfanEmbeddingsEndpoint()
embedding = ZhipuAIEmbeddings()

# 定义持久化路径
persist_directory = '../../data_base/vector_db/chroma'

删除旧的数据库文件(如果文件夹中有文件的话),windows 电脑请手动删除

$ !rm -rf '../../data_base/vector_db/chroma'
from langchain.vectorstores.chroma import Chroma

vectordb = Chroma.from_documents(
    documents = split_docs,
    embedding = embedding,
    persist_directory = persist_directory  # 允许将 persist_directory 目录保存到磁盘上
)

在此之后,要确保通过运行 vectordb.persist 来持久化向量数据库,以便以后使用。

vectordb.persist()
print(f"向量库中存储的数量:{vectordb._collection.count()}")

向量检索

相似度检索

Chroma 的相似度搜索使用的是余弦距离,即:

$$\begin{align} similarity &=cos(A, B) \\ &=\frac{A \cdot B}{||A|| ||B||} \\ &=\frac{\sum_{i=1}^{n}a_{i}b_{i}}{\sqrt{\sum_{i=1}^{n}a_{i}^{2}}\sqrt{\sum_{i=1}^{n}b_{i}^{2}}} \end{align}$$

其中 $a_{i}, b_{i}$ 分别是向量 $A, B$ 的分量。

当需要数据库返回严谨的按余弦相似度排序的结果时可以使用 similarity_search 函数。

question = "什么是大语言模型"
sim_docs = vectordb.similarity_search(question, k = 3)
print(f"检索到的内容数:{len(sim_docs)}")
for i, sim_docs in enumerate(sim_docs):
    print(f"检索到的第{i}个内容:\n{sim_doc.page_content[:200]}", end = "\n-------------\n")

MMR 检索

如果只考虑检索出内容的相关性会导致内容过于单一,可能丢失重要信息。 最大边际相关性(MMR, Maximum Marginal Relevance)可以帮助在保持相关性的同时, 增加内容的丰富度。核心思想是在已经选择了一个相关性搞得文档之后, 再选择一个与已选文档相关性较低但是信息丰富的文档。 这样可以在保持相关性的同时,增加内容的多样性,避免过于单一的结果。

mmr_docs = vector_db.max_marginal_relevance_search(question, k = 3)
for i, sim_doc in enumerate(mmr_docs):
    print(f"MMR 检索到的第 {i} 个内容:\n{sim_doc.page_content[:200]}", end = "\n-----------\n")