Logo
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 實作更相似,因為它們實現了相同的演算法邏輯,儘管使用不同的語言語法

KotlinPython 的差異:0.2308878050753932
KotlinJava 的差異: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_TEXT137M7688192平衡的品質與效率一般用途文字embedding
ALL_MINILM33M384512快速、輕量即時應用、資源受限環境
MULTILINGUAL_E5300M768512多語言支援跨語言內容處理
BGE_LARGE335M1024512高品質英文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 產生