RAG技術全面解析:Langchain4j如何實現智慧問答的跨越式進化?

公众号-JavaEdge發表於2024-09-23

LLM 的知識僅限於其訓練資料。如希望使 LLM 瞭解特定領域的知識或專有資料,可:

  • 使用本節介紹的 RAG
  • 使用你的資料對 LLM 進行微調
  • 結合使用 RAG 和微調

1 啥是 RAG?

RAG 是一種在將提示詞傳送給 LLM 之前,從你的資料中找到並注入相關資訊的方式。這樣,LLM 希望能獲得相關的資訊並利用這些資訊作出回應,從而減少幻覺機率。

可透過各種資訊檢索方法找到相關資訊。這些方法包括但不限於:

  • 全文(關鍵詞)搜尋。該方法使用 TF-IDF 和 BM25 等技術,透過匹配查詢(例如使用者提問)中的關鍵詞與文件資料庫中的內容來搜尋文件。它根據這些關鍵詞在每個文件中的頻率和相關性對結果進行排名
  • 向量搜尋,也稱“語義搜尋”。文字文件透過嵌入模型轉換為數值向量。然後根據查詢向量與文件向量之間的餘弦相似度或其他相似度/距離度量,查詢並對文件進行排名,從而捕捉更深層次的語義含義
  • 混合搜尋。結合多種搜尋方法(例如全文搜尋 + 向量搜尋)通常能提高搜尋效果

本文主要關注向量搜尋。全文搜尋和混合搜尋目前僅透過 Azure AI Search 整合支援,詳情參見 AzureAiSearchContentRetriever。計劃在不久的將來擴充套件 RAG 工具箱,以包含全文搜尋和混合搜尋。

2 RAG 的階段

RAG 過程分為兩個不同階段:索引和檢索。LangChain4j 提供用於兩個階段的工具。

2.1 索引

文件會進行預處理,以便在檢索階段實現高效搜尋。

該過程可能因使用的資訊檢索方法而有所不同。對向量搜尋,通常包括清理文件,利用附加資料和後設資料對其進行增強,將其拆分為較小的片段(即“分塊”),對這些片段進行嵌入,最後將它們儲存在嵌入儲存庫(即向量資料庫)。

通常在離線完成,即使用者無需等待該過程的完成。可透過例如每週末執行一次的定時任務來重新索引公司內部文件。負責索引的程式碼也可以是一個僅處理索引任務的單獨應用程式。

但某些場景,使用者可能希望上傳自定義文件以供 LLM 訪問。此時,索引應線上進行,併成為主應用程式的一部分。

索引階段的簡化流程圖

2.2 檢索

通常線上進行,當使用者提交一個問題時,系統會使用已索引的文件來回答問題。

該過程可能會因所用的資訊檢索方法不同而有所變化。對於向量搜尋,通常包括嵌入使用者的查詢(問題),並在嵌入儲存庫中執行相似度搜尋。然後,將相關片段(原始文件的部分內容)注入提示詞併傳送給 LLM。

檢索階段的簡化流程圖

3 簡單 RAG

LangChain4j 提供了“簡單 RAG”功能,使你儘可能輕鬆使用 RAG。無需學習嵌入技術、選擇向量儲存、尋找合適的嵌入模型、瞭解如何解析和拆分文件等操作。只需指向你的文件,LangChain4j 就會自動處理!

若需定製化RAG,請跳到rag-apis。

當然,這種“簡單 RAG”的質量會比定製化 RAG 設定的質量低一些。然而,這是學習 RAG 或製作概念驗證的最簡單方法。稍後,您可以輕鬆地從簡單 RAG 過渡到更高階的 RAG,逐步調整和自定義各個方面。

3.1 匯入 langchain4j-easy-rag 依賴

<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-easy-rag</artifactId>
    <version>0.34.0</version>
</dependency>

3.2 載入文件

List<Document> documents = FileSystemDocumentLoader.loadDocuments("/home/langchain4j/documentation");

這將載入指定目錄下的所有檔案。

底層發生了什麼?

Apache Tika 庫被用於檢測文件型別並解析它們。由於我們沒有顯式指定使用哪個 DocumentParser,因此 FileSystemDocumentLoader 將載入 ApacheTikaDocumentParser,該解析器由 langchain4j-easy-rag 依賴透過 SPI 提供。

咋自定義載入文件?

若想載入所有子目錄中的文件,可用 loadDocumentsRecursively

List<Document> documents = FileSystemDocumentLoader.loadDocumentsRecursively("/home/langchain4j/documentation");

還可透過使用 glob 或正規表示式過濾文件:

PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher("glob:*.pdf");
List<Document> documents = FileSystemDocumentLoader.loadDocuments("/home/langchain4j/documentation", pathMatcher);

使用 loadDocumentsRecursively 時,可能要在 glob 中使用雙星號(而不是單星號):glob:**.pdf

3.3 預處理

並將文件儲存在專門的嵌入儲存中也稱向量資料庫。這是為了在使用者提出問題時快速找到相關資訊片段。可用 15+ 種支援的嵌入儲存,但為簡化操作,使用記憶體儲存:

InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
EmbeddingStoreIngestor.ingest(documents, embeddingStore);

底層發生了啥?

  • EmbeddingStoreIngestor 透過 SPI 從 langchain4j-easy-rag 依賴中載入 DocumentSplitter。每個 Document 被拆分成較小的片段(即 TextSegment),每個片段不超過 300 個 token,且有 30 個 token 的重疊部分。
  • EmbeddingStoreIngestor 透過 SPI 從 langchain4j-easy-rag 依賴中載入 EmbeddingModel。每個 TextSegment 都使用 EmbeddingModel 轉換為 Embedding

選擇 bge-small-en-v1.5 作為簡單 RAG 的預設嵌入模型。該模型在 MTEB 排行榜 上取得了不錯的成績,其量化版本僅佔用 24 MB 空間。因此,我們可以輕鬆將其載入到記憶體中,並在同一程序中透過 ONNX Runtime 執行。

可在完全離線的情況下,在同一個 JVM 程序中將文字轉換為嵌入。LangChain4j 提供 5 種流行的嵌入模型開箱即用

  1. 所有 TextSegmentEmbedding 對被儲存在 EmbeddingStore

  2. 建立一個AI 服務,它將作為我們與 LLM 互動的 API:

interface Assistant {

    String chat(String userMessage);
}

ChatLanguageModel chatModel = OpenAiChatModel.builder()
    .apiKey(System.getenv("OPENAI_API_KEY"))
    .modelName(GPT_4_O_MINI)
    .build();

Assistant assistant = AiServices.builder(Assistant.class)
    .chatLanguageModel(chatModel)
    .chatMemory(MessageWindowChatMemory.withMaxMessages(10))
    .contentRetriever(EmbeddingStoreContentRetriever.from(embeddingStore))
    .build();

配置 Assistant 使用 OpenAI 的 LLM 來回答使用者問題,記住對話中的最近 10 條訊息,並從包含我們文件的 EmbeddingStore 中檢索相關內容。

  1. 對話!
String answer = assistant.chat("如何使用 LangChain4j 實現簡單 RAG?");

4 訪問源資訊

如希望訪問增強訊息的檢索源,可將返回型別包裝在 Result 類中:

interface Assistant {

    Result<String> chat(String userMessage);
}

Result<String> result = assistant.chat("如何使用 LangChain4j 實現簡單 RAG?");

String answer = result.content();
List<Content> sources = result.sources();

流式傳輸時,可用 onRetrieved() 指定一個 Consumer<List<Content>>

interface Assistant {

    TokenStream chat(String userMessage);
}

assistant.chat("如何使用 LangChain4j 實現簡單 RAG?")
    .onRetrieved(sources -> ...)
    .onNext(token -> ...)
    .onError(error -> ...)
    .start();

5 RAG API

LangChain4j 提供豐富的 API 讓你可輕鬆構建從簡單到高階的自定義 RAG 流水線。本節介紹主要的領域類和 API。

5.1 文件(Document)

Document 類表示整個文件,例如單個 PDF 檔案或網頁。當前,Document 只能表示文字資訊,但未來的更新將支援影像和表格。

package dev.langchain4j.data.document;

/**
 * 表示通常對應於單個檔案內容的非結構化文字。此文字可能來自各種來源,如文字檔案、PDF、DOCX 或網頁 (HTML)。
 * 每個文件都可能具有關聯的後設資料,包括其來源、所有者、建立日期等
 */
public class Document {

    /**
     * Common metadata key for the name of the file from which the document was loaded.
     */
    public static final String FILE_NAME = "file_name";
    /**
     * Common metadata key for the absolute path of the directory from which the document was loaded.
     */
    public static final String ABSOLUTE_DIRECTORY_PATH = "absolute_directory_path";
    /**
     * Common metadata key for the URL from which the document was loaded.
     */
    public static final String URL = "url";

    private final String text;
    private final Metadata metadata;

API

  • Document.text() 返回 Document 的文字內容
  • Document.metadata() 返回 Document 的後設資料(見下文)
  • Document.toTextSegment()Document 轉換為 TextSegment(見下文)
  • Document.from(String, Metadata) 從文字和 Metadata 建立一個 Document
  • Document.from(String) 從文字建立一個帶空 MetadataDocument

5.2 後設資料(Metadata)

每個 Document 都包含 Metadata,用於儲存文件的元資訊,如名稱、來源、最後更新時間、所有者或任何其他相關細節。

Metadata 以KV對形式儲存,其中鍵是 String 型別,值可為 StringIntegerLongFloatDouble 中的任意一種。

用途

  • 在將文件內容包含到 LLM 的提示詞中時,可以將後設資料條目一併包含,向 LLM 提供額外資訊。例如,提供文件名稱和來源可以幫助 LLM 更好地理解內容。

  • 在搜尋相關內容以包含在提示詞中時,可以根據後設資料條目進行過濾。例如,您可以將語義搜尋範圍限制為屬於特定所有者的文件。

  • 當文件的來源被更新(例如文件的特定頁面),您可以透過其後設資料條目(例如“id”、“source”等)輕鬆找到相應的文件,並在嵌入儲存中更新它,以保持同步。

API

  • Metadata.from(Map)Map 建立 Metadata
  • Metadata.put(String key, String value) / put(String, int) / 等方法新增後設資料條目
  • Metadata.getString(String key) / getInteger(String key) / 等方法返回後設資料條目的值,並轉換為所需型別
  • Metadata.containsKey(String key) 檢查後設資料中是否包含指定鍵的條目
  • Metadata.remove(String key) 從後設資料中刪除指定鍵的條目
  • Metadata.copy() 返回後設資料的副本
  • Metadata.toMap() 將後設資料轉換為 Map

5.3 文件載入器(Document Loader)

可從 String 建立一個 Document,但更簡單的是使用庫中包含的文件載入器之一:

  • FileSystemDocumentLoader 來自 langchain4j 模組
  • UrlDocumentLoader 來自 langchain4j 模組
  • AmazonS3DocumentLoader 來自 langchain4j-document-loader-amazon-s3 模組
  • AzureBlobStorageDocumentLoader 來自 langchain4j-document-loader-azure-storage-blob 模組
  • GitHubDocumentLoader 來自 langchain4j-document-loader-github 模組
  • TencentCosDocumentLoader 來自 langchain4j-document-loader-tencent-cos 模組

5.4 文字片段轉換器

TextSegmentTransformer 類似於 DocumentTransformer(如上所述),但它用於轉換 TextSegment

DocumentTransformer 類似,沒有統一的解決方案,建議根據您的資料自定義實現 TextSegmentTransformer

提高檢索效果的有效方法是將 Document 的標題或簡短摘要包含在每個 TextSegment

5.5 嵌入

Embedding 類封裝了一個數值向量,表示嵌入內容(通常是文字,如 TextSegment)的“語義意義”。

閱讀更多關於向量嵌入的內容:

  • https://www.elastic.co/what-is/vector-embedding
  • https://www.pinecone.io/learn/vector-embeddings/
  • https://cloud.google.com/blog/topics/developers-practitioners/meet-ais-multitool-vector-embeddings

API

  • Embedding.dimension() 返回嵌入向量的維度(即長度)
  • CosineSimilarity.between(Embedding, Embedding) 計算兩個 Embedding 之間的餘弦相似度
  • Embedding.normalize() 對嵌入向量進行歸一化(就地操作)

嵌入模型

EmbeddingModel 介面代表一種特殊型別的模型,將文字轉換為 Embedding

當前支援的嵌入模型可以在這裡找到。

API

  • EmbeddingModel.embed(String) 嵌入給定的文字
  • EmbeddingModel.embed(TextSegment) 嵌入給定的 TextSegment
  • EmbeddingModel.embedAll(List<TextSegment>) 嵌入所有給定的 TextSegment
  • EmbeddingModel.dimension() 返回該模型生成的 Embedding 的維度

嵌入儲存

EmbeddingStore 介面表示嵌入儲存,也稱為向量資料庫。它用於儲存和高效搜尋相似的(在嵌入空間中接近的)Embedding

當前支援的嵌入儲存可以在這裡找到。

EmbeddingStore 可以單獨儲存 Embedding,也可以與相應的 TextSegment 一起儲存:

  • 它可以僅按 ID 儲存 Embedding,嵌入的資料可以儲存在其他地方,並透過 ID 關聯。
  • 它可以同時儲存 Embedding 和被嵌入的原始資料(通常是 TextSegment)。

API

  • EmbeddingStore.add(Embedding) 將給定的 Embedding 新增到儲存中並返回隨機 ID
  • EmbeddingStore.add(String id, Embedding) 將給定的 Embedding 以指定 ID 新增到儲存中
  • EmbeddingStore.add(Embedding, TextSegment) 將給定的 Embedding 和關聯的 TextSegment 新增到儲存中,並返回隨機 ID
  • EmbeddingStore.addAll(List<Embedding>) 將一組 Embedding 新增到儲存中,並返回一組隨機 ID
  • EmbeddingStore.addAll(List<Embedding>, List<TextSegment>) 將一組 Embedding 和關聯的 TextSegment 新增到儲存中,並返回一組隨機 ID
  • EmbeddingStore.search(EmbeddingSearchRequest) 搜尋最相似的 Embedding
  • EmbeddingStore.remove(String id) 按 ID 從儲存中刪除單個 Embedding
  • EmbeddingStore.removeAll(Collection<String> ids) 按 ID 從儲存中刪除多個 Embedding
  • EmbeddingStore.removeAll(Filter) 刪除儲存中與指定 Filter 匹配的所有 Embedding
  • EmbeddingStore.removeAll() 刪除儲存中的所有 Embedding

嵌入搜尋請求(EmbeddingSearchRequest)

EmbeddingSearchRequest 表示在 EmbeddingStore 中的搜尋請求。其屬性如下:

  • Embedding queryEmbedding: 用作參考的嵌入。
  • int maxResults: 返回的最大結果數。這是一個可選引數,預設為 3。
  • double minScore: 最低分數,範圍為 0 到 1(含)。僅返回得分 >= minScore 的嵌入。這是一個可選引數,預設為 0。
  • Filter filter: 搜尋時應用於 Metadata 的過濾器。僅返回 Metadata 符合 FilterTextSegment

過濾器(Filter)

關於 Filter 的更多細節可以在這裡找到。

嵌入搜尋結果(EmbeddingSearchResult)

EmbeddingSearchResult 表示在 EmbeddingStore 中的搜尋結果,包含 EmbeddingMatch 列表。

嵌入匹配(Embedding Match)

EmbeddingMatch 表示一個匹配的 Embedding,包括其相關性得分、ID 和嵌入的原始資料(通常是 TextSegment)。

嵌入儲存匯入器

EmbeddingStoreIngestor 表示一個匯入管道,負責將 Document 匯入到 EmbeddingStore

在最簡單的配置中,EmbeddingStoreIngestor 使用指定的 EmbeddingModel 嵌入提供的 Document,並將它們與其 Embedding 一起儲存在指定的 EmbeddingStore 中:

EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
        .embeddingModel(embeddingModel)
        .embeddingStore(embeddingStore)
        .build();

ingestor.ingest(document1);
ingestor.ingest(document2, document3);
ingestor.ingest(List.of(document4, document5, document6));

可選地,EmbeddingStoreIngestor 可以使用指定的 DocumentTransformer 來轉換 Document。這在您希望在嵌入之前對文件進行清理、增強或格式化時非常有用。

可選地,EmbeddingStoreIngestor 可以使用指定的 DocumentSplitterDocument 拆分為 TextSegment。這在文件較大且您希望將其拆分為較小的 TextSegment 時非常有用,以提高相似度搜尋的質量並減少傳送給 LLM 的提示詞的大小和成本。

可選地,EmbeddingStoreIngestor 可以使用指定的 TextSegmentTransformer 來轉換 TextSegment。這在您希望在嵌入之前對 TextSegment 進行清理、增強或格式化時非常有用。

示例:

EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()

    // 為每個 Document 新增 userId 後設資料條目,便於後續過濾
    .documentTransformer(document -> {
        document.metadata().put("userId", "12345");
        return document;
    })

    // 將每個 Document 拆分為 1000 個 token 的 TextSegment,具有 200 個 token 的重疊
    .documentSplitter(DocumentSplitters.recursive(1000, 200, new OpenAiTokenizer()))

    // 為每個 TextSegment 新增 Document 的名稱,以提高搜尋質量
    .textSegmentTransformer(textSegment -> TextSegment.from(
            textSegment.metadata("file_name") + "\n" + textSegment.text(),
            textSegment.metadata()
    ))

    .embeddingModel(embeddingModel)
    .embeddingStore(embeddingStore)
    .build();

關注我,緊跟本系列專欄文章,咱們下篇再續!

作者簡介:魔都架構師,多家大廠後端一線研發經驗,在分散式系統設計、資料平臺架構和AI應用開發等領域都有豐富實踐經驗。

各大技術社群頭部專家博主。具有豐富的引領團隊經驗,深厚業務架構和解決方案的積累。

負責:

  • 中央/分銷預訂系統效能最佳化
  • 活動&券等營銷中臺建設
  • 交易平臺及資料中臺等架構和開發設計
  • 車聯網核心平臺-物聯網連線平臺、大資料平臺架構設計及最佳化
  • LLM Agent應用開發
  • 區塊鏈應用開發
  • 大資料開發挖掘經驗
  • 推薦系統專案

目前主攻市級軟體專案設計、構建服務全社會的應用系統。

參考:

  • 程式設計嚴選網

本文由部落格一文多發平臺 OpenWrite 釋出!

相關文章