- Published on
第一次學 Kotlin Koog AI 就上手 Day 30:Embeddings:讓 AI 理解文件語意
在前一篇文章中,我們學習了結構化資料處理,讓 AI 能夠產生可預測、格式一致的回應。這解決了輸出格式的問題,但如果我們想讓 AI 更深入理解輸入內容的語意呢?今天我們要探討 Koog AI 框架的另一個重要功能:Embeddings
想像這樣的場景:客戶詢問「我的手機充電很慢」,而你的知識庫中儲存的是「行動裝置電池充電速度異常」。傳統的關鍵字搜索會認為這兩句話毫無關聯,但人類一眼就能看出它們在討論同一個問題。Embeddings 就是讓電腦具備這種「理解意思」的能力,將文字轉換成數學向量,讓 AI 能夠識別語意上的相似性
今天我們將建立文件語意查詢系統、程式碼相似度比較工具,並整合到 RAG 系統中,展示 Embeddings 在實際應用中的強大威力
文件分塊的重要性
在深入學習 Embeddings 技術之前,我們必須先討論一個在實際應用中非常關鍵,但 Koog 官方文件和 API 目前沒有特別提到的重要概念:文件分塊(Document Chunking)
為什麼需要文件分塊
當我們處理長篇文件時,直接對整份文件進行 embedding 會面臨幾個嚴重問題
- Token 限制:大多數 embedding 模型都有輸入長度限制(如 8192 tokens),超過限制的內容會被截斷
- 語意稀釋:長文件包含多個主題,整體 embedding 會「平均化」所有內容,導致特定主題的語意被稀釋
- 檢索精確度下降:用戶查詢通常針對特定問題,但整份文件的 embedding 可能無法精確匹配
- 成本浪費:處理超長文件會消耗大量 tokens,增加 API 呼叫成本
分塊帶來的優勢
透過將文件分割成較小的語意塊,我們可以
- 提升檢索準確性:每個分塊專注於特定主題,更容易找到相關內容
- 降低成本:避免處理超長文件,減少不必要的 token 消耗
- 改善回答品質:AI 可以基於最相關的文件片段提供更精準的回答
- 支援大型文件:突破模型輸入限制,處理任意長度的文件
重要提醒:由於 Koog 官方文件和 API 沒有內建文件分塊功能,所以這個重要概念沒有在文章中特別寫出範例。但在實際的 embedding 和 RAG 應用中,文件分塊是不可或缺的技術。沒有適當分塊的話,等於是拿整份文件來做 embedding,效果會很差,而且會浪費大量 token
什麼是 Embeddings
Embeddings 可以想像成是一種「數學翻譯器」,它將文字、程式碼轉換成數學向量(一串數字)。這些數字不是隨機的,而是經過訓練的模型學會了語言的語意結構後產生的
舉個簡單的例子,假設我們要比較這三句話的相似度
- 「我喜歡吃蘋果」
- 「蘋果是我最愛的水果」
- 「今天天氣很好」
人類能夠輕易理解前兩句在表達相似的概念,第三句則完全不同。Embeddings 就是讓電腦具備這樣的理解能力,將每句話轉換成向量,相似的概念在向量空間中會比較接近
Embeddings 架構
Koog AI 的 Embeddings 功能分為兩個主要模組
embeddings-base 核心模組
提供基礎的介面和資料結構,包括
interface Embedder {
/**
* 將文字轉換成向量 embedding
*/
suspend fun embed(text: String): Vector
/**
* 計算兩個向量 embedding 的差異
* 數值越低代表越相似
*/
fun diff(embedding1: Vector, embedding2: Vector): Double
}
embeddings-llm 實現模組
包含了本地 embedding 模型的實作,支援 Ollama 和 OpenAI 等供應商
OpenAI 範例
suspend fun openAIEmbeddingExample() {
// 建立 OpenAI 用戶端
val client = OpenAILLMClient(ApiKeyManager.openAIApiKey!!)
// 建立 embedder,使用 TextEmbedding3Small 模型
val embedder = LLMEmbedder(client, OpenAIModels.Embeddings.TextEmbedding3Small)
// 產生文字 embedding
val text = "Kotlin 是一個現代的程式語言"
val embedding = embedder.embed(text)
println("文字:$text")
println("向量維度:${embedding.dimension}")
println("向量前 5 個值:${embedding.values.take(5)}")
}
OpenAI 提供多種 embedding 模型可供選擇
- text-embedding-3-small:適合一般用途,成本較低
- text-embedding-3-large:高品質,適合對精度要求高的應用
- text-embedding-ada-002:經典模型,穩定可靠
執行 AI 回應內容
文字:Kotlin 是一個現代的程式語言
向量維度:1536
向量前 5 個值:[-0.004686708, -0.011598508, -0.065175906, 0.006767256, 0.052771457]
Ollama 範例
如果你偏好使用本地模型,Ollama 是絕佳的選擇。首先確保已安裝並執行 Ollama 服務
# 安裝所需的embedding模型
ollama pull nomic-embed-text
suspend fun main() {
// 建立 OpenAI 用戶端
val client = OllamaClient()
// 建立嵌入器,使用 NOMIC_EMBED_TEXT 模型
val embedder = LLMEmbedder(client, OllamaEmbeddingModels.NOMIC_EMBED_TEXT)
// 產生文字embedding
val text = "Kotlin 是一個現代的程式語言"
val embedding = embedder.embed(text)
println("文字:$text")
println("向量維度:${embedding.dimension}")
println("向量前 5 個值:${embedding.values.take(5)}")
}
執行 AI 回應內容
文字:Kotlin 是一個現代的程式語言
向量維度:768
向量前 5 個值:[0.163072407245636, 0.5100051164627075, -3.5343101024627686, -0.4249712824821472, -0.4862282872200012]
程式碼語意比較範列
Embeddings 的一個令人興奮的應用是跨程式語言的語意比較。讓我們來比較 Kotlin、Python、Java 三種語言實作 Fibonacci 演算法的相似度
suspend fun main() {
// Kotlin 實作
val kotlinCode = """
fun fibonacci(n: Int): Int {
return if (n <= 1) n else fibonacci(n - 1) + fibonacci(n - 2)
}
""".trimIndent()
// Python 實作
val pythonCode = """
def fibonacci(n):
if n <= 1:
return n
else:
return fibonacci(n-1) + fibonacci(n-2)
""".trimIndent()
// Java 的泡沫排序(不同演算法)
val javaCode = """
public static int bubbleSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n-1; i++) {
for (int j = 0; j < n-i-1; j++) {
if (arr[j] > arr[j+1]) {
int temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
}
}
}
return arr;
}
""".trimIndent()
// 建立 OpenAI 用戶端
val client = OpenAILLMClient(ApiKeyManager.openAIApiKey!!)
// 建立 embedder,使用 TextEmbedding3Small 模型
val embedder = LLMEmbedder(client, OpenAIModels.Embeddings.TextEmbedding3Small)
// 產生embedding向量
val kotlinEmbedding = embedder.embed(kotlinCode)
val pythonEmbedding = embedder.embed(pythonCode)
val javaEmbedding = embedder.embed(javaCode)
// 計算相似度
val kotlinPythonDiff = embedder.diff(kotlinEmbedding, pythonEmbedding)
val kotlinJavaDiff = embedder.diff(kotlinEmbedding, javaEmbedding)
println("Kotlin 與 Python 的差異:$kotlinPythonDiff")
println("Kotlin 與 Java 的差異:$kotlinJavaDiff")
// 判斷最相似的程式碼
if (kotlinPythonDiff < kotlinJavaDiff) {
println("Kotlin 程式碼與 Python 實作較相似")
println("原因:兩者都是 Fibonacci 遞迴實作")
} else {
println("Kotlin 程式碼與 Java 實作較相似")
}
}
執行 AI 回應內容
執行結果通常會顯示 Kotlin 和 Python 的 Fibonacci 實作更相似,因為它們實現了相同的演算法邏輯,儘管使用不同的語言語法
Kotlin 與 Python 的差異:0.2308878050753932
Kotlin 與 Java 的差異:0.725976096414004
Kotlin 程式碼與 Python 實作較相似
原因:兩者都是 Fibonacci 遞迴實作
文件內容查詢範例
現在讓我們建立一個簡單的文件索引和查詢系統
class SimpleDocumentIndex(private val embedder: Embedder) {
private val documents = mutableMapOf<String, String>()
private val documentEmbeddings = mutableMapOf<String, Vector>()
suspend fun addDocument(id: String, content: String) {
documents[id] = content
documentEmbeddings[id] = embedder.embed(content)
println("已加入文件:$id")
}
fun getDocument(id: String): String? {
return documents[id]
}
suspend fun searchSimilarDocuments(
query: String,
maxResults: Int = 3
): List<Pair<String, Double>> {
val queryEmbedding = embedder.embed(query)
val similarities = documentEmbeddings.map { (docId, docEmbedding) ->
val similarity = 1.0 - embedder.diff(queryEmbedding, docEmbedding)
docId to similarity
}
return similarities
.sortedByDescending { it.second }
.take(maxResults)
}
}
suspend fun main() {
// 建立 OpenAI 用戶端
val client = OpenAILLMClient(ApiKeyManager.openAIApiKey!!)
// 建立 embedder,使用 TextEmbedding3Small 模型
val embedder = LLMEmbedder(client, OpenAIModels.Embeddings.TextEmbedding3Small)
val index = SimpleDocumentIndex(embedder)
// 加入範例文件
index.addDocument(
"doc1",
"人工智慧正在改變軟體開發的方式,自動化工具讓開發者更專注於創意和解決方案"
)
index.addDocument(
"doc2",
"Kotlin 是一個現代的程式語言,提供簡潔的語法和強大的類型安全性"
)
index.addDocument(
"doc3",
"機器學習模型訓練需要大量的資料和運算資源,雲端平台提供了彈性的解決方案"
)
index.addDocument(
"doc4",
"今天的天氣很好,適合外出散步和運動"
)
// 搜索相關文件
val query = "AI 如何幫助程式設計師?"
val results = index.searchSimilarDocuments(query)
println("\n查詢:$query")
println("最相關的文件:")
results.forEach { (docId, similarity) ->
val docContent = documents[docId]
println("$docId (相似度: %.3f) - ${docContent?.take(50)}...".format(similarity))
}
}
執行 AI 回應內容
這個範例會找出與查詢最相關的文件,通常 doc1(關於 AI 和軟體開發)會排在最前面
已加入文件:doc1
已加入文件:doc2
已加入文件:doc3
已加入文件:doc4
查詢:AI 如何幫助程式設計師?
最相關的文件:
doc1 (相似度: 0.496) - 人工智慧正在改變軟體開發的方式,自動化工具讓開發者更專注於創意和解決方案...
doc3 (相似度: 0.368) - 機器學習模型訓練需要大量的資料和運算資源,雲端平台提供了彈性的解決方案...
doc2 (相似度: 0.236) - Kotlin 是一個現代的程式語言,提供簡潔的語法和強大的類型安全性...
模型選擇指南
Ollama 提供多種embedding模型,每個都有不同的特性
模型 | 參數量 | 維度 | 上下文長度 | 特色 | 適用場景 |
---|---|---|---|---|---|
NOMIC_EMBED_TEXT | 137M | 768 | 8192 | 平衡的品質與效率 | 一般用途文字embedding |
ALL_MINILM | 33M | 384 | 512 | 快速、輕量 | 即時應用、資源受限環境 |
MULTILINGUAL_E5 | 300M | 768 | 512 | 多語言支援 | 跨語言內容處理 |
BGE_LARGE | 335M | 1024 | 512 | 高品質英文embedding | 英文文件檢索、高精度需求 |
MXBAI_EMBED_LARGE | - | - | - | 高維度embedding | 需要高維向量的特殊應用 |
選擇建議
- 一般文字embedding:使用
NOMIC_EMBED_TEXT
,品質與效能的最佳平衡 - 多語言支援:選擇
MULTILINGUAL_E5
,對中文內容也有不錯的表現 - 追求最高品質:使用
BGE_LARGE
,特別適合英文內容 - 性能優先:選擇
ALL_MINILM
,在資源受限的環境中表現出色
實用技巧與最佳實務
批次處理最佳化
當處理大量文件時,可以實作批次embedding以提升效率
suspend fun batchEmbedding(embedder: Embedder, texts: List<String>): List<Vector> {
return texts.map { text ->
embedder.embed(text)
}
}
快取機制
對於經常查詢的文件,建議實作快取機制
class CachedEmbedder(private val embedder: Embedder) : Embedder {
private val cache = mutableMapOf<String, Vector>()
override suspend fun embed(text: String): Vector {
return cache[text] ?: run {
val embedding = embedder.embed(text)
cache[text] = embedding
embedding
}
}
override fun diff(embedding1: Vector, embedding2: Vector): Double {
return embedder.diff(embedding1, embedding2)
}
}
相似度閾值設定
設定合適的相似度閾值可以過濾不相關的結果
suspend fun findRelevantDocuments(
query: String,
threshold: Double = 0.7
): List<String> {
val queryEmbedding = embedder.embed(query)
return documentEmbeddings.filter { (_, docEmbedding) ->
val similarity = 1.0 - embedder.diff(queryEmbedding, docEmbedding)
similarity >= threshold
}.keys.toList()
}
文件分塊策略
雖然 Koog 框架目前沒有提供內建的文件分塊功能,但我們可以自行實作。以下是幾種常見的分塊策略
固定大小分塊
最簡單的分塊方式,按固定的字數或 token 數切割文件
class FixedSizeChunker(
private val chunkSize: Int = 1000,
private val overlapSize: Int = 200
) {
fun chunk(text: String): List<TextChunk> {
val chunks = mutableListOf<TextChunk>()
val words = text.split(" ")
var startIndex = 0
var chunkId = 0
while (startIndex < words.size) {
val endIndex = minOf(startIndex + chunkSize, words.size)
val chunkText = words.subList(startIndex, endIndex).joinToString(" ")
chunks.add(
TextChunk(
id = "chunk_${chunkId++}",
content = chunkText,
startIndex = startIndex,
endIndex = endIndex
)
)
// 使用重疊避免語意斷裂
startIndex += chunkSize - overlapSize
}
return chunks
}
}
data class TextChunk(
val id: String,
val content: String,
val startIndex: Int,
val endIndex: Int
)
語意分塊
按段落或語意單位分割,保持內容的完整性
class SemanticChunker {
fun chunkByParagraphs(text: String): List<TextChunk> {
return text.split("\n\n")
.filter { it.trim().isNotEmpty() }
.mapIndexed { index, paragraph ->
TextChunk(
id = "paragraph_$index",
content = paragraph.trim(),
startIndex = index,
endIndex = index + 1
)
}
}
fun chunkByHeaders(markdownText: String): List<TextChunk> {
val sections = mutableListOf<TextChunk>()
val lines = markdownText.lines()
var currentSection = StringBuilder()
var currentTitle = "intro"
var sectionId = 0
for (line in lines) {
if (line.startsWith("#")) {
// 儲存前一個區段
if (currentSection.isNotEmpty()) {
sections.add(
TextChunk(
id = "section_${sectionId++}",
content = currentSection.toString().trim(),
startIndex = sectionId,
endIndex = sectionId + 1
)
)
}
// 開始新區段
currentTitle = line.removePrefix("#").trim()
currentSection = StringBuilder(line + "\n")
} else {
currentSection.append(line + "\n")
}
}
// 加入最後一個區段
if (currentSection.isNotEmpty()) {
sections.add(
TextChunk(
id = "section_${sectionId++}",
content = currentSection.toString().trim(),
startIndex = sectionId,
endIndex = sectionId + 1
)
)
}
return sections
}
}
整合分塊與 Embedding
將分塊功能整合到我們的文件索引系統中
class ChunkedDocumentIndex(
private val embedder: Embedder,
private val chunker: FixedSizeChunker = FixedSizeChunker()
) {
private val chunks = mutableMapOf<String, TextChunk>()
private val chunkEmbeddings = mutableMapOf<String, Vector>()
suspend fun addDocument(docId: String, content: String) {
val documentChunks = chunker.chunk(content)
documentChunks.forEach { chunk ->
val chunkKey = "${docId}_${chunk.id}"
chunks[chunkKey] = chunk
chunkEmbeddings[chunkKey] = embedder.embed(chunk.content)
println("已處理分塊:$chunkKey (${chunk.content.take(50)}...)")
}
}
suspend fun searchSimilarChunks(
query: String,
maxResults: Int = 5
): List<Pair<TextChunk, Double>> {
val queryEmbedding = embedder.embed(query)
val similarities = chunkEmbeddings.map { (chunkKey, chunkEmbedding) ->
val similarity = 1.0 - embedder.diff(queryEmbedding, chunkEmbedding)
chunks[chunkKey]!! to similarity
}
return similarities
.sortedByDescending { it.second }
.take(maxResults)
}
}
// 使用範例
suspend fun main() {
val client = OpenAILLMClient(ApiKeyManager.openAIApiKey!!)
val embedder = LLMEmbedder(client, OpenAIModels.Embeddings.TextEmbedding3Small)
val index = ChunkedDocumentIndex(embedder)
// 加入長文件(模擬)
val longDocument = """
人工智慧的發展歷程可以追溯到 1950 年代。艾倫·圖靈提出了著名的圖靈測試,
這成為了判斷機器是否具有智慧的重要標準。
在 1956 年的達特茅斯會議上,人工智慧這個術語首次被正式提出。約翰·麥卡錫、
馬文·明斯基等學者奠定了 AI 研究的基礎。
現代深度學習的興起始於 2006 年,傑佛瑞·辛頓等研究者的突破性工作讓神經網路
重新受到關注。2012 年 AlexNet 在 ImageNet 競賽中的勝利標誌著深度學習時代的開始。
今天,人工智慧已經應用到各個領域,從自動駕駛到語言翻譯,從醫療診斷到金融分析。
ChatGPT 和 GPT-4 等大型語言模型的出現,更是讓 AI 技術走入了普通人的生活。
""".trimIndent()
index.addDocument("ai_history", longDocument)
// 搜尋相關分塊
val results = index.searchSimilarChunks("深度學習的發展", maxResults = 3)
println("\n查詢:深度學習的發展")
println("最相關的分塊:")
results.forEach { (chunk, similarity) ->
println("${chunk.id} (相似度: %.3f)".format(similarity))
println("內容:${chunk.content.take(100)}...")
println("-".repeat(50))
}
}
執行 AI 回應內容
已處理分塊:ai_history_chunk_0 (人工智慧的發展歷程可以追溯到 1950 年代。艾倫·圖靈提出了著名的圖靈測試,
這成為了判斷機器是否...)
查詢:深度學習的發展
最相關的分塊:
chunk_0 (相似度: 0.531)
內容:人工智慧的發展歷程可以追溯到 1950 年代。艾倫·圖靈提出了著名的圖靈測試,
這成為了判斷機器是否具有智慧的重要標準。
在 1956 年的達特茅斯會議上,人工智慧這個術語首次被正式提出。約翰·麥卡...
--------------------------------------------------
分塊策略選擇建議
- 固定大小分塊:適合一般文字內容,確保每個分塊長度適中
- 語意分塊:適合結構化文件(如 Markdown、文章),保持語意完整性
- 重疊分塊:避免重要資訊在分塊邊界被切斷
- 混合策略:根據文件類型採用不同分塊方式
社群與未來發展
在 GitHub 上已經有開發者提出相關建議,Issue #470 中討論了向量儲存系統對文字分塊的支援問題
該 issue 中,開發者嘗試實作文件分塊功能,包括
TextChunker
:按段落分割文字文件CodeChunker
:按函數分割程式碼文件DocumentChunk
:包含 id、內容和元資料的分塊物件
JetBrains 團隊也回應表示這是一個有潛力的改進方向,正在考慮相關功能。這表示未來的 Koog 框架版本可能會內建更完善的文件分塊支援
不確定為什麼官方的 API 目前沒有 chunk 的相關實作,它對於 embedding 和 RAG 還蠻重要的
另外,如何找到適合的分塊方式,以及分塊的大小、重疊的程度,都是需要根據文件類型來適當的調整和測試,這裡的程式碼基本上只是一個示意的範例,實際應用中需要根據具體情況進行調整,希望官方可以加入相關的功能
總結
透過本篇文章,我們深入學習了 Koog AI 框架的 Embeddings 功能,這是 AI 應用中的關鍵技術。從理論基礎到實際應用,我們掌握了
- 語意理解能力:讓 AI 真正理解文字的意思,而非僅做關鍵字比對
- 多模型支援:從雲端 OpenAI 到本地 Ollama,靈活選擇適合的embedding模型
- 實用系統建構:文件查詢系統、程式碼相似度分析、RAG 整合應用
通過本篇文章的學習,我們掌握了 Embedding 這個強大的 AI 技術。在下一篇文章中,我們將探索 RAG(Retrieval-Augmented Generation),相關的技術,我們將從基礎的 FAQ 系統開始,逐步建立一個能夠理解問題、搜尋相關文件、並產生準確回答的 RAG 系統。這將是 Embeddings 技術最實用的應用場景之一
參考文件
支持創作
如果這篇文章對您有幫助,歡迎透過 贊助連結 支持我持續創作優質內容。您的支持是我前進的動力!
圖片來源:AI 產生