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

LLM網頁服務應用(2)

toys
LLM
RAG
作者

紙魚

發佈於

2026年2月26日

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

拖到開學才寫完:P。


讓使用者上傳 PDF 並即時學習

要實作的功能是:使用者在網頁上丟一個 PDF 檔案,系統自動讀取、切割文字、轉成向量,然後 AI 下一秒就能回答裡面的內容。

裝套件

要裝兩個東西

pip install pypdf python-multipart
  • pypdf: 用來讀取 PDF 檔案的文字。

  • python-multipart: 讓 FastAPI 可以接收檔案上傳。

改 RAG 服務的函數

再來要調整 rag_service.py,讓它可以吃 PDF。

from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
from pypdf import PdfReader # [NEW]
import numpy as np

print("正在載入 Embedding 模型...")
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')

# 這次我們不預設資料,改用一個空的列表,等使用者上傳
knowledge_base = []
knowledge_embeddings = None

def add_pdf_to_knowledge_base(file_stream):
    """
    讀取 PDF -> 抓文字 -> 切割 -> 轉向量 -> 存入知識庫
    """
    global knowledge_base, knowledge_embeddings
    
    # 1. 讀取 PDF 文字
    reader = PdfReader(file_stream)
    text = ""
    for page in reader.pages:
        text += page.extract_text() + "\n"
    
    # 2. 文字切割 (Chunking)
    # LLM 一次讀不完這麽多字,我們要切成小塊 (例如每 200 字一塊)
    chunk_size = 200
    chunks = [text[i:i+chunk_size] for i in range(0, len(text), chunk_size)]
    
    print(f"PDF 處理完畢,切成 {len(chunks)} 個片段")

    # 3. 更新知識庫
    new_items = [{"id": len(knowledge_base) + i, "content": chunk} for i, chunk in enumerate(chunks)]
    knowledge_base.extend(new_items)
    
    # 4. 重新計算向量索引 (簡單暴力法:全部重算)
    # 在真實系統中,我們會用 Vector DB (如 ChromaDB) 來處理,不用每次重算
    texts = [item["content"] for item in knowledge_base]
    knowledge_embeddings = model.encode(texts)
    
    return len(chunks)

def search_knowledge_base(query, top_k=3):
    """
    搜尋功能 (跟之前一樣,但加了防呆機制)
    """
    global knowledge_embeddings
    
    # 如果知識庫是空的,直接回傳空
    if not knowledge_base or knowledge_embeddings is None:
        return []

    query_embedding = model.encode([query])
    similarities = cosine_similarity(query_embedding, knowledge_embeddings)
    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

改後端

使用者上傳的檔案要用 API 才能傳到後端,因此要修改 server.py

# ... (前面的 import 不變)
from fastapi import UploadFile, File # [NEW] 引入檔案處理模組
from rag_service import search_knowledge_base, add_pdf_to_knowledge_base # [NEW] 引入新函式

# ... (app = FastAPI... 中間設定不變) ...

# [NEW] 上傳檔案的 API
@app.post("/upload")
async def upload_file(file: UploadFile = File(...)):
    try:
        # 把檔案內容傳給 rag_service 處理
        chunk_count = add_pdf_to_knowledge_base(file.file)
        return {"message": f"成功讀取 {file.filename},新增了 {chunk_count} 個知識片段!"}
    except Exception as e:
        return {"error": str(e)}

# ... (chat_with_ai 函式保持不變) ...
# 注意:記得確認 chat_with_ai 裡面有呼叫 search_knowledge_base

改前端

最後修改前端,先在 HTML 區建一個讓使用者可以按下「📚 知識庫上傳」的按鈕,上傳 PDF

<!-- 在標題下方 -->
<div style="margin-bottom: 10px; padding: 10px; background: #fff3cd; border: 1px solid #ffeeba; border-radius: 5px;">
    <h3>📚 知識庫上傳</h3>
    <input type="file" id="pdf-upload" accept=".pdf">
    <button onclick="uploadPDF()" style="background-color: #ffc107; color: black;">上傳 PDF</button>
    <span id="upload-status" style="margin-left: 10px; font-size: 0.9em; color: #666;"></span>
</div>

<div class="chat-box" id="chat-box">
    </div>

再到 js 區新增接收檔案傳進後端的函數

async function uploadPDF() {
            const fileInput = document.getElementById("pdf-upload");
            const statusText = document.getElementById("upload-status");
            const file = fileInput.files[0];

            if (!file) {
                alert("請先選擇 PDF 檔案!");
                return;
            }

            statusText.innerText = "正在處理中... (請稍候)";

            const formData = new FormData();
            formData.append("file", file);

            try {
                const response = await fetch("http://127.0.0.1:8000/upload", {
                    method: "POST",
                    body: formData
                });
                
                const data = await response.json();
                if (data.message) {
                    statusText.innerText = "✅ " + data.message;
                    statusText.style.color = "green";
                } else {
                    statusText.innerText = "❌ 上傳失敗:" + data.error;
                    statusText.style.color = "red";
                }
            } catch (error) {
                console.error(error);
                statusText.innerText = "❌ 連線錯誤";
            }
        }

測試

  1. 準備好一個有專屬資訊的PDF (例如課綱)

  2. 啟動後端 uvicorn server:app --reload,確認正常運作

  3. 開啟網站

  4. 先輸入一個只有 PDF 有寫的問題,預期輸出為不知道

  5. 上傳檔案,再問一次,預期輸出要能回答相關問題

將系統打包成 Docker 容器

讓系統無論在哪都可以跑。

安裝 Docker

首次使用需要此步,Windows 安裝前提條件:

  • Windows 10 64-bit:版本 1903 或更新版
  • 啟用 WSL 2(Windows Subsystem for Linux)

安裝步驟:

  1. 安裝 WSL 2(如尚未安裝)

    • 開啟 PowerShell(以系統管理員身份)
    wsl --install
    • 安裝完成後重新啟動電腦。
  2. 下載 Docker Desktop for Windows

  3. 安裝並啟動 Docker Desktop

    • 執行安裝檔
    • 勾選「使用 WSL 2」作為後端
    • 安裝完成後啟動 Docker Desktop 並登入 Docker Hub(可註冊免費帳號)
  4. 確認 Docker 安裝是否成功

    docker --version
    docker run hello-world

產出 requirements.txt

用來告訴 Docker 內的乾淨環境:需要安裝哪些東西。

pip freeze > requirements.txt

會看到資料夾多了一個 requirements.txt 檔案,打開來檢查,裡面應該有 fastapi, uvicorn, openai, sentence-transformers 等等。

寫 Dockerfile

寫一個腳本,告訴 Docker 怎麼打包。

  1. 在專案根目錄建立一個新檔案,檔名就叫 Dockerfile (沒有副檔名)。

  2. 貼上以下內容:

# 1. 選擇基底映像檔 (就像選擇要灌什麼作業系統)
# 我們選一個輕量級的 Python 3.12 環境
FROM python:3.12-slim

# 2. 設定工作目錄 (在箱子裡建立一個 /app 資料夾)
WORKDIR /app

# 3. 把你電腦裡的 requirements.txt 複製進箱子
COPY requirements.txt .

# 4. 在箱子裡安裝套件
# --no-cache-dir 可以讓箱子小一點
RUN pip install --no-cache-dir -r requirements.txt

# 5. 把你電腦裡的所有程式碼 (main.py, server.py...) 複製進箱子
COPY . .

# 6. 告訴 Docker,這個程式會開 8000 port
EXPOSE 8000

# 7. 當箱子啟動時,要執行的指令
# 注意:這裡的 --host 0.0.0.0 是關鍵,一定要設成這樣外部才連得進去
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]

用指令開始 build

docker build -t my-ai-system .

my-ai-system 就是會在 Docker 桌面版的 images 頁面裡看到的名稱。

Build 常見問題

安裝不了套件

有時候前面都很正常但裝到某些套件時突然說裝不了,因為套件的版本沒有這麼新的。這時只要先確認目前使用的虛擬環境的 python 版本,將對應 version 的內容修改成與虛擬環境一致,通常就沒問題了。

如果還有問題可以上DockerHub 上 Ptyhon 官方 image找出對應的版本,除非安裝的版本太新,不然應該都會有。

目前的問題

Docker 包起來的容量很大

最後一次包起來在 Docker images page 上居然顯示 13 GB ,好扯==。因為沒有任何向量資料庫,大成這樣其實很不尋常。

要如何測試 RAG 效能

目前這個的玩具的設定對稍微長一點的文本檢索機制不佳,回應也有幻覺問題。本來想說既然 AI 幫我生出這個小玩具,那我再加入 LangChain 或 Huggingface 的生態系、改一改模型、加入不同的檢索策略跟 Reranker 機制,以及針對特定需求作客製化後,就會是個實用小玩具了,可是要怎麼好好評估這些機制實在很困擾我。雖然之前有寫過製作 RAG 的作業,也跑過 kaggle 上的競賽,但這些都有提供一定數量的測試集,裡面都會附上問題與解答,但現在這個小玩具的情境裡:

  1. 我需要針對我落地的情境設計問題與確定解答,但我沒有很多時間製作測試集,可能只能針對提問的種類設計幾個問題與解答,再就這些小樣本推論回應品質。

  2. 文本要自行上傳,且資料格式是沒有標準儲存格式的 PDF,1如果讓給其他使用者使用、上傳其他來源的資料,回應的品質會變得難以掌握。除非我能列出所有可能面臨的使用情境,並且設計出好的解決策略,讓使用體驗良好,但這對初次設計的我來說太不切實際了。

我覺得第二個問題是最棘手的,所以我後來決定重新做一個不能上傳資料的封閉式 RAG 系統,先解決資料格式與來源不一的問題。這個小玩具就先放著,以後再想想看要怎麼調整。

後續

後來我就爬了一些舊聞,做了網路考古學家的小玩具,目標是滿足當歷史學家的慾望。不過還是有遇到一些問題,加上後來過年了就偷懶,但還是希望能趕快解決,順利上線後再分享。

然後雖然最後沒辦法上生成式AI應用系統與工程,不過昨天透過學校的 Email 提供的連結旁聽了一下,想不到居然是著重跟 AI Agent 協作的課,跟我(還有 Geimei)之前想的完全不一樣 XD,滿有趣的。雖然我猜這要應用在職場上還需要一段時間(取決於公司),不過有機會還是看看有沒有甚麼小撇步可以偷學XD

無符合的項目

腳註

  1. 舉例來說,有的 PDF 是圖片轉成的,沒有辦法擷取出文字;也有的 PDF 會有反爬蟲機制,試圖用程式開啟並擷取PDF會爬到一堆亂碼。這些都會影響模型生成回答的正確性。↩︎