連續放了兩天颱風假,想到明天星期五又要回去上班,心情真是複雜。還好山陀兒颱風在今天下午五點已經減弱為輕度颱風,希望明天的上班路程不會受到太多影響。不過,iThome 鐵人賽的挑戰還是要繼續,今天就來分享一些我在技術上的心得和體驗吧。
昨天我嘗試新增檔案上傳和建立 ETL Pipeline 的功能,並用 WebFlux 的方式改寫這個 API。但是過程並不順利。一開始我以為是自己對 Reactive Programming 的理解不夠深入,導致程式在檔案還沒上傳完成的時候就去讀取它,才會出現「檔案不存在」的錯誤。
經過深入調查,我才發現自己犯了一個小疏忽。在 Spring AI 的 TextReader
中,它可以接受兩個參數:
InputStream
。我原本以為只要提供檔案的路徑就可以了,但實際上需要將路徑轉換成 URI 格式,TextReader
才能正確讀取檔案。原本的程式碼只是直接傳入路徑,沒有進行 URI 轉換,這就是錯誤的根源。修正之後,終於成功地將檔案上傳的 API 端點改寫成 WebFlux 風格。
File.createTempFile
建立一個暫存檔案。FilePart.transferTo
將上傳的檔案寫入暫存檔案。由於 transferTo
回傳的是 Mono<Void>
,因此我加入 then(Mono.fromCallable)
,以便在檔案寫入完成後回傳暫存檔案的路徑供後續步驟使用。flatMap
中,使用 Mono.fromCallable
建立對應的 TextReader
,記得將檔案路徑轉換成 URI 格式(file.toURI()
)。接著,使用 TextReader.get()
取得文件列表 List<Document>
。TokenTextSplitter
,將文件拆解成多個 chunks。然後,透過 VectorStore.add
將這些區塊進行 embedding,並儲存至向量資料庫。@PostMapping("/ai/upload")
fun uploadFileWithoutEntity(
@RequestPart("file") filePart: Mono<FilePart>
): Mono<ResponseEntity<Map<String, String>>> {
val tempFile = Files.createTempFile("upload-", ".txt")
return filePart.flatMap { part ->
part.transferTo(tempFile)
.then(Mono.fromCallable { tempFile })
.flatMap { file ->
Mono.fromCallable {
val textReader = TextReader(file.toUri().toString())
textReader.get()
}.flatMap { documents ->
Mono.fromCallable {
val tokenTextSplitter = TokenTextSplitter()
tokenTextSplitter.apply(documents)
}
}.flatMap { splitDocuments ->
Mono.fromCallable { vectorStore.add(splitDocuments) }
}.map { ResponseEntity.ok(mapOf("upload" to "ok")) }
}
}.onErrorResume { ex ->
val errorMessage = "Error uploading file: ${ex.message}"
Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(mapOf("error" to errorMessage)))
}
}
除了檔案上傳的功能之外,我還新增了一個 API 來實作基本功能。這個實作雖然相對簡單,但對於理解 RAG 的運作方式非常有幫助。
首先,新增一個 DataRetrievalService
,其中包含 searchData
方法。這個方法會利用 VectorStore.similaritySearch
,將使用者查詢進行 embedding 後,透過 Cosine Similarity(餘弦相似度)演算法,找出最相似的向量,並提取對應的 context。
@Service
class DataRetrievalService @Autowired constructor(
private val vectorStore: VectorStore
) {
fun searchData(query: String?): List<Document> {
return vectorStore.similaritySearch(query)
}
}
DataRetrievalService.searchData
搜尋並取得與使用者查詢相關的 context。PromptTemplate
,用來引導 LLM 據提供的 context 回答使用者的問題。Prompt 需明確指示 LLM 必須參考 context 中的特定段落來回答問題,並且在無法回答時,需回應「 I'm sorry, I don't have the information you are looking for.」以降低 LLM 產生幻覺的機率。ChatModel.call
方法,將建構好的 Prompt 傳遞給 LLM,並取得 LLM 的回應。@GetMapping("/ai/ask")
fun ask(
@RequestParam(
value = "message",
defaultValue = "Tell me a joke"
) message: String?
): Map<*, *> {
val context = this.dataRetrievalService.searchData(message)
val promptTemplate = PromptTemplate(askPromptBlueprint)
val prompt = promptTemplate.create(mapOf("context" to context, "query" to message))
return java.util.Map.of("answer", chatModel.call(prompt))
}
上述流程即是最基本的 RAG,也稱為 Naive RAG。其主要步驟如下:
這種方法可以被形象地比喻為「Open Book」,也就是先從知識庫中找出參考資訊,再提供給 LLM 進行作答。相較於直接對模型進行 Fine-tuning,這種方式的成本要低得多。
雖然 Naive RAG 已能滿足基本需求,但它也存在一些不足之處。因此,業界發展出各種 Advanced RAG 方法,例如導入更複雜的檢索演算法、強化上下文理解能力等等。這些進階方法目前不在我的研究範圍內。
圖片來源:Retrieval Augmented Generation (RAG) for LLMs | Prompt Engineering Guide
目前,我已經可以使用 Spring AI 實現簡單的 Naive RAG 應用。明天會繼續探索,看看可以做出哪些新的嘗試。雖然目前還沒有具體的想法,但我會一步一步地推進。
最後,提醒大家明天上班路上要注意風雨,平安順利!