讓 AI 教我製作簡易 RAG 網頁服務(上)

LLM網頁服務應用(1)

toys
LLM
RAG
作者

紙魚

發佈於

2026年1月26日

摘要
因為想做東西但修不到課,所以叫 AI 教我。

緣起

最近發現 AI 聯盟開了「生成式AI應用系統與工程」,課綱很吸引我,但跟統研的必修衝堂了 QQ ,所以叫 Gemini 根據課綱教我,看看它能教到甚麼程度。

目前發現它教的都是最簡單的部分XD,比較複雜的東西以前端為例,課綱裡列的 Next.js / React / Tailwind 都被自刻 Html/CSS/JavaScript 取代,然後還吹得我好像會了甚麼很厲害的東西一樣 == 。不過它也有教我沒接觸過的 FastAPI,雖然講得超級淺,但能 Build 出自己的 API 服務滿有成就感的,除此之外還可以觀察有使用 LLM 的系統服務是怎麼串的,所以有了這篇文章。

  1. 建立新的專案資料夾
  2. 建立新的乾淨虛擬環境
  • IDE: Vscode

  • Python version: 3.12.5 (venv 虛擬環境)

另外,也記錄本次 requirement.txt,實際上我是跟著 Gemini 的指示做 pip install,所以我覺得沒必要先安裝好這些,但可以參考。

annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.12.1
certifi==2026.1.4
charset-normalizer==3.4.4
click==8.3.1
colorama==0.4.6
distro==1.9.0
fastapi==0.128.0
filelock==3.20.3
fsspec==2026.1.0
groq==1.0.0
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
huggingface-hub==0.36.0
idna==3.11
Jinja2==3.1.6
jiter==0.12.0
joblib==1.5.3
MarkupSafe==3.0.3
mpmath==1.3.0
networkx==3.6.1
numpy==2.4.1
openai==2.15.0
packaging==25.0
pydantic==2.12.5
pydantic_core==2.41.5
PyYAML==6.0.3
regex==2026.1.15
requests==2.32.5
safetensors==0.7.0
scikit-learn==1.8.0
scipy==1.17.0
sentence-transformers==5.2.0
setuptools==80.9.0
sniffio==1.3.1
starlette==0.50.0
sympy==1.14.0
threadpoolctl==3.6.0
tokenizers==0.22.2
torch==2.9.1
tqdm==4.67.1
transformers==4.57.6
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.6.3
uvicorn==0.40.0

申請 Groq 免費 API Key 並測試

因為 OpenAI API 要 $$,所以我叫它改用免費的 Groq :P。

申請步驟

完整申請步驟如下:1

  1. 上去官網,點選右上角的 Start Building

  2. 建立帳號登入

  3. 點選右上方的 API Keys,進入後選擇大大的「+ Create API Key」

  4. 給 API 命名後 Submit,copy API。

測試

接著在乾淨的 venv 環境下安裝套件

pip install openai

然後在專案資料夾下建立 main.py 或其他 py 檔,輸入以下程式(記得修改 API KEY):


from openai import OpenAI

# 1. 設定連線資訊
# base_url 是關鍵,這行告訴程式:「不要連去 OpenAI,改連去 Groq」
client = OpenAI(
    api_key="剛剛申請的API KEY", 
    base_url="https://api.groq.com/openai/v1"
)

print("--- 免費 AI 聊天機器人 (Groq Llama3) 啟動 ---")
print("--- 輸入 'quit' 可以離開程式 ---")

while True:
    # 2. 讓使用者輸入
    user_input = input("\n你:")
    
    if user_input.lower() == "quit":
        print("再見!")
        break

    # 3. 呼叫 AI (這裡使用開源模型 Llama3-8b)
    try:
        completion = client.chat.completions.create(
            model="llama-3.3-70b-versatile",  # 指定模型名稱
            messages=[
                {"role": "system", "content": "你是一個繁體中文的 AI 助教,回答簡潔有力。"},
                {"role": "user", "content": user_input}
            ]
        )

        # 4. 取得回答
        ai_response = completion.choices[0].message.content
        print(f"AI:{ai_response}")
        
    except Exception as e:
        print(f"發生錯誤:{e}")

然後跑跑看腳本,如果能在 Terminal 跟模型對話就成功了,如果失敗,則有可能是 API Key 錯誤或是模型不支援要更換。這裡我們用的模型是llama-3.3-70b-versatile

如果點進去 Groq 官網,會發現它們用的是 Groq()而非 OpenAI()建立 Client,這主要是因為:

  1. 時序上 OpenAI 較早提供 API 服務,在使用者增加後其他公司才後續跟進

  2. OpenAI()也支援連接其他 LLM 的 API 服務,泛用性較高

  3. 觀察網路上的常見的 LLM 服務課程,開發程式碼也以OpenAI()為主

所以就用OpenAI()囉!

base_url 就是伺服器的地址。想像你在寄信(發送請求):

  • 信的內容 (Payload): 你的問題(例如:“你好”)。

  • 信的格式 (Schema): OpenAI 規定的格式(JSON 格式)。

  • 收件地址 (base_url): 這就是關鍵所在。

預設情況 (不寫 base_url):當你只寫 client = OpenAI(api_key=“…”) 時,程式預設的 base_url 是 https://api.openai.com/v1 (直接寄去 OpenAI 總部)。

我們的情況 (修改 base_url):當你加上 base_url=“https://api.groq.com/openai/v1” 時,發生了這件事:你的程式依然使用 OpenAI 的格式打包信件,但是信件被攔截,轉送到了 Groq 的伺服器。Groq 的伺服器看懂了這個格式 (因為它相容),於是處理後回傳結果。

同理,假設我們今天要改成在本地跑 Ollama 模型,可以把 base_url改成自己的電腦地址,ex base_url="http://localhost:11111/v1"

建立後端 API 服務

首先,在乾淨的 venv 環境下安裝套件

pip install fastapi uvicorn

說明:

  1. FastAPI: 用來快速建立網頁伺服器的框架。

  2. Uvicorn: 用來啟動這個伺服器的工具。

在專案資料夾下建立 server.py,內容打一下(一樣要修改API KEY):

from fastapi import FastAPI
from pydantic import BaseModel
from openai import OpenAI

# 1. 初始化 FastAPI APP
app = FastAPI()

# 2. 設定 Groq 連線 (跟上一步一樣)
client = OpenAI(
    api_key="你申請的API Key", 
    base_url="https://api.groq.com/openai/v1"
)

# 3. 定義資料格式:告訴程式,別人傳進來的資料應該長什麼樣子
# 這裡規定:一定要有一個欄位叫做 "message" 且是文字 (str)
class UserInput(BaseModel):
    message: str

# 4. 建立一個 "路徑" (API Endpoint)
# 當有人對網址 /chat 發送 POST 請求時,會執行這個函式
@app.post("/chat")
def chat_with_ai(data: UserInput):
    user_message = data.message
    
    print(f"收到訊息:{user_message}") # 讓你在後台看得到

    try:
        # 呼叫 Groq
        completion = client.chat.completions.create(
            model="llama-3.3-70b-versatile",
            messages=[
                {"role": "system", "content": "你是一個繁體中文 AI 助教。"},
                {"role": "user", "content": user_message}
            ]
        )
        ai_reply = completion.choices[0].message.content
        
        # 回傳結果給前端
        return {"reply": ai_reply}
        
    except Exception as e:
        return {"error": str(e)}

# 5. 測試首頁 (確認伺服器活著)
@app.get("/")
def read_root():
    return {"status": "Server is running!", "course": "Generative AI Engineering"}

這裡使用的測試模型一樣是llama-3.3-70b-versatile。然後用

uvicorn server:app --reload

啟動伺服器,成功時 Terminal 會顯示的訊息

INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
...
...
...
INFO:     ... - "GET / HTTP/1.1" 200 OK
INFO:     ... - "GET /docs HTTP/1.1" 200 OK
INFO:     ... - "GET /openapi.json HTTP/1.1" 200 OK

且到 http://127.0.0.1:8000時,畫面會像這樣

這個成功畫面顯示的就是第五步的 code,但其實第五步不是必須,只是可以直接確認伺服器可正常運作,完全不寫的話 INFO 會跑出 404 Not Found 的訊息,打開網站也會跑出 404 的訊息,但我們可以用一個簡單的方式確認:

  1. 首先打開 API 文件 http://127.0.0.1:8000/docs

  2. 依序點擊

3. 修改 “string”部分(模仿使用者輸入問題)

{
  "message": "string"
}
  1. 按下 Execute 按鈕

  2. 查看下方 Response 部分, Code 200 代表正常運作,且可以看到 Response Body 有模型回應跟 Response Header 紀錄回應時間等資訊。

實務上 API 文件在開發跟檢查問題時都很需要,學起來不是壞事:)

如果連 API KEY 都不能隱藏的話,要怎麼上線!首先再來安裝一個套件

pip install dotenv

接著在同專案資料夾下建立名為 .env 的檔案,建立變數:

API_KEY="你申請的API KEY"

可以用以下腳本試跑看看 Terminal 可不可以成功顯示 API_KEY裡的參數。

import os
from dotenv import load_dotenv

load_dotenv() # 讀取 .env

API_KEY = os.getenv("API_KEY") # 取得 API_KEY 這個參書內容
print(API_KEY)

成功的話再把這段程式貼入 server.py 裡。

網頁服務的建立

上一個 part 的程式只能在後端運作,使用者用不了,所以需要前端讓使用者輸入東西,傳給後端處理。這裡使用比較常見的網頁服務來實作,而且是用最簡單的 HTML 包 CSS 跟 JS 的形式。

不過首先要先讓網頁跟伺服器可以溝通,就要先解決瀏覽器的安全機制 (CORS)。

所謂的 CORS 就是,網頁檔案 (HTML) 和伺服器 (FastAPI) 雖然都在同一台電腦,但在瀏覽器眼中它們是「不同的來源」。如果沒有特別允許,瀏覽器會禁止網頁去讀取伺服器的資料。所以要修改 server.py變成:

# 建立 web
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware  # [NEW] 引入 CORS 套件
from pydantic import BaseModel
from openai import OpenAI

app = FastAPI()

# [NEW] 設定 CORS,允許所有來源 (為了開發方便,我們先全開)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 允許任何網址呼叫這個 API
    allow_credentials=True,
    allow_methods=["*"],  # 允許任何方法 (GET, POST...)
    allow_headers=["*"],
)

client = OpenAI(
    api_key="APIKEY", 
    base_url="https://api.groq.com/openai/v1"
)

# 後面都一樣
參數名稱 設定值 功能說明
allow_origins [“*”] 允許的來源網域。[“*”] 代表「允許所有網站」存取你的 API。在開發階段很方便,但在正式上線(Production)時,建議指定具體的網址(例如 [“https://www.your-app.com”])以策安全。
allow_credentials True 是否允許攜帶憑證。當設為 True 時,跨來源請求可以包含 Cookies、HTTP 認證(Authentication)或 TLS 用戶端憑證。注意:如果此項為 True,allow_origins 最好不要設為 [“*”],有些瀏覽器會因為安全限制而報錯。
allow_methods [“*”] 允許的 HTTP 方法。[“*”] 代表允許所有方法,包括 GET、POST、PUT、DELETE 等。你目前的程式碼中使用了 POST 方法來處理 /chat/upload
allow_headers [“*”] 允許的 HTTP 標頭。[“*”] 代表允許請求攜帶任何自定義標頭(Headers),例如 Content-Type、Authorization 等。

AI 目前給的設定都是安全最低的選擇,有部署需要時需要調整(尤其是allow_origins)。

然後建立網頁index.html

<!DOCTYPE html>
<html lang="zh-TW">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>我的 AI 聊天室</title>
    <style>
        /* 簡單的 CSS 樣式,讓畫面好看一點 */
        body { font-family: sans-serif; max-width: 600px; margin: 20px auto; padding: 0 20px; }
        .chat-box { border: 1px solid #ccc; height: 400px; overflow-y: scroll; padding: 10px; margin-bottom: 10px; border-radius: 5px; background: #f9f9f9; }
        .message { margin: 10px 0; padding: 8px 12px; border-radius: 10px; width: fit-content; max-width: 80%; }
        .user { background-color: #007bff; color: white; margin-left: auto; text-align: right; } /* 使用者訊息靠右 */
        .ai { background-color: #e9ecef; color: black; margin-right: auto; } /* AI 訊息靠左 */
        .input-area { display: flex; gap: 10px; }
        input { flex: 1; padding: 10px; border-radius: 5px; border: 1px solid #ddd; }
        button { padding: 10px 20px; background-color: #28a745; color: white; border: none; border-radius: 5px; cursor: pointer; }
        button:hover { background-color: #218838; }
        button:disabled { background-color: #ccc; }
    </style>
</head>
<body>

    <h2>🤖 我的生成式 AI 助手</h2>
    
    <div class="chat-box" id="chat-box">
        <div class="message ai">你好!我是你的 AI 助教,有什麼我可以幫你的嗎?</div>
    </div>

    <div class="input-area">
        <input type="text" id="user-input" placeholder="輸入你的問題..." onkeypress="handleEnter(event)">
        <button onclick="sendMessage()" id="send-btn">發送</button>
    </div>

    <script>
        // 3. JavaScript 邏輯區:負責跟後端講話
        async function sendMessage() {
            const inputField = document.getElementById("user-input");
            const sendBtn = document.getElementById("send-btn");
            const chatBox = document.getElementById("chat-box");
            const message = inputField.value.trim();

            if (!message) return; // 如果沒輸入字就不理會

            // 顯示使用者的訊息
            appendMessage(message, "user");
            inputField.value = ""; // 清空輸入框
            sendBtn.disabled = true; // 發送中禁止按按鈕
            sendBtn.innerText = "思考中...";

            try {
                // --- 關鍵步驟:呼叫你的 FastAPI 後端 ---
                const response = await fetch("http://127.0.0.1:8000/chat", {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ message: message })
                });

                const data = await response.json();
                
                // 顯示 AI 的回答
                if (data.reply) {
                    appendMessage(data.reply, "ai");
                } else {
                    appendMessage("錯誤:" + JSON.stringify(data), "ai");
                }

            } catch (error) {
                appendMessage("連線失敗,請確認後端伺服器有開啟。", "ai");
                console.error(error);
            }

            // 恢復按鈕狀態
            sendBtn.disabled = false;
            sendBtn.innerText = "發送";
        }

        // 輔助功能:把訊息加到畫面上
        function appendMessage(text, sender) {
            const chatBox = document.getElementById("chat-box");
            const div = document.createElement("div");
            div.classList.add("message", sender);
            div.innerText = text;
            chatBox.appendChild(div);
            chatBox.scrollTop = chatBox.scrollHeight; // 自動捲動到底部
        }

        // 輔助功能:按 Enter 鍵也能發送
        function handleEnter(event) {
            if (event.key === "Enter") sendMessage();
        }
    </script>
</body>
</html>

然後在伺服器已啟動的前提下用瀏覽器開啟網頁,成功時可以正常跟它對話,正常流程為:

  1. Frontend (HTML/JS): 瀏覽器抓取你輸入的文字。

  2. API Call: 瀏覽器發送請求給 http://127.0.0.1:8000/chat。

  3. Backend (FastAPI): 你的 Python 程式收到請求,轉頭去問 Groq。

  4. LLM: Groq 運算完,把結果傳回 Python。

  5. Render: Python 把結果傳回瀏覽器,JavaScript 把文字貼在畫面上。

但它還不具備逐字載入與上下文記憶功能。

細節微調

讓網頁逐字載入回應

先修改 serve.py,首先在套件部分加入

from fastapi.responses import StreamingResponse # [NEW] 引入串流回應

再去修改回傳函數

# [NEW] 這裡我們不直接回傳,而是寫一個 "產生器 (Generator)"
# 它的功能是:一邊收 Groq 的資料,一邊吐給前端
def get_ai_response_stream(user_message: str):
    try:
        completion = client.chat.completions.create(
            model="llama-3.3-70b-versatile",
            messages=[
                {"role": "system", "content": "你是一個繁體中文 AI 助教。"},
                {"role": "user", "content": user_message}
            ],
            stream=True  # [關鍵] 告訴 Groq 我們要用串流模式
        )
        
        for chunk in completion:
            # 檢查這個碎片有沒有內容
            content = chunk.choices[0].delta.content
            if content:
                yield content  # "yield" 意思是產出一個碎片就馬上送出去
                
    except Exception as e:
        yield f"Error: {str(e)}"

@app.post("/chat")
def chat_with_ai(data: UserInput):
    print(f"收到訊息 (串流模式):{data.message}")
    
    # 回傳一個 "串流回應",而不是一般的 JSON
    return StreamingResponse(
        get_ai_response_stream(data.message), 
        media_type="text/plain"
    )

再來改前端部分,主要是要修改 Javascripts 部分,讓回應可以產生逐字載入的動畫效果:

<script>
        async function sendMessage() {
            const inputField = document.getElementById("user-input");
            const sendBtn = document.getElementById("send-btn");
            const chatBox = document.getElementById("chat-box");
            const message = inputField.value.trim();

            if (!message) return;

            // 1. 顯示使用者訊息
            appendMessage(message, "user");
            inputField.value = "";
            sendBtn.disabled = true;
            sendBtn.innerText = "思考中...";

            // 2. 準備接收 AI 的回答 (先建立一個空的對話框)
            const aiMessageDiv = appendMessage("", "ai"); 

            try {
                const response = await fetch("http://127.0.0.1:8000/chat", {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ message: message })
                });

                // [NEW] 處理串流資料的關鍵邏輯
                const reader = response.body.getReader();
                const decoder = new TextDecoder("utf-8");

                while (true) {
                    const { done, value } = await reader.read();
                    if (done) break; // 如果讀完了就跳出迴圈

                    // 把讀到的二進位資料轉成文字
                    const chunk = decoder.decode(value, { stream: true });
                    
                    // 把文字 "追加" 到目前的對話框裡,而不是覆蓋
                    aiMessageDiv.innerText += chunk;
                    
                    // 自動捲動到底部
                    chatBox.scrollTop = chatBox.scrollHeight;
                }

            } catch (error) {
                aiMessageDiv.innerText = "連線發生錯誤。";
                console.error(error);
            }

            sendBtn.disabled = false;
            sendBtn.innerText = "發送";
        }

        // 輔助功能:建立訊息框並回傳該元素 (讓我們可以持續更新它)
        function appendMessage(text, sender) {
            const chatBox = document.getElementById("chat-box");
            const div = document.createElement("div");
            div.classList.add("message", sender);
            div.innerText = text;
            chatBox.appendChild(div);
            chatBox.scrollTop = chatBox.scrollHeight;
            return div; // 回傳這個 div,讓外面的程式可以繼續塞字進去
        }

        function handleEnter(event) {
            if (event.key === "Enter") sendMessage();
        }
    </script>

不意外的超級長,根本可以拆成獨立檔案了

測試有沒有成功跟前面一樣,只要確定伺服器有再跑,網頁重整後輸入 Prompt 觀察即可。

上下文記憶

LLM 本質上是 「無狀態 (Stateless)」 的,意思就是說:每一次呼叫 API,對它來說都是全新的開始,它完全不記得上一秒發生了什麼。所以我們需要把之前的對話紀錄,全部打包再一次寄給它,讓它看起來好像具備了記憶能力。

一樣先修改 serve.py,首先在套件部分加入

from typing import List, Dict # [NEW] 引入型別定義

再去修改資料模型與回傳函數:

# [NEW] 修改資料模型:不再只是單一字串,而是接受一個列表 (List)
# 格式會像這樣:[{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}]
class UserInput(BaseModel):
    messages: List[Dict[str, str]]

def get_ai_response_stream(messages_history: List[Dict[str, str]]):
    try:
        # [NEW] 這裡不再是寫死的 system prompt + user message
        # 而是直接把前端傳來的 "整包歷史紀錄" 丟給 Groq
        
        # 我們可以在最前面偷偷加一個 System Prompt 設定 AI 人設
        system_prompt = [{"role": "system", "content": "你是一個繁體中文 AI 助教,記憶力很好。"}]
        full_context = system_prompt + messages_history

        completion = client.chat.completions.create(
            model="llama-3.3-70b-versatile",
            messages=full_context, # 這裡放入完整的對話歷史
            stream=True
        )
        
        for chunk in completion:
            content = chunk.choices[0].delta.content
            if content:
                yield content

    except Exception as e:
        yield f"Error: {str(e)}"

@app.post("/chat")
def chat_with_ai(data: UserInput):
    # print(f"收到歷史對話,長度:{len(data.messages)}") # 除錯用
    return StreamingResponse(
        get_ai_response_stream(data.messages), 
        media_type="text/plain"
    )

可以觀察到除了程式越來越長以外,送給模型的不再只有 System Prompt,也有歷史對話訊息。

再來需要修改前端,讓它可以記錄之前的對話訊息,送入後端,一樣改index.html的 JavaScripts 部分:

<script>
        // [NEW] 用來暫存對話紀錄的變數
        let conversationHistory = [];

        async function sendMessage() {
            const inputField = document.getElementById("user-input");
            const sendBtn = document.getElementById("send-btn");
            const chatBox = document.getElementById("chat-box");
            const message = inputField.value.trim();

            if (!message) return;

            // 1. 顯示並紀錄使用者的訊息
            appendMessage(message, "user");
            // [NEW] 把使用者的話加入歷史紀錄
            conversationHistory.push({ "role": "user", "content": message });

            inputField.value = "";
            sendBtn.disabled = true;
            sendBtn.innerText = "思考中...";

            const aiMessageDiv = appendMessage("", "ai"); 
            let fullAiResponse = ""; // 用來收集 AI 的完整回答

            try {
                // [NEW] 傳送 "整包歷史紀錄" 給後端
                const response = await fetch("http://127.0.0.1:8000/chat", {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ messages: conversationHistory }) 
                });

                const reader = response.body.getReader();
                const decoder = new TextDecoder("utf-8");

                while (true) {
                    const { done, value } = await reader.read();
                    if (done) break;

                    const chunk = decoder.decode(value, { stream: true });
                    aiMessageDiv.innerText += chunk;
                    fullAiResponse += chunk; // 收集碎片
                    chatBox.scrollTop = chatBox.scrollHeight;
                }
                
                // [NEW] AI 講完後,把它的話也加入歷史紀錄
                conversationHistory.push({ "role": "assistant", "content": fullAiResponse });

            } catch (error) {
                aiMessageDiv.innerText = "連線發生錯誤。";
                console.error(error);
            }

            sendBtn.disabled = false;
            sendBtn.innerText = "發送";
        }

        function appendMessage(text, sender) {
            const chatBox = document.getElementById("chat-box");
            const div = document.createElement("div");
            div.classList.add("message", sender);
            div.innerText = text;
            chatBox.appendChild(div);
            chatBox.scrollTop = chatBox.scrollHeight;
            return div;
        }

        function handleEnter(event) {
            if (event.key === "Enter") sendMessage();
        }
    </script>

最後重整網頁,輸入需要上下文記憶的問題,看它能不能正確回應、順順的跟你聊下去。

RAG 系統

重點終於來啦,RAG 的概念因為我已經學過,就不贅述,直接進入程式部分。

先建立簡單的 RAG

安裝套件

pip install sentence-transformers scikit-learn

然後建立一個簡單的資料來源faq_data.py

# 這只是一個簡單的模擬資料庫 (Dictionary)
# 實務上這裡會是 PDF 檔案或資料庫
knowledge_base = [
    {
        "id": 1,
        "content": "關於請假:本公司員工每年享有 14 天特休,請假需提前 3 天在 HR 系統申請。"
    },
    {
        "id": 2,
        "content": "關於加班費:平日加班費為 1.33 倍,假日加班費為 1.66 倍,需經理核准。"
    },
    {
        "id": 3,
        "content": "關於午餐:公司每日中午 12:00 提供免費便當,素食者需在 10:00 前登記。"
    },
    {
        "id": 4,
        "content": "關於在家工作 (WFH):每週三為固定 WFH 日,其餘時間需進辦公室。"
    }
]

建立一個新的 rag_service.py

from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
from faq_data import knowledge_base
import numpy as np

print("正在載入 Embedding 模型 (第一次會比較久)...")
# 下載一個輕量級的中文模型 (這會存在你的電腦裡)
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')

# 1. 先把知識庫裡的每一條資料,都轉換成「向量 (Vector)」並存起來
# 這樣待會搜尋速度才會快
print("正在建立索引...")
knowledge_texts = [item["content"] for item in knowledge_base]
knowledge_embeddings = model.encode(knowledge_texts)
print("索引建立完成!")

def search_knowledge_base(query, top_k=1):
    """
    輸入使用者的問題 (query),回傳最相似的 k 筆資料
    """
    # 2. 把使用者的問題也變成向量
    query_embedding = model.encode([query])
    
    # 3. 計算相似度 (Cosine Similarity)
    # 比較「問題向量」跟所有「知識庫向量」的距離
    similarities = cosine_similarity(query_embedding, knowledge_embeddings)
    
    # 4. 找出分數最高的 top_k 個結果
    # argsort 會回傳排序後的索引,我們取最後 top_k 個 (分數最高的)
    top_indices = similarities[0].argsort()[-top_k:][::-1]
    
    results = []
    for idx in top_indices:
        score = similarities[0][idx]
        if score > 0.3: # 設定一個門檻,相關度太低就不採用 (避免誤導)
            results.append(knowledge_base[idx]["content"])
            
    return results

# 測試用 (當你直接執行這個檔案時會跑這段)
if __name__ == "__main__":
    test_query = "請問加班有錢拿嗎?"
    print(f"\n測試搜尋:{test_query}")
    print(search_knowledge_base(test_query))

跑這支腳本,成功的話會出現資料庫裡,加班費的計算方式。

整合進後端

修改 server.py 的套件部分跟get_ai_response_stream()

from rag_service import search_knowledge_base # [NEW] 引入搜尋功能

這裡抓的是同資料夾下,rag_service.pysearch_knowledge_base() 函數,如果是放在專案資料夾下的另一個資料夾(假設叫 another_folder )則需這樣讀取

from another_folder.rag_service import search_knowledge_base # [NEW] 引入搜尋功能
# ... (中間設定不變)
def get_ai_response_stream(messages_history: List[Dict[str, str]]):
    try:
        # 1. 抓出使用者最新的一句話
        latest_user_message = messages_history[-1]["content"]
        
        # 2. [RAG 核心] 先去知識庫搜尋有沒有相關資料
        print(f"正在搜尋知識庫:{latest_user_message}")
        retrieved_info = search_knowledge_base(latest_user_message)
        
        system_instruction = "你是一個繁體中文 AI 助教。"
        
        # 3. 如果有搜到資料,就把它塞進 System Prompt 裡
        if retrieved_info:
            print(f"搜到資料:{retrieved_info}")
            context_str = "\n".join(retrieved_info)
            # 這裡就是 RAG 的精隨:把資料貼給 AI 看
            system_instruction += f"\n\n【參考資料】:\n{context_str}\n\n請根據上述【參考資料】回答使用者的問題。如果資料裡沒有答案,請說不知道,不要瞎掰。"
        else:
            print("沒搜到相關資料")

        # 重新組裝 messages
        # 我們把原本的 messages_history 保留,但把第一句 system prompt 換掉
        current_messages = [{"role": "system", "content": system_instruction}] 
        
        # 把使用者之前的對話紀錄接在後面 (除了第一句 system prompt)
        # 注意:這裡邏輯要小心,我們假設 messages_history 都是 user/assistant 的對話
        for msg in messages_history:
             if msg["role"] != "system":
                 current_messages.append(msg)

        completion = client.chat.completions.create(
            model="llama-3.3-70b-versatile",
            messages=current_messages,
            stream=True
        )
        
        for chunk in completion:
            content = chunk.choices[0].delta.content
            if content:
                yield content

    except Exception as e:
        yield f"Error: {str(e)}"

# ... (後面不變)

前端不用改,直接進入網頁測試

  1. 測試 1 (基本題): 問它:「你好。」(它應該正常回答)

  2. 測試 2 (RAG 題): 問它:「請問公司中午有便當吃嗎?」

  3. 測試 3 (RAG 題): 問它:「我想申請 WFH,星期幾可以?」

若有成功按照資料庫回答問題表示成功。

警告

啟動伺服器時,首次執行會進行 Embedding 步驟,耗時較長,需耐心等候,如果要加快速度,實務上會用 Chrome DB 儲存切好 chunk的資料。

暫時性結論

其實它後面還介紹了上傳 PDF 、包成 Docker 之類的服務,但我累惹先停在這,不過還是沒有介紹到課綱裡的前端框架跟服務,感覺它完全自己走自己的==。

後面的東西有空再來繼續,希望可以實現包 Docker 成功初體驗~~

有用的參考資料

官方文件絕對是最好的一手資料來源:

其次是其他工程師寫過的文章

無符合的項目

腳註

  1. AI 多半會直接給,不過我還是列一下從搜尋結果進入官網的步驟。↩︎