|
| 1 | +[//]: # (title: Build a Kotlin app that uses Spring AI to answer questions based on documents stored in Qdrant — tutorial) |
| 2 | + |
| 3 | +In this tutorial, you'll learn how to build a Kotlin app that uses [Spring AI](https://spring.io/projects/spring-ai) to connect to an LLM, |
| 4 | +store documents in a vector database, and answer questions using context from those documents. |
| 5 | + |
| 6 | +You will use the following tools during this tutorial: |
| 7 | + |
| 8 | +* [Spring Boot](https://spring.io/projects/spring-boot) as the base to configure and run the web application. |
| 9 | +* [Spring AI](https://spring.io/projects/spring-ai) to interact with the LLM and perform context-based retrieval. |
| 10 | +* [IntelliJ IDEA](https://www.jetbrains.com/idea/) to generate the project and implement the application logic. |
| 11 | +* [Qdrant](https://qdrant.tech/) as the vector database for similarity search. |
| 12 | +* [Docker](https://www.docker.com/) to run Qdrant locally. |
| 13 | +* [OpenAI](https://platform.openai.com) as the LLM provider. |
| 14 | + |
| 15 | +## Before you start |
| 16 | + |
| 17 | +1. Download and install the latest version of [IntelliJ IDEA Ultimate Edition](https://www.jetbrains.com/idea/download/index.html). |
| 18 | + |
| 19 | + > If you use IntelliJ IDEA Community Edition or another IDE, you can generate a Spring Boot project using |
| 20 | + > a [web-based project generator](https://start.spring.io/#!language=kotlin&type=gradle-project-kotlin). |
| 21 | + > |
| 22 | + {style="tip"} |
| 23 | + |
| 24 | +2. Create an OpenAI API key on the [OpenAI platform](https://platform.openai.com/api-keys) to access the API. |
| 25 | +3. Install [Docker](https://www.docker.com/) to run the Qdrant vector database locally. |
| 26 | +4. After installing Docker, open your terminal and run the following command to start the container: |
| 27 | + |
| 28 | + ```bash |
| 29 | + docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant |
| 30 | + ``` |
| 31 | + |
| 32 | +## Create the project |
| 33 | + |
| 34 | +> You can use [Spring Boot web-based project generator](https://start.spring.io/) as an alternative to generate your project. |
| 35 | +> |
| 36 | +{style="note"} |
| 37 | + |
| 38 | +Create a new Spring Boot project in IntelliJ IDEA Ultimate Edition: |
| 39 | + |
| 40 | +1. In IntelliJ IDEA, select **File** | **New** | **Project**. |
| 41 | +2. In the panel on the left, select **New Project** | **Spring Boot**. |
| 42 | +3. Specify the following fields and options in the **New Project** window: |
| 43 | + |
| 44 | + * **Name**: springAIDemo |
| 45 | + * **Language**: Kotlin |
| 46 | + * **Type**: Gradle - Kotlin |
| 47 | + |
| 48 | + > This option specifies the build system and the DSL. |
| 49 | + > |
| 50 | + {style="tip"} |
| 51 | + |
| 52 | + * **Package name**: com.example.springaidemo |
| 53 | + * **JDK**: Java JDK |
| 54 | + |
| 55 | + > This tutorial uses **Oracle OpenJDK version 21.0.1**. |
| 56 | + > If you don't have a JDK installed, you can download it from the dropdown list. |
| 57 | + > |
| 58 | + {style="note"} |
| 59 | +
|
| 60 | + * **Java**: 17 |
| 61 | +
|
| 62 | + {width=800} |
| 63 | +
|
| 64 | +4. Make sure that you have specified all the fields and click **Next**. |
| 65 | +5. Select the latest stable Spring Boot version in the **Spring Boot** field. |
| 66 | +
|
| 67 | +6. Select the following dependencies required for this tutorial: |
| 68 | +
|
| 69 | + * **Web | Spring Web** |
| 70 | + * **AI | OpenAI** |
| 71 | + * **SQL | Qdrant Vector Database** |
| 72 | +
|
| 73 | + {width=800} |
| 74 | +
|
| 75 | +7. Click **Create** to generate and set up the project. |
| 76 | +
|
| 77 | + > The IDE will generate and open a new project. It may take some time to download and import the project dependencies. |
| 78 | + > |
| 79 | + {style="tip"} |
| 80 | +
|
| 81 | +After this, you can see the following structure in the **Project view**: |
| 82 | +
|
| 83 | +{width=400} |
| 84 | +
|
| 85 | +The generated Gradle project corresponds to the Maven's standard directory layout: |
| 86 | + |
| 87 | +* There are packages and classes under the `main/kotlin` folder that belong to the application. |
| 88 | +* The entry point to the application is the `main()` method of the `SpringAiDemoApplication.kt` file. |
| 89 | +
|
| 90 | +
|
| 91 | +## Update the project configuration |
| 92 | +
|
| 93 | +1. Update your `build.gradle.kts` Gradle build file with the following: |
| 94 | +
|
| 95 | + ```kotlin |
| 96 | + plugins { |
| 97 | + kotlin("jvm") version "%kotlinVersion%" |
| 98 | + kotlin("plugin.spring") version "%kotlinVersion%" |
| 99 | + // Rest of the plugins |
| 100 | + } |
| 101 | + ``` |
| 102 | +
|
| 103 | +2. Update your `springAiVersion` to `1.0.0-M6`: |
| 104 | +
|
| 105 | + ```kotlin |
| 106 | + extra["springAiVersion"] = "1.0.0-M6" |
| 107 | + ``` |
| 108 | +
|
| 109 | +3. Click the **Sync Gradle Changes** button to synchronize the Gradle files. |
| 110 | +4. Update your `src/main/resources/application.properties` file with the following: |
| 111 | +
|
| 112 | + ```text |
| 113 | + # OpenAI |
| 114 | + spring.ai.openai.api-key=YOUR_OPENAI_API_KEY |
| 115 | + spring.ai.openai.chat.options.model=gpt-4o-mini |
| 116 | + spring.ai.openai.embedding.options.model=text-embedding-ada-002 |
| 117 | + # Qdrant |
| 118 | + spring.ai.vectorstore.qdrant.host=localhost |
| 119 | + spring.ai.vectorstore.qdrant.port=6334 |
| 120 | + spring.ai.vectorstore.qdrant.collection-name=kotlinDocs |
| 121 | + spring.ai.vectorstore.qdrant.initialize-schema=true |
| 122 | + ``` |
| 123 | + |
| 124 | + > Set your OpenAI API key to the `spring.ai.openai.api-key` property. |
| 125 | + > |
| 126 | + {style="note"} |
| 127 | +
|
| 128 | +5. Run the `SpringAiDemoApplication.kt` file to start the Spring Boot application. |
| 129 | + Once it's running, open the [Qdrant collections](http://localhost:6333/dashboard#/collections) page in your browser to see the result: |
| 130 | +
|
| 131 | + {width=700} |
| 132 | +
|
| 133 | +## Create a controller to load and search documents |
| 134 | +
|
| 135 | +Create a Spring `@RestController` to search documents and store them in the Qdrant collection: |
| 136 | +
|
| 137 | +1. In the `src/main/kotlin/org/example/springaidemo` directory, create a new file named `KotlinSTDController.kt`, and add the following code: |
| 138 | +
|
| 139 | + ```kotlin |
| 140 | + package org.example.springaidemo |
| 141 | + |
| 142 | + // Imports the required Spring and utility classes |
| 143 | + import org.slf4j.LoggerFactory |
| 144 | + import org.springframework.ai.document.Document |
| 145 | + import org.springframework.ai.vectorstore.SearchRequest |
| 146 | + import org.springframework.ai.vectorstore.VectorStore |
| 147 | + import org.springframework.web.bind.annotation.GetMapping |
| 148 | + import org.springframework.web.bind.annotation.PostMapping |
| 149 | + import org.springframework.web.bind.annotation.RequestMapping |
| 150 | + import org.springframework.web.bind.annotation.RequestParam |
| 151 | + import org.springframework.web.bind.annotation.RestController |
| 152 | + import org.springframework.web.client.RestTemplate |
| 153 | + import kotlin.uuid.ExperimentalUuidApi |
| 154 | + import kotlin.uuid.Uuid |
| 155 | +
|
| 156 | + // Data class representing the chat request payload |
| 157 | + data class ChatRequest(val query: String, val topK: Int = 3) |
| 158 | +
|
| 159 | + @RestController |
| 160 | + @RequestMapping("/kotlin") |
| 161 | + class KotlinSTDController( |
| 162 | + private val restTemplate: RestTemplate, |
| 163 | + private val vectorStore: VectorStore, |
| 164 | + ) { |
| 165 | + private val logger = LoggerFactory.getLogger(this::class.java) |
| 166 | +
|
| 167 | + @OptIn(ExperimentalUuidApi::class) |
| 168 | + @PostMapping("/load-docs") |
| 169 | + fun load() { |
| 170 | + // Loads a list of documents from the Kotlin documentation |
| 171 | + val kotlinStdTopics = listOf( |
| 172 | + "collections-overview", "constructing-collections", "iterators", "ranges", "sequences", |
| 173 | + "collection-operations", "collection-transformations", "collection-filtering", "collection-plus-minus", |
| 174 | + "collection-grouping", "collection-parts", "collection-elements", "collection-ordering", |
| 175 | + "collection-aggregate", "collection-write", "list-operations", "set-operations", |
| 176 | + "map-operations", "read-standard-input", "opt-in-requirements", "scope-functions", "time-measurement", |
| 177 | + ) |
| 178 | + // Base URL for the documents |
| 179 | + val url = "https://raw.githubusercontent.com/JetBrains/kotlin-web-site/refs/heads/master/docs/topics/" |
| 180 | + // Retrieves each document from the URL and adds it to the vector store |
| 181 | + kotlinStdTopics.forEach { topic -> |
| 182 | + val data = restTemplate.getForObject("$url$topic.md", String::class.java) |
| 183 | + data?.let { it -> |
| 184 | + val doc = Document.builder() |
| 185 | + // Builds a document with a random UUID |
| 186 | + .id(Uuid.random().toString()) |
| 187 | + .text(it) |
| 188 | + .metadata("topic", topic) |
| 189 | + .build() |
| 190 | + vectorStore.add(listOf(doc)) |
| 191 | + logger.info("Document $topic loaded.") |
| 192 | + } ?: logger.warn("Failed to load document for topic: $topic") |
| 193 | + } |
| 194 | + } |
| 195 | +
|
| 196 | + @GetMapping("docs") |
| 197 | + fun query( |
| 198 | + @RequestParam query: String = "operations, filtering, and transformations", |
| 199 | + @RequestParam topK: Int = 2 |
| 200 | + ): List<Document>? { |
| 201 | + val searchRequest = SearchRequest.builder() |
| 202 | + .query(query) |
| 203 | + .topK(topK) |
| 204 | + .build() |
| 205 | + val results = vectorStore.similaritySearch(searchRequest) |
| 206 | + logger.info("Found ${results?.size ?: 0} documents for query: '$query'") |
| 207 | + return results |
| 208 | + } |
| 209 | + } |
| 210 | + ``` |
| 211 | + {collapsible="true"} |
| 212 | +
|
| 213 | +2. Update the `SpringAiDemoApplication.kt` file to declare a `RestTemplate` bean: |
| 214 | +
|
| 215 | + ```kotlin |
| 216 | + package org.example.springaidemo |
| 217 | + |
| 218 | + import org.springframework.boot.autoconfigure.SpringBootApplication |
| 219 | + import org.springframework.boot.runApplication |
| 220 | + import org.springframework.context.annotation.Bean |
| 221 | + import org.springframework.web.client.RestTemplate |
| 222 | + |
| 223 | + @SpringBootApplication |
| 224 | + class SpringAiDemoApplication { |
| 225 | + |
| 226 | + @Bean |
| 227 | + fun restTemplate(): RestTemplate = RestTemplate() |
| 228 | + } |
| 229 | + |
| 230 | + fun main(args: Array<String>) { |
| 231 | + runApplication<SpringAiDemoApplication>(*args) |
| 232 | + } |
| 233 | + ``` |
| 234 | + {collapsible="true"} |
| 235 | +
|
| 236 | +3. Run the application. |
| 237 | +4. In the terminal, send a POST request to the `/kotlin/load-docs` endpoint to load the documents: |
| 238 | +
|
| 239 | + ```bash |
| 240 | + curl -X POST http://localhost:8080/kotlin/load-docs |
| 241 | + ``` |
| 242 | +
|
| 243 | +5. Once the documents are loaded, you can search for them with a GET request: |
| 244 | +
|
| 245 | + ```Bash |
| 246 | + curl -X GET http://localhost:8080/kotlin/docs |
| 247 | + ``` |
| 248 | +
|
| 249 | + {width="700"} |
| 250 | +
|
| 251 | +> You can also view the results on the [Qdrant collections](http://localhost:6333/dashboard#/collections) page. |
| 252 | +> |
| 253 | +{style="tip"} |
| 254 | +
|
| 255 | +## Implement an AI chat endpoint |
| 256 | +
|
| 257 | +Once the documents are loaded, the final step is to add an endpoint that answers questions using the documents in Qdrant through Spring AI's Retrieval-Augmented Generation (RAG) support: |
| 258 | +
|
| 259 | +1. Open the `KotlinSTDController.kt` file, and import the following classes: |
| 260 | +
|
| 261 | + ```kotlin |
| 262 | + import org.springframework.ai.chat.client.ChatClient |
| 263 | + import org.springframework.ai.chat.client.advisor.RetrievalAugmentationAdvisor |
| 264 | + import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor |
| 265 | + import org.springframework.ai.chat.prompt.Prompt |
| 266 | + import org.springframework.ai.chat.prompt.PromptTemplate |
| 267 | + import org.springframework.ai.rag.preretrieval.query.transformation.RewriteQueryTransformer |
| 268 | + import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever |
| 269 | + import org.springframework.web.bind.annotation.RequestBody |
| 270 | + ``` |
| 271 | +
|
| 272 | +2. Add `ChatClient.Builder` to the controller's constructor parameters: |
| 273 | +
|
| 274 | + ```kotlin |
| 275 | + class KotlinSTDController( |
| 276 | + private val chatClientBuilder: ChatClient.Builder, |
| 277 | + private val restTemplate: RestTemplate, |
| 278 | + private val vectorStore: VectorStore, |
| 279 | + ) |
| 280 | + ``` |
| 281 | +
|
| 282 | +3. Inside the controller class, create a `ChatClient` instance and a query transformer: |
| 283 | +
|
| 284 | + ```kotlin |
| 285 | + // Builds the chat client with a simple logging advisor |
| 286 | + private val chatClient = chatClientBuilder.defaultAdvisors(SimpleLoggerAdvisor()).build() |
| 287 | + // Builds the query transformer used to rewrite the input query |
| 288 | + private val rqtBuilder = RewriteQueryTransformer.builder().chatClientBuilder(chatClientBuilder) |
| 289 | + ``` |
| 290 | +
|
| 291 | +4. At the bottom of your `KotlinSTDController.kt` file, add a new `chatAsk()` endpoint, with the following logic: |
| 292 | +
|
| 293 | + ```kotlin |
| 294 | + @PostMapping("/chat/ask") |
| 295 | + fun chatAsk(@RequestBody request: ChatRequest): String? { |
| 296 | + // Defines the prompt template with placeholders |
| 297 | + val promptTemplate = PromptTemplate( |
| 298 | + """ |
| 299 | + {query}. |
| 300 | + Please provide a concise answer based on the {target} documentation. |
| 301 | + """.trimIndent() |
| 302 | + ) |
| 303 | + |
| 304 | + // Creates the prompt by substituting placeholders with actual values |
| 305 | + val prompt: Prompt = |
| 306 | + promptTemplate.create(mapOf("query" to request.query, "target" to "Kotlin standard library")) |
| 307 | + |
| 308 | + // Configures the retrieval advisor to augment the query with relevant documents |
| 309 | + val retrievalAdvisor = RetrievalAugmentationAdvisor.builder() |
| 310 | + .documentRetriever( |
| 311 | + VectorStoreDocumentRetriever.builder() |
| 312 | + .similarityThreshold(0.7) |
| 313 | + .topK(request.topK) |
| 314 | + .vectorStore(vectorStore) |
| 315 | + .build() |
| 316 | + ) |
| 317 | + .queryTransformers(rqtBuilder.promptTemplate(promptTemplate).build()) |
| 318 | + .build() |
| 319 | + |
| 320 | + // Sends the prompt to the LLM with the retrieval advisor and get the response |
| 321 | + val response = chatClient.prompt(prompt) |
| 322 | + .advisors(retrievalAdvisor) |
| 323 | + .call() |
| 324 | + .content() |
| 325 | + logger.info("Chat response generated for query: '${request.query}'") |
| 326 | + return response |
| 327 | + } |
| 328 | + ``` |
| 329 | +
|
| 330 | +5. Run the application. |
| 331 | +6. In the terminal, send a POST request to the new endpoint to see the results: |
| 332 | +
|
| 333 | + ```bash |
| 334 | + curl -X POST "http://localhost:8080/kotlin/chat/ask" \ |
| 335 | + -H "Content-Type: application/json" \ |
| 336 | + -d '{"query": "What are the performance implications of using lazy sequences in Kotlin for large datasets?", "topK": 3}' |
| 337 | + ``` |
| 338 | +
|
| 339 | + {width="700"} |
| 340 | +
|
| 341 | +Congratulations! You now have a Kotlin app that connects to OpenAI and answers questions using context retrieved from |
| 342 | +documentation stored in Qdrant. |
| 343 | +Try experimenting with different queries or importing other documents to explore more possibilities. |
| 344 | +
|
| 345 | +You can view the completed project in the [Spring AI demo GitHub repository](https://github.com/Kotlin/Kotlin-AI-Examples/tree/master/projects/spring-ai/springAI-demo), |
| 346 | +or explore other Spring AI examples in [Kotlin AI Examples](https://github.com/Kotlin/Kotlin-AI-Examples/tree/master). |
0 commit comments