- 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? |
|---|---|---|
| TXT | File.readText() | 否 |
| JSON | 正則/Jackson 解析 | 否 |
| CSV | readLines() + split | 否 |
| Markdown | 按標題正則分割 | 否 |
| PDF / Word / PPT / Excel | TikaDocumentReader | 是 |
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 Token | 50~100 Token | 平衡精準度與上下文 |
| 長篇報告 | 800~1000 Token | 100~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 Pipeline | Extract(讀取)→ Transform(切割)→ Load(存入向量 DB) |
| DocumentReader | TXT/JSON/CSV/MD 直接處理,PDF/Word 用 Tika |
| TokenTextSplitter | 按 Token 自動切割,可配置 chunk size 與 overlap |
| 按標題切割 | 保留文件邏輯結構,比固定長度更精準 |
| Metadata | source、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 部署。


