Key Findings
  • Spring AI 的 ETL Pipeline(Extract → Transform → Load)是知識庫建置的標準流程——從讀取原始檔案、切割成段落、到存入向量資料庫
  • 支援多種格式的文件讀取:TXT、JSON、CSV、Markdown 可直接處理,PDF、Word、PPT、Excel 透過 Apache Tika 整合
  • TokenTextSplitter 是切割的核心工具——預設 800 Token/段落、350 Token 重疊,但實務中應根據文件類型調整(FAQ 用 200、一般文件用 400~500)
  • 章節標題自訂切割比固定長度更精準——保留文件的邏輯結構,搜尋結果的語意完整性大幅提升

ETL Pipeline:知識庫建置的三步驟

在 Lesson 6~7 中,我們手動建立 Document 物件來模擬知識庫。但真實企業有成百上千的文件——員工手冊、技術指南、會議紀錄、FAQ——分散在不同格式中。Spring AI 的 ETL Pipeline[1] 就是為了解決這個問題:

═══ ETL Pipeline ═══

【Extract 提取】          【Transform 轉換】         【Load 載入】
DocumentReader            TextSplitter              VectorStore
                          + Metadata
TXT ─┐                    ┌→ 段落 1 ─┐
JSON ┤→ Raw Documents ──→ ├→ 段落 2 ─┤→ Vector DB
CSV  ┤                    ├→ 段落 3 ─┤
PDF  ┘                    └→ 段落 N ─┘
為什麼要切割? 一份 50 頁的員工手冊整份存入向量資料庫,搜尋「年假幾天」會得到整份手冊——太雜。切割成小段落後,搜尋只會命中「年假規定」那一段——精準。

Step 1:環境準備

開啟課程專案的 Lesson9/Lesson9_DocumentProcessing.ipynb。這堂課多引入了 spring-ai-tika-document-reader

@file:DependsOn("org.springframework.ai:spring-ai-openai:1.0.0")
@file:DependsOn("org.springframework.ai:spring-ai-client-chat:1.0.0")
@file:DependsOn("org.springframework.ai:spring-ai-vector-store:1.0.0")
@file:DependsOn("org.springframework.ai:spring-ai-advisors-vector-store:1.0.0")
@file:DependsOn("org.springframework.ai:spring-ai-tika-document-reader:1.0.0")
@file:DependsOn("org.slf4j:slf4j-simple:2.0.16")

import org.springframework.ai.openai.*
import org.springframework.ai.openai.api.OpenAiApi
import org.springframework.ai.chat.client.ChatClient
import org.springframework.ai.vectorstore.SimpleVectorStore
import org.springframework.ai.vectorstore.SearchRequest
import org.springframework.ai.document.Document
import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor
import org.springframework.ai.transformer.splitter.TokenTextSplitter
import java.io.File

val apiKey = System.getenv("OPENAI_API_KEY")
    ?: error("請先設定 OPENAI_API_KEY(參考 Lesson 0)")

val openAiApi = OpenAiApi.builder().apiKey(apiKey).build()
val chatModel = OpenAiChatModel.builder().openAiApi(openAiApi).build()
val embeddingModel = OpenAiEmbeddingModel(openAiApi)
val chatClient = ChatClient.builder(chatModel).build()
val vectorStore = SimpleVectorStore.builder(embeddingModel).build()

println("✓ 環境準備完成")

Step 2:Extract——多格式文件讀取

格式一:TXT 純文字

最基礎的格式,直接讀取檔案內容:

val txtFile = File("sample-docs/company-policy.txt")
val txtContent = txtFile.readText()

println("=== TXT 文件 ===")
println("檔名: ${txtFile.name}")
println("大小: ${txtContent.length} 字")
println("前 200 字: ${txtContent.take(200)}...")

課程專案的 company-policy.txt 是一份模擬的員工手冊(2025 年版),包含 5 個章節:工作制度、休假、薪酬福利、績效考核、資訊安全。

格式二:JSON 結構化資料

JSON 檔案通常有明確的結構,可以解析出各區段並附上 Metadata:

val jsonFile = File("sample-docs/tech-guide.json")
val jsonContent = jsonFile.readText()

val jsonDocs = mutableListOf<Document>()
val sectionRegex = """"content"\s*:\s*"([^"]+)"""".toRegex()
val nameRegex = """"name"\s*:\s*"([^"]+)"""".toRegex()

val names = nameRegex.findAll(jsonContent).map { it.groupValues[1] }.toList()
val contents = sectionRegex.findAll(jsonContent).map { it.groupValues[1] }.toList()

for (i in names.indices) {
    jsonDocs.add(Document(
        contents[i],
        mapOf("source" to "tech-guide.json", "section" to names[i], "format" to "json")
    ))
}

println("解析出 ${jsonDocs.size} 個段落:")
jsonDocs.forEach { println("  - [${it.metadata["section"]}]") }

預期輸出:

解析出 4 個段落:
  - [向量資料庫選擇]
  - [Embedding 模型選擇]
  - [AI 模型選擇]
  - [部署架構]

格式三:CSV 表格資料

CSV 適合 FAQ、問答集等結構化資料:

val csvFile = File("sample-docs/faq.csv")
val csvLines = csvFile.readLines()

val csvDocs = csvLines.drop(1).map { line ->
    val values = line.split(",")
    val question = values.getOrElse(0) { "" }
    val answer = values.getOrElse(1) { "" }
    val category = values.getOrElse(2) { "" }
    Document(
        "問:$question\n答:$answer",
        mapOf("source" to "faq.csv", "category" to category, "format" to "csv")
    )
}

println("解析出 ${csvDocs.size} 筆 FAQ")

格式四:Markdown

Markdown 檔案可以按標題(#####)自然分割:

val mdFile = File("sample-docs/meeting-notes.md")
val mdContent = mdFile.readText()

val mdDocs = mdContent.split(Regex("(?=^#{2,3}\\s)", RegexOption.MULTILINE))
    .filter { it.trim().length > 20 }
    .map { section ->
        Document(section.trim(), mapOf("source" to "meeting-notes.md", "format" to "markdown"))
    }

println("解析出 ${mdDocs.size} 個段落")

格式五:PDF / Word / PPT(Apache Tika)

對於二進位格式,Spring AI 整合了 Apache Tika[4]

import org.springframework.ai.reader.tika.TikaDocumentReader
import org.springframework.core.io.FileSystemResource

// 一行程式碼讀取 PDF
val pdfReader = TikaDocumentReader(FileSystemResource("sample-docs/report.pdf"))
val pdfDocs = pdfReader.read()
println("PDF 解析出 ${pdfDocs.size} 個文件")

各格式的處理方式總覽:

格式處理方式需要 Tika?
TXTFile.readText()
JSON正則/Jackson 解析
CSVreadLines() + split
Markdown按標題正則分割
PDF / Word / PPT / ExcelTikaDocumentReader

Step 3:Transform——文件切割策略

方法一:TokenTextSplitter(自動切割)

TokenTextSplitter[3] 按 Token 數量自動切割,適合大部分場景:

val fullDoc = Document(
    txtContent,
    mapOf("source" to "company-policy.txt", "format" to "txt")
)

println("原始文件: ${fullDoc.text?.length ?: 0} 字")

val splitter = TokenTextSplitter.builder()
    .withChunkSize(300)          // 每段最多 300 Token
    .withMinChunkSizeChars(50)   // 最小段落 50 字元
    .withKeepSeparator(true)     // 保留分隔符
    .build()

val chunks = splitter.split(listOf(fullDoc))

println("切割後: ${chunks.size} 個段落")
chunks.forEachIndexed { i, chunk ->
    println("  段落 ${i + 1}: ${chunk.text?.length ?: 0} 字")
}

預期輸出:

原始文件: 1292 字
切割後: 7 個段落
  段落 1: 211 字
  段落 2: 167 字
  段落 3: 215 字
  ...共 7 個段落

方法二:按章節標題自訂切割(推薦)

固定長度切割可能切斷語意。更好的方式是按文件本身的邏輯結構——章節標題——來分割:

fun splitBySection(content: String, source: String): List<Document> {
    val sections = content.split(
        Regex("(?=^第[一二三四五六七八九十]+章|^\\d+\\.\\d+\\s)", RegexOption.MULTILINE)
    ).filter { it.trim().length > 20 }

    return sections.map { section ->
        val firstLine = section.trim().lines().first()
        val chapter = when {
            firstLine.startsWith("第") -> firstLine.trim()
            firstLine.matches(Regex("\\d+\\.\\d+.*")) -> firstLine.trim()
            else -> "其他"
        }
        Document(
            section.trim(),
            mapOf("source" to source, "section" to chapter, "format" to "txt")
        )
    }
}

val sectionDocs = splitBySection(txtContent, "company-policy.txt")

println("按標題切割: ${sectionDocs.size} 個段落")
sectionDocs.forEach { doc ->
    println("  [${doc.metadata["section"]}] ${doc.text?.length ?: 0} 字")
}

預期輸出:

按標題切割: 16 個段落
  [1.1 彈性工時] 78 字
  [1.2 遠距工作] 65 字
  [2.1 年假] 92 字
  [2.2 病假] 68 字
  ...每段都是完整的規定

Chunk 大小的選擇建議:

文件類型建議 Chunk 大小重疊(Overlap)說明
FAQ / 問答集200 Token不需要每筆 FAQ 獨立
一般文件400~500 Token50~100 Token平衡精準度與上下文
長篇報告800~1000 Token100~200 Token需要更多上下文
結構化文件按標題切割不需要保留邏輯結構最佳

Step 4:Load——載入向量資料庫

將所有來源的文件整合並載入:

val allDocs = mutableListOf<Document>()
allDocs.addAll(sectionDocs)   // TXT: 16 段
allDocs.addAll(jsonDocs)       // JSON: 4 段
allDocs.addAll(mdDocs)         // Markdown: 8 段
allDocs.addAll(csvDocs)        // CSV: 10 段

vectorStore.add(allDocs)

println("✓ 已載入 ${allDocs.size} 個段落(來自 4 種格式)")

預期輸出:

✓ 已載入 38 個段落(來自 4 種格式)

Step 5:驗證搜尋品質

載入完成後,用各種查詢測試搜尋結果是否精準:

val testQueries = listOf(
    "年假有幾天?",
    "向量資料庫怎麼選?",
    "AI 知識庫什麼時候上線?",
    "密碼規定是什麼?"
)

testQueries.forEach { query ->
    val results = vectorStore.similaritySearch(
        SearchRequest.builder().query(query).topK(2).build()
    )
    println("\n❓ $query")
    results.forEachIndexed { i, doc ->
        println("  ${i + 1}. [${doc.metadata["source"]} / ${doc.metadata["section"] ?: doc.metadata["category"]}]")
        println("     ${doc.text?.take(60)}...")
    }
}

每個查詢都能精準命中對應來源:年假 → company-policy.txt 第 2.1 節、向量資料庫 → tech-guide.json、AI 知識庫 → meeting-notes.md、密碼 → company-policy.txt 第 5 章。

Step 6:完整 RAG 問答系統

QuestionAnswerAdvisor 建立跨來源的 RAG 問答:

val ragClient = ChatClient.builder(chatModel)
    .defaultSystem("""你是公司的智能助理,根據知識庫中的文件回答問題。
        規則:
        1. 只根據檢索到的文件回答,不要編造
        2. 回答末尾標注資訊來源
        3. 用繁體中文回答,語氣親切專業
        4. 找不到答案時誠實告知""")
    .defaultAdvisors(
        QuestionAnswerAdvisor.builder(vectorStore)
            .searchRequest(SearchRequest.builder().topK(3).build())
            .build()
    )
    .build()

測試跨來源問答——問題分別命中 TXT、JSON、Markdown、CSV:

val questions = listOf(
    "到職三年年假幾天?未休完怎麼處理?",    // → company-policy.txt
    "正式環境推薦用什麼向量資料庫?",          // → tech-guide.json
    "Spring AI POC 什麼時候完成?",            // → meeting-notes.md
    "加班費怎麼算?",                          // → faq.csv
    "三節獎金什麼時候發?",                    // → company-policy.txt + faq.csv
    "公司用什麼前端框架?"                     // → meeting-notes.md
)

questions.forEach { q ->
    val answer = ragClient.prompt().user(q).call().content()
    println("❓ $q")
    println("💬 $answer\n")
}

AI 能自動從 4 種不同格式的文件中找到正確的答案,並標注來源。

Step 7:通用文件載入器

建立一個可重用的函式,自動偵測檔案格式並載入整個資料夾:

fun loadDocumentsFromFolder(folderPath: String): List<Document> {
    val folder = File(folderPath)
    val allDocs = mutableListOf<Document>()
    val splitter = TokenTextSplitter.builder()
        .withChunkSize(400)
        .withMinChunkSizeChars(50)
        .build()

    folder.listFiles()?.forEach { file ->
        val docs = when (file.extension.lowercase()) {
            "txt" -> {
                val doc = Document(file.readText(),
                    mapOf("source" to file.name, "format" to "txt"))
                splitter.split(listOf(doc))
            }
            "md" -> {
                file.readText()
                    .split(Regex("(?=^#{2,3}\\s)", RegexOption.MULTILINE))
                    .filter { it.trim().length > 20 }
                    .map { Document(it.trim(),
                        mapOf("source" to file.name, "format" to "markdown")) }
            }
            "csv" -> {
                file.readLines().drop(1).map { line ->
                    val parts = line.split(",", limit = 3)
                    Document("問:${parts.getOrElse(0){""}}\n答:${parts.getOrElse(1){""}}",
                        mapOf("source" to file.name,
                              "category" to parts.getOrElse(2){""},
                              "format" to "csv"))
                }
            }
            "json" -> {
                val doc = Document(file.readText(),
                    mapOf("source" to file.name, "format" to "json"))
                splitter.split(listOf(doc))
            }
            else -> {
                println("  跳過不支援的格式: ${file.name}")
                emptyList()
            }
        }
        println("  ${file.name} → ${docs.size} 個段落")
        allDocs.addAll(docs)
    }

    println("✓ 共載入 ${allDocs.size} 個段落")
    return allDocs
}

使用方式:

val myVectorStore = SimpleVectorStore.builder(embeddingModel).build()
val myDocs = loadDocumentsFromFolder("sample-docs")
myVectorStore.add(myDocs)

// 立即可用的 RAG
val myRagClient = ChatClient.builder(chatModel)
    .defaultSystem("根據知識庫回答問題,用繁體中文,找不到就說不知道。")
    .defaultAdvisors(
        QuestionAnswerAdvisor.builder(myVectorStore)
            .searchRequest(SearchRequest.builder().topK(3).build())
            .build()
    )
    .build()

val answer = myRagClient.prompt().user("員工有什麼福利?").call().content()
println(answer)

只需指定資料夾路徑,就能自動處理所有支援的檔案格式,建立完整的 RAG 知識庫。

本課重點回顧

概念重點
ETL PipelineExtract(讀取)→ Transform(切割)→ Load(存入向量 DB)
DocumentReaderTXT/JSON/CSV/MD 直接處理,PDF/Word 用 Tika
TokenTextSplitter按 Token 自動切割,可配置 chunk size 與 overlap
按標題切割保留文件邏輯結構,比固定長度更精準
Metadatasource、section、category、format——搜尋與過濾的基礎
多來源整合不同格式的文件統一存入同一個 VectorStore
通用載入器自動偵測格式,一個函式處理整個資料夾

下一步:Lesson 10 — Spring Boot 專案整合

到目前為止,所有實作都在 Kotlin Notebook 中完成。下一堂課將把這些功能遷移到正式的 Spring Boot 專案

  • Spring Boot 專案架構 — Controller、Service、Configuration 的分層設計
  • PGVector 整合 — 從 SimpleVectorStore 切換到 PostgreSQL 向量資料庫
  • REST API — 將 RAG 問答包裝成 API 端點

掌握文件處理,繼續前進!

知識庫已就緒,下一步將所有功能遷移到正式的 Spring Boot 專案——REST API、PGVector、Docker 部署。

前往 Lesson 10:Spring Boot 專案整合 →