構建RAG應用-day01: 詞向量和向量資料庫 文件預處理

passion2021發表於2024-04-17

詞向量和向量資料庫

image-20240417145729703

詞向量(Embeddings)是一種將非結構化資料,如單詞、句子或者整個文件,轉化為實數向量的技術。

詞向量搜尋和關鍵詞搜尋的比較

優勢1:詞向量可以語義搜尋

比如百度搜尋,使用的是關鍵詞搜尋。而詞向量搜尋,是對句子的語義進行搜尋,他會找到意思相近的前k個句子。

優勢2:詞向量可以對多模態資料進行搜尋

當傳統資料庫儲存文字、聲音、影像、影片等多種媒介時,很難去將上述多種媒介構建起關聯與跨模態的查詢方法;但是詞向量卻可以透過多種向量模型將多種資料對映成統一的向量形式。

缺點:準確度問題

向量資料庫

定義
向量資料庫是一種專門用於儲存和檢索向量資料(embedding)的資料庫系統。
在向量資料庫中,資料被表示為向量形式,每個向量代表一個資料項。這些向量可以是數字、文字、影像或其他型別的資料。向量資料庫使用高效的索引和查詢演算法來加速向量資料的儲存和檢索過程。

原理
向量資料庫中的資料以向量作為基本單位,對向量進行儲存、處理及檢索。向量資料庫透過計算與目標向量的餘弦距離、點積等獲取與目標向量的相似度。當處理大量甚至海量的向量資料時,向量資料庫索引和查詢演算法的效率明顯高於傳統資料庫。

主流向量資料庫

  • Chroma:是一個輕量級向量資料庫,擁有豐富的功能和簡單的 API,具有簡單、易用、輕量的優點,但功能相對簡單且不支援GPU加速,適合初學者使用。
  • Weaviate:是一個開源向量資料庫。除了支援相似度搜尋和最大邊際相關性(MMR,Maximal Marginal Relevance)搜尋外還可以支援結合多種搜尋演算法(基於詞法搜尋、向量搜尋)的混合搜尋,從而搜尋提高結果的相關性和準確性。
  • Qdrant:Qdrant使用 Rust 語言開發,有極高的檢索效率和RPS(Requests Per Second),支援本地執行、部署在本地伺服器及Qdrant雲三種部署模式。且可以透過為頁面內容和後設資料制定不同的鍵來複用資料。

使用 OpenAI Embedding API

有三種Embedding模型,效能如下所示:

模型 每美元頁數 MTEB得分 MIRACL得分
text-embedding-3-large 9,615 54.9 64.6
text-embedding-3-small 62,500 62.3 44.0
text-embedding-ada-002 12,500 61.0 31.4
  • MTEB得分為embedding model分類、聚類、配對等八個任務的平均得分。
  • MIRACL得分為embedding model在檢索任務上的平均得分。

text-embedding-3-large效能最好,text-embedding-3-small價效比高,text-embedding-ada-002效果不好,不推薦。

如果沒有openai apikey可以使用自己的github賬號去這個專案領取一個免費的使用:
chatanywhere/GPT_API_free: Free ChatGPT API Key,免費ChatGPT API,支援GPT4 API(免費),ChatGPT國內可用免費轉發API,直連無需代理。可以搭配ChatBox等軟體/外掛使用,極大降低介面使用成本。國內即可無限制暢快聊天。 (github.com)

import os
from openai import OpenAI
from dotenv import load_dotenv, find_dotenv


# 讀取本地/專案的環境變數。
# find_dotenv()尋找並定位.env檔案的路徑
# load_dotenv()讀取該.env檔案,並將其中的環境變數載入到當前的執行環境中  
# 如果你設定的是全域性的環境變數,這行程式碼則沒有任何作用。
_ = 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:'text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002'
    if model == None:
        model="text-embedding-3-small"

    response = client.embeddings.create(
        input=text,
        model=model
    )
    return response

response = openai_embedding(text='要生成 embedding 的輸入文字,字串形式。')

執行程式碼:

response = openai_embedding(text='要生成 embedding 的輸入文字,字串形式。')
# 返回一個Embedding物件
print(response)
# 獲取真正的Embedding資料,是一個list
print(response.data[0].embedding)
# 更多
print(f'本次embedding model為:{response.model}')
print(f'本次token使用情況為:{response.usage}')
'''
返回物件:
CreateEmbeddingResponse(data=[Embedding(embedding=[0.03884002938866615, 0.013516489416360855, -0.0024250170681625605, ... 中間很長省略 ..., 0.002844922710210085, -0.012999682687222958], index=0, object='embedding')], model='text-embedding-3-small', object='list', usage=Usage(prompt_tokens=12, total_tokens=12, completion_tokens=0))
'''
{
  "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
  }
}

文件預處理

使用 langchain text_splitter

使用re處理\n、空格、·
使用langchain RecursiveCharacterTextSplitter 按照分隔符的優先順序進行遞迴的文件分割。
設定 單段文字長度(CHUNK_SIZE)、知識庫中相鄰文字重合長度(OVERLAP_SIZE)。CHUNK_SIZE = 500 OVERLAP_SIZE = 50

import re
from langchain.document_loaders.pdf import PyMuPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter

# 建立一個 PyMuPDFLoader Class 例項,輸入為待載入的 pdf 文件路徑
loader = PyMuPDFLoader("books/1-民法典總則編 理解與適用 上.pdf")

# 呼叫 PyMuPDFLoader Class 的函式 load 對 pdf 檔案進行載入
pdf_pages = loader.load()

# 載入後的變數型別為:<class 'list'>, 該 PDF 一共包含 540 頁
print(f"載入後的變數型別為:{type(pdf_pages)},", f"該 PDF 一共包含 {len(pdf_pages)} 頁")

pdf_page = pdf_pages[100]
print(f"每一個元素的型別:{type(pdf_page)}.",
      f"該文件的描述性資料:{pdf_page.metadata}",
      f"檢視該文件的內容:\n{pdf_page.page_content}",
      sep="\n------\n")

# 清除字元之間的換行符\n
pattern = re.compile(r'[^\u4e00-\u9fff](\n)[^\u4e00-\u9fff]', re.DOTALL)

# re.sub(匹配模式,對匹配到的內容使用函式處理,被匹配的字串),如下就是將匹配到的\n替換為空字串
pdf_page.page_content = re.sub(pattern, lambda match: match.group(0).replace('\n', ''), pdf_page.page_content)
print("清除換行符之後的內容:", pdf_page.page_content, sep="\n------\n")

# 清除 • 和 空格
pdf_page.page_content = pdf_page.page_content.replace('•', '')
pdf_page.page_content = pdf_page.page_content.replace(' ', '')

# 替換\n\n
pdf_page.page_content = pdf_page.page_content.replace('\n\n', '\n')
print("繼續清洗的結果:", pdf_page.page_content, sep="\n------\n")

# 知識庫中單段文字長度(CHUNK_SIZE)、知識庫中相鄰文字重合長度(OVERLAP_SIZE)
CHUNK_SIZE = 500
OVERLAP_SIZE = 50

# 使用遞迴字元文字分割器 (遞迴地嘗試按不同的分隔符進行分割文字。)
''' 
* RecursiveCharacterTextSplitter
按不同的字元遞迴地分割(按照這個優先順序["\n\n", "\n", " ", ""]),這樣就能儘量把所有和語義相關的內容儘可能長時間地保留在同一位置

RecursiveCharacterTextSplitter需要關注的是4個引數:
* separators - 分隔符字串陣列
* chunk_size - 每個文件的字元數量限制
* chunk_overlap - 兩份文件重疊區域的長度
* length_function - 長度計算函式
'''
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=CHUNK_SIZE,
    chunk_overlap=OVERLAP_SIZE
)
# 將第一頁的前 1000 個字元進行分割
chunks = text_splitter.split_text(pdf_page.page_content[0:1000])

for i, chunk in enumerate(chunks, start=1):
    print(f'第{i}塊的內容如下:', chunk, sep="\n------\n", end="\n\n")
print(f"前1000個字元分塊的數量為:{len(chunks)}", sep="\n------\n")
split_docs = text_splitter.split_documents(pdf_pages)
print(f"切分後的檔案數量:{len(split_docs)}", sep="\n------\n")
print(f"切分後的字元數(可以用來大致評估 token 數):{sum([len(doc.page_content) for doc in split_docs])}")

使用 open-parse

專案地址:Filimoa/open-parse: Improved file parsing for LLM’s (github.com)

open-parse推薦使用語義進行分塊,並且預設了一些文件預處理流程,還可以將自己的處理方式新增到管道中,或者是自定義預處理管道。

import os
from openparse import processing, DocumentParser

basic_doc_path = "books/人生虧錢指南-TEST.pdf"

# 使用語義來進行分塊,使用 openai embedding 時間上會有點舊
# SemanticIngestionPipeline 是一個預處理管道,中間整合了各種預處理方式,按照順序執行
semantic_pipeline = processing.SemanticIngestionPipeline(
    openai_api_key=os.getenv("OPENAI_API_KEY"),
    model="text-embedding-3-large",
    min_tokens=64,
    max_tokens=1024,
)
parser = DocumentParser(
    processing_pipeline=semantic_pipeline,
)
parsed_content = parser.parse(basic_doc_path)
print('chunk數量:', len(parsed_content.nodes))

for node in parsed_content.nodes:
    print('chunk:', node.text, end='\n')
print('---------------------')
for data in parsed_content:
    print(data[0], data[1], end='\n')

新增你的處理流程:

from openparse import processing, Node
from typing import List


class CustomCombineTables(processing.ProcessingStep):
    """
    Let's combine tables that are next to each other
    """

    def process(self, nodes: List[Node]) -> List[Node]:
        new_nodes = []
        print("Combining concurrent tables")
        for i in range(len(nodes) - 1):
            if "table" in nodes[i].variant and "table" in nodes[i + 1].variant:
                new_node = nodes[i] + nodes[i + 1]
                new_nodes.append(new_node)
            else:
                new_nodes.append(nodes[i])

        return new_nodes


# add a custom processing step to the pipeline
custom_pipeline = processing.BasicIngestionPipeline()
custom_pipeline.append_transform(CustomCombineTables())

parser = openparse.DocumentParser(
    table_args={"parsing_algorithm": "pymupdf"}, processing_pipeline=custom_pipeline
)
custom_10k = parser.parse(meta10k_path)

自定義整個處理管道:

from openparse import processing, Node
from typing import List


class BasicIngestionPipeline(processing.IngestionPipeline):
    """
    A basic pipeline for ingesting and processing Nodes.
    """

    def __init__(self):
        self.transformations = [
            processing.RemoveTextInsideTables(),
            processing.RemoveFullPageStubs(max_area_pct=0.35),
        ]

更多參考:

  • Open Parse 文件
  • open-parse/src/cookbooks/semantic_processing.ipynb 語義分塊工作原理
  • Evaluating the Ideal Chunk Size for a RAG System using LlamaIndex — LlamaIndex, Data Framework for LLM Applications 關於分塊大小的討論

相關文章