iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0
生成式 AI

用 Node.js 打造生成式 AI 應用:從 Prompt 到 Agent 開發實戰系列 第 19

Day 19 - RAG 實戰應用:打造可檢索公司年報的 AI 問答系統

  • 分享至 

  • xImage
  •  

經過前幾天的學習,我們已經完成了 RAG 系統的核心模組:文件載入與分割、嵌入模型、向量資料庫,以及透過 Retriever 封裝檢索邏輯。今天,我們要進行一次完整的實戰,把這些元件整合起來,打造一個能檢索公司年報的 AI 問答系統。

透過這個專案,你會看到 RAG 的整個流程如何串起來,並且能快速應用到其他類似的資料檢索場景。

系統架構設計

要打造一個能夠針對公司年報進行問答的 AI 系統,核心概念就是 RAG(Retrieval-Augmented Generation)。整體流程可分為兩個主要階段:

  1. 知識準備階段
    在這個階段,我們的目標是將靜態的公司年報轉換為可檢索的知識庫。主要步驟包括:

    • 文件載入:將年報 PDF 匯入並轉換成可處理的文字資料。
    • 文本分割:將長篇內容切割成較小的片段(chunk),以利後續向量化與檢索。
    • 向量化與儲存:透過嵌入模型將片段轉換成向量,並存入向量資料庫,作為檢索基礎。
    • 檢索工具封裝:將檢索邏輯封裝成一個 Tool,並附上清楚的使用說明,讓 LLM 能在需要時主動調用。
  2. 問答階段
    當知識庫準備完成後,系統即可支援使用者進行互動式問答。流程如下:

    • 使用者提問:使用者以自然語言輸入問題。
    • LLM 推理:模型先分析問題,判斷是否需要查詢外部知識。
    • 檢索工具調用:若需要額外資訊,LLM 會呼叫 Retriever Tool,取得最相關的文件片段。
    • 答案生成:結合檢索內容與自身語言能力,產生最終回應。

整體流程如下圖所示:

https://ithelp.ithome.com.tw/upload/images/20250919/201501504mClaCU4ho.png

在這個架構中,Retriever 不再是流程中被動執行的固定步驟,而是被設計成 LLM 可隨時調用的「外部工具」。當使用者提出問題時,LLM 會先進行推理判斷:

  • 如果問題屬於一般常識,或模型本身已具備相關知識,便能直接回答;
  • 如果需要額外的文件支援,則會呼叫 Retriever Tool 檢索最相關的片段,並將結果納入最終回應。

這樣的設計讓 LLM 的運作方式更接近 Agent 思維:能根據實際情境自主決定是否使用工具,而不是僅僅依照固定流程執行。

下載公司年報:以台積電為例

在進入實作之前,我們需要先準備一份公司年報 PDF 檔案。大部分上市公司都會在 投資人關係(Investor Relations, IR) 網站上公開年報,例如台積電(TSMC)就會在官方 IR 頁面提供歷年年報與財務資料。

前往台積電網站進入 投資人關係 頁面,在「公司年報」區塊即可下載最新年度的年報 PDF。

https://ithelp.ithome.com.tw/upload/images/20250919/201501509Xo8mYoHnm.png

下載後,我們可以將檔案放在專案的指定目錄中。

安裝 Qdrant 並啟動服務

在 RAG 系統中,我們需要一個能快速進行語意檢索的向量資料庫。市面上常見的選項包括 Chroma、Qdrant、Pinecone、Weaviate、Faiss 等,本篇文章將以 Qdrant 為例,示範如何安裝與整合。

Qdrant 是一個高效能的開源向量資料庫,具備以下特性:

  • REST API 與 gRPC 支援:可輕鬆與不同應用整合。
  • 多種相似度度量:包含 cosine、dot product、euclidean。
  • 部署簡單:支援 Docker 或雲端服務,幾分鐘即可啟動。
  • 支援 metadata:能額外儲存來源資訊,方便檢索結果追蹤。

如果是第一次使用,可以透過 Docker 快速在本地啟動 Qdrant:

docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant

啟動後,Qdrant 預設會在 http://localhost:6333 提供 API 服務,並同時開放內建的 Web UI:

http://localhost:6333/dashboard

在這個介面中,可以檢視 Collections 狀態,確認資料是否正確匯入。

https://ithelp.ithome.com.tw/upload/images/20250919/201501508AuXaBCJpv.png

Note:這裡我們以 Qdrant 作為範例,但若在實際專案中使用其他向量資料庫(如 Pinecone 或 Weaviate),流程也大致相同,只需更換 VectorStore 的實作與連線設定即可。

實作:打造可檢索公司年報的智慧問答系統

當我們準備好公司財報文件,以及安裝好 Qdrant 向量資料庫環境後,就可以正式開始動手實作一個能夠針對年報進行檢索與回答的智慧問答系統。

在開始之前,請先初始化一個新的專案,命名為 llm-chatbot-with-rag,我們將在這個專案中完成實作內容。

Note:如果你對 Node.js 專案初始化流程還不熟悉,可以先回顧 Day 01 中「建立 Node.js 專案與 TypeScript 開發環境」的內容。

安裝依賴套件

建立專案環境後,請在專案根目錄中執行以下指令,安裝所需依賴套件:

npm install @langchain/core @langchain/openai @langchain/qdrant @langchain/community pdf-parse langchain dotenv

這些套件的用途如下:

  • @langchain/core:LangChain 的核心模組,提供所有開發元件的共通介面與執行邏輯。
  • @langchain/openai:LangChain 提供的 OpenAI 模型整合套件。
  • @langchain/qdrant:LangChain 的 Qdrant VectorStore 介面,讓我們能用一致的 API 存取/查詢 Qdrant 向量資料庫。
  • @langchain/community::LangChain 社群維護的整合模組,包含各種資料來源的 Document Loader。這裡使用 PDFLoader 載入 PDF 檔案。
  • pdf-parse:供 PDFLoader 使用的底層解析套件。
  • langchain:LangChain 核心框架,提供 Chain、Tool、Retriever 等功能。
  • dotenv:用來讀取 .env 檔案中的環境變數,例如 API 金鑰。

設定環境變數

在專案根目錄建立 .env 檔案,填入必要的設定:

OPENAI_API_KEY=sk-...
QDRANT_URL=
PDF_FILE_PATH=

各變數用途如下:

  • OPENAI_API_KEY:OpenAI API 的授權金鑰,用來呼叫 OpenAI 的 Embedding 模型與 Chat 模型。
  • QDRANT_URL:Qdrant 服務的連線位址,預設本地執行會是 http://localhost:6333
  • PDF_FILE_PATH:指定要匯入的 PDF 文件路徑,本專案以公司年報為例。

在程式中,我們會透過 dotenv 套件自動載入 .env 檔案中的內容,確保 API 金鑰與各種設定不會直接寫在程式碼中,也方便未來修改或換環境時直接調整。

專案目錄規劃

為了讓程式碼易於維護與擴充,我們會將不同職責的程式碼拆分成模組化結構,如下所示:

llm-chatbot-with-rag/
├── src/
│   ├── tools/                               # 封裝工具模組
│   │   └── annual-report-retriever.tool.ts  # 向量檢索封裝成 Tool
│   ├── vectorstores/                        # 向量資料庫模組
│   │   └── qdrant.vectorstore.ts            # 存取 Qdrant VectorStore
│   ├── index.ts                             # 程式進入點
│   └── ingest.ts                            # 一次性資料匯入流程
├── data/                                    # 公司年報 PDF 檔案放置處
├── package.json                             # 專案設定與依賴套件清單
├── tsconfig.json                            # TypeScript 編譯設定
└── .env                                     # 環境變數設定

我們將一次性的資料處理程式 ingest.ts 與主程式 index.ts 的查詢邏輯分開,並透過 tools/vectorstores/ 目錄來封裝相關功能。這樣的結構不僅讓專案架構更清晰,也方便日後擴充新的檢索工具,或切換至不同的向量資料庫。

匯入年報資料

在匯入年報前,請確認已經準備好公司年報的 PDF 檔,並放置 /data 目錄下,並設定好 PDF_FILE_PATH 環境變數路徑。

在 RAG 系統中,第一步就是把外部文件轉換成可檢索的向量資料。這個流程通常只需要執行一次,因此我們將它獨立成 ingest.ts 腳本,專門負責「資料匯入」:

// src/ingest.ts
import 'dotenv/config';
import { OpenAIEmbeddings } from '@langchain/openai';
import { QdrantVectorStore } from '@langchain/qdrant';
import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf';
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';

const filePath = process.env.PDF_FILE_PATH as string;

async function ingest() {
  const loader =  new PDFLoader(filePath);
  const rawDocs = await loader.load();

  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 1000,
    chunkOverlap: 200,
  });
  const docs = await splitter.splitDocuments(rawDocs);

  const embeddings = new OpenAIEmbeddings({
    model: 'text-embedding-3-small'
  });

  await QdrantVectorStore.fromDocuments(docs, embeddings, {
    url: process.env.QDRANT_URL,
    collectionName: 'annual-report',
  });

  console.log('done');
}

ingest();

整個流程會先用 PDFLoader 載入年報內容,將 PDF 轉成可處理的純文字文件。接著透過 TextSplitter 把長篇文件切成較小的片段,每段大約 1000 個字元,並設定 200 個字元的重疊區,以避免重要語意在切割時被斷開。

完成分割後,我們再利用 OpenAIEmbeddings 將每個片段轉換成高維度的向量,這樣模型之後才能透過語意相似度進行比對。最後,這些向量會存入 QdrantVectorStore,並建立一個名為 annual-report 的 collection,專門存放公司年報的知識片段。

為了更方便地執行這個匯入流程,我們可以在 package.json 中加入一個 ingest script:

{
  "scripts": {
    "ingest": "ts-node src/ingest.ts"
  }
}

這樣只要執行以下命令,就能把 PDF 年報資料匯入 Qdrant:

npm run ingest

完成執行後,你的年報資料就會被妥善儲存在向量資料庫中,後續就能透過檢索功能找到與問題最相關的內容。

存取向量資料庫

在完成資料匯入後,我們需要能夠隨時存取 Qdrant 中的向量資料,並透過它進行檢索。為了避免在專案中重複建立連線或初始化邏輯,可以將這部分抽出成一個共用模組。以下程式碼放在 src/vectorstores/qdrant.vectorstore.ts,專門負責回傳一個可重複使用的 QdrantVectorStore 實例:

// src/vectorstores/qdrant.vectorstore.ts
import { QdrantVectorStore } from '@langchain/qdrant';
import { OpenAIEmbeddings } from '@langchain/openai';

let vectorStore: QdrantVectorStore | null = null;

export const getVectorStore = async () => {
  if (vectorStore) {
    return vectorStore;
  }

  const embeddings = new OpenAIEmbeddings({
    model: 'text-embedding-3-small'
  });

  vectorStore = await QdrantVectorStore.fromExistingCollection(embeddings, {
    url: process.env.QDRANT_URL,
    collectionName: 'annual-report'
  });

  return vectorStore;
};

這裡我們採用了簡單的 Singleton 模式,利用 vectorStore 變數來暫存實例,確保在應用程式的生命週期中,只會建立一次向量資料庫連線,避免重複初始化造成效能浪費。

需要特別注意的是,與匯入資料流程中使用的 fromDocuments 不同,這裡改用 fromExistingCollection,表示直接讀取已經存在的 collection,而不是重新建立並覆蓋資料。這樣能確保我們讀取的正是之前匯入的年報向量資料。

最終,getVectorStore 函式會回傳一個可直接使用的 QdrantVectorStore 實例,方便在 RetrieverTool 中調用,完成語意檢索的工作。

建立年報檢索工具

到目前為止,我們已經能透過 QdrantVectorStore 存取公司年報的向量資料。不過,若要讓 LLM 在對話過程中能夠「自行判斷」什麼時候需要檢索,就必須再進一步將這個檢索能力封裝成 Tool。如此一來,使用者只要提問,模型就能決定是否要查詢年報,並根據檢索到的內容生成更精準的回答。

以下程式碼放在 src/tools/retriever.tool.ts,用來建立一個專門針對公司年報的檢索工具:

// src/tools/retriever.tool.ts
import { createRetrieverTool } from 'langchain/tools/retriever';
import { getVectorStore } from '../vectorstores/qdrant.vectorstore';

export const getRetrieverTool = async () => {
  const vectorStore = await getVectorStore();
  const retriever = vectorStore.asRetriever(3);

  return createRetrieverTool(retriever, {
    name: "annual-report-retriever",
    description: "檢索公司年報內容,提供與查詢問題最相關的內容片段",
  });
};

在這段程式中,我們先呼叫 getVectorStore() 取得 QdrantVectorStore 物件,接著用 .asRetriever(3) 將其轉換成 Retriever,並設定每次檢索最多回傳 3 個相關片段。這個數字可以依需求調整,以控制上下文的豐富程度。

最後,透過 createRetrieverTool() 將 Retriever 包裝成 Tool,並賦予清楚的 namedescription。這些資訊會提供給 LLM,幫助模型理解工具的用途,並在需要時自動呼叫它。

一旦在主程式中將這個 Tool 綁定到 LLM,模型就能在與使用者互動時,自主判斷是否需要啟用 annual-report-retriever 來查詢公司年報,並根據檢索到的片段生成回應。

建立主程式

上面我們已經完成了檢索工具的封裝,接下來要把它整合到對話流程中。主程式的角色,就是將 LLM檢索工具 綁定,並透過命令列(CLI)與使用者互動:

// src/index.ts
import 'dotenv/config';
import readline from 'readline';
import { ChatOpenAI } from '@langchain/openai';
import { BaseMessage, HumanMessage, SystemMessage } from '@langchain/core/messages';
import { getRetrieverTool } from './tools/retriever.tool';

async function main() {
  const llm = new ChatOpenAI({
    model: 'gpt-4o-mini',
  });

  const retrievalTool = await getRetrieverTool();

  const llmWithTools = llm.bindTools([retrievalTool]);

  const messages: BaseMessage[] = [
    new SystemMessage('你是一個樂於助人的 AI 助理。'),
  ];

  const toolsByName: Record<string, any> = {
    [retrievalTool.name]: retrievalTool,
  };

  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  console.log('LLM Chatbot 已啟動,輸入訊息開始對話(按 Ctrl+C 離開)。\n');
  rl.setPrompt('> ');
  rl.prompt();

  rl.on('line', async (input) => {
    try {
      const humanMessage = new HumanMessage(input);
      messages.push(humanMessage);

      const aiMessage = await llmWithTools.invoke(messages);
      messages.push(aiMessage);

      const toolCalls = aiMessage.tool_calls || [];
      if (toolCalls.length) {
        for (const toolCall of toolCalls) {
          const selectedTool = toolsByName[toolCall.name];
          const toolMessage = await selectedTool.invoke(toolCall);
          messages.push(toolMessage);
        }
        const followup = await llmWithTools.invoke(messages);
        messages.push(followup);
      }

      const lastMessage = messages.slice(-1)[0];
      console.log(`${lastMessage.content}\n`);
    } catch (err) {
      console.error(err);
    }

    rl.prompt();
  });
}

main();

程式啟動後,會建立一個 ChatOpenAI 模型實例,並把 annual-report-retriever 工具綁定進去。當使用者輸入問題時,模型會先生成回應,若判斷需要額外資訊,就會觸發工具呼叫,檢索公司年報中的相關內容。工具返回的結果會再加入對話脈絡,讓模型生成更完整的最終答案。

這樣的設計讓系統具備「動態檢索」能力:模型不僅能回答一般問題,還能在需要時自動查詢年報,並將結果結合到回應中。最終,使用者只要透過 CLI 提問,就能即時獲得來自公司年報的智慧問答服務。

執行與測試程式

當所有程式碼都完成後,就可以啟動專案來驗證整個問答流程是否正常運作。在此之前,請先確認已經執行過 資料匯入流程,並且 Qdrant 服務已啟動,否則檢索功能將無法正常運作。

由於我們已在 package.json 中設定了 dev 指令,在開發階段可以直接使用以下命令啟動:

npm run dev

啟動後,終端機會顯示提示字元 > ,代表聊天機器人已經就緒。此時你可以輸入任何與公司年報相關的問題,例如:

> 2024 年公司的營收是多少?
2024 年公司的營收為新台幣 2,894,307,699 仟元。

模型會根據問題自動判斷是否需要檢索資料,若需要就會呼叫我們封裝好的 annual-report-retriever 工具,從向量資料庫中找到最相關的內容,並結合檢索結果生成完整的回答。

這樣,你就能直接在 CLI 中與 AI 助手互動,並獲得基於公司年報的智慧問答服務。

小結

今天我們完成了一個完整的 RAG 實戰應用,把前幾天學到的模組整合起來,實作出能針對公司年報進行問答的 AI 系統:

  • 系統架構分為兩階段:知識準備(文件載入、分割、存入向量資料庫並封裝為 Tool)與 問答互動(使用者提問、LLM 推理、必要時檢索、生成答案)。
  • 使用 Qdrant 向量資料庫示範如何快速安裝、啟動與連線,並整合到 LangChain。
  • 使用 PDFLoader 載入公司年報,並透過 TextSplitter 切分成適合檢索的片段。
  • 利用 OpenAIEmbeddings 生成語意向量,並存入 Qdrant 向量資料庫。
  • 透過 createRetrieverTool() 將檢索能力封裝成工具,讓 LLM 能自動判斷並呼叫。
  • 將 Tool 與 LLM 綁定,並透過 CLI 與使用者互動,實現智慧問答。
  • 實測可在 CLI 輸入「2024 年公司的營收是多少?」這類問題,系統會動態檢索年報內容並生成答案。

這個專案展示了 RAG 流程從資料準備到問答互動的完整串接,未來可以快速套用到其他文件檢索場景,讓 AI 成為真正能讀資料的智慧助手。


上一篇
Day 18 - 檢索器封裝:讓向量檢索成為可呼叫的工具
下一篇
Day 20 - 認識 AI Agent:具自主決策與行動能力的智慧代理
系列文
用 Node.js 打造生成式 AI 應用:從 Prompt 到 Agent 開發實戰22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言