MS Lesson24: Spring AI
1. LLM - large language model. Sade dilde insanin beyni kimi dushune bilersiz. Cox oxumush agilli bir robot.
Spring project with Spring AI
Step1: generate key: https://platform.openai.com/api-keys
Step2: application.yaml fayline key elave etmek:
spring:
application:
name: demo-spring-ai
ai:
openai:
api-key: ${OPENAI_API_KEY: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}
chat:
options:
model: gpt-4o-mini
temperature: 0.7
Step3:
package az.etibarli.demospringai.controller;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final ChatClient chatClient;
public ChatController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@GetMapping
public String chat(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.call()
.content();
}
}
Step4: build another api
package az.etibarli.demospringai.controller;
import java.util.Map;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final ChatClient chatClient;
public ChatController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@GetMapping
public String chat(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.call()
.content();
}
@GetMapping("/celeb")
public String getCelebDetails(@RequestParam String name) {
String message = """
List the details of the Famous personality {name}
along with their Carrier achievements.
Show the details in the readable format
""";
PromptTemplate template = new PromptTemplate(message);
Prompt prompt = template.create(Map.of("name", name));
return chatClient
.prompt(prompt)
.call()
.content();
}
}
Step5: indi ise prompt-lar ucun ayrica st fayli yaradaq
Mes: celeb-details.st
List the details of the Famous personality {name}
along with their Carrier achievements.
Show the details in the readable format
package az.etibarli.demospringai.controller;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final ChatClient chatClient;
@Value("classpath:/prompts/celeb-details.st")
private Resource celebPrompt;
public ChatController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@GetMapping
public String chat(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.call()
.content();
}
@GetMapping("/celeb")
public String getCelebDetails(@RequestParam String name) {
PromptTemplate template = new PromptTemplate(celebPrompt);
Prompt prompt = template.create(Map.of("name", name));
return chatClient
.prompt(prompt)
.call()
.content();
}
}
Step6: indi ise Ai yalniz sual vermirik. SystemMessage ile rol ve serhedleri teyin edirik, UserMessage ile konkret idman adini gonderirik. Her iki mesaj Prompt obyektinde birleshdirilir ve ChatClient vasitesile modele oturulur. Belelikle cavab hem formatli hem de movzu cercivesinde qalir.
@GetMapping("/sports")
public String getSportsDetail(@RequestParam String name) {
String message = """
List the details of the Sport %s
along with their Rules and Regulations.
Show the details in the readable format
""";
String systemMessage = """
You are a smart Virtual Assistant.
Your task is to give the details about the Sports.
If someone ask about something else and you do not know the answer
Just say that you do not know the answer.
""";
UserMessage userMessage = new UserMessage(String.format(message, name));
SystemMessage systemMessage1 = new SystemMessage(systemMessage);
Prompt prompt = new Prompt(List.of(userMessage, systemMessage1));
return chatClient
.prompt(prompt)
.call()
.chatResponse()
.getResult()
.getOutput()
.getText();
}
Step7: Ai cavabini artiq String ile deyil Java obyekt kimi verek
package az.etibarli.demospringai.model;
import java.util.List;
public record Player(String playerName, List<String> achievements) {
}
package az.etibarli.demospringai.controller;
import az.etibarli.demospringai.model.Player;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class PlayerController {
private final ChatClient.Builder chatClientBuilder;
@GetMapping("/player")
public List<Player> getPlayerAchievement(@RequestParam String name) {
BeanOutputConverter<List<Player>> converter =
new BeanOutputConverter<>(new ParameterizedTypeReference<>() {
});
String message = """
Generate a list of Career achievements for the sportsperson {sports}.
Include the Player as the key and achievements as the value for it — {format}
""";
PromptTemplate template = new PromptTemplate(message);
Prompt prompt = template.create(Map.of(
"sports", name,
"format", converter.getFormat()
));
Generation result = chatClientBuilder.build()
.prompt(prompt)
.call()
.chatResponse()
.getResult();
return converter.convert(result.getOutput().getText());
}
}
Step8: indi ise shekil formatinda melumati text formatina cevirmek ucun endpoint yazaq:
package az.etibarli.demospringai.controller;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpStatus;
import org.springframework.util.MimeType;
import org.springframework.util.MimeTypeUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class ImageController {
private final ChatClient.Builder chatClientBuilder;
@PostMapping("/image-to-text")
public String describeImage(
@RequestParam("file") MultipartFile file,
@RequestParam(defaultValue = "Explain what you see in this Image") String prompt
) throws IOException {
if (file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Image file is required");
}
MimeType mimeType = resolveImageMimeType(file);
byte[] imageBytes = file.getBytes();
String filename = file.getOriginalFilename();
return chatClientBuilder.build()
.prompt()
.user(userSpec -> userSpec
.text(prompt)
.media(mimeType, new ByteArrayResource(imageBytes) {
@Override
public String getFilename() {
return filename;
}
}))
.call()
.content();
}
private MimeType resolveImageMimeType(MultipartFile file) {
String contentType = file.getContentType();
if (!StringUtils.hasText(contentType) || !contentType.startsWith("image/")) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Only image files are allowed (jpeg, png, webp, gif, ...)"
);
}
return MimeTypeUtils.parseMimeType(contentType);
}
}
Step9: Text formatinda infomasiyani shekil formatina cevirmek
@GetMapping({"/image"})
public String generateImage(@RequestParam String prompt) {
ImageResponse response = imageModel.call(
new ImagePrompt(
prompt,
OpenAiImageOptions.builder()
.n(1)
.width(1024)
.height(1024)
.quality("high")
.build()
)
);
var image = response.getResult().getOutput();
if (StringUtils.hasText(image.getUrl())) {
return image.getUrl();
}
if (StringUtils.hasText(image.getB64Json())) {
return "data:image/png;base64," + image.getB64Json();
}
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Image generation returned empty result");
}
Step10: Audio formatda olan informasiyani text formatina cevirmek
package az.etibarli.demospringai.controller;
import java.io.IOException;
import com.openai.models.audio.AudioResponseFormat;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.audio.transcription.AudioTranscriptionPrompt;
import org.springframework.ai.audio.transcription.TranscriptionModel;
import org.springframework.ai.openai.OpenAiAudioTranscriptionOptions;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpStatus;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class AudioController {
private final TranscriptionModel audioTranscriptionModel;
@PostMapping("/audio-to-text")
public String audioTranscription(
@RequestParam("file") MultipartFile file,
@RequestParam(defaultValue = "en") String language
) throws IOException {
if (file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Audio file is required");
}
OpenAiAudioTranscriptionOptions options = OpenAiAudioTranscriptionOptions.builder()
.language(language)
.responseFormat(AudioResponseFormat.TEXT)
.temperature(0.5f)
.build();
byte[] audioBytes = file.getBytes();
String filename = StringUtils.hasText(file.getOriginalFilename())
? file.getOriginalFilename()
: "audio.mp3";
AudioTranscriptionPrompt prompt = new AudioTranscriptionPrompt(
new ByteArrayResource(audioBytes) {
@Override
public String getFilename() {
return filename;
}
},
options
);
return audioTranscriptionModel.call(prompt)
.getResult()
.getOutput();
}
}
bu ise istenilen dili anlayir:
package az.etibarli.demospringai.controller;
import java.io.IOException;
import com.openai.models.audio.AudioResponseFormat;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.audio.transcription.AudioTranscriptionPrompt;
import org.springframework.ai.audio.transcription.TranscriptionModel;
import org.springframework.ai.openai.OpenAiAudioTranscriptionOptions;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpStatus;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class AudioController {
private final TranscriptionModel audioTranscriptionModel;
@PostMapping("/audio-to-text")
public String audioTranscription(
@RequestParam("file") MultipartFile file,
@RequestParam(required = false) String language
) throws IOException {
if (file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Audio file is required");
}
var optionsBuilder = OpenAiAudioTranscriptionOptions.builder()
.responseFormat(AudioResponseFormat.TEXT)
.temperature(0.5f);
if (StringUtils.hasText(language)) {
optionsBuilder.language(language);
}
OpenAiAudioTranscriptionOptions options = optionsBuilder.build();
byte[] audioBytes = file.getBytes();
String filename = StringUtils.hasText(file.getOriginalFilename())
? file.getOriginalFilename()
: "audio.mp3";
AudioTranscriptionPrompt prompt = new AudioTranscriptionPrompt(
new ByteArrayResource(audioBytes) {
@Override
public String getFilename() {
return filename;
}
},
options
);
return audioTranscriptionModel.call(prompt)
.getResult()
.getOutput();
}
}
Step11: text to audio
@GetMapping("/text-to-audio")
public ResponseEntity<Resource> generateAudio(@RequestParam String prompt) {
OpenAiAudioSpeechOptions options = OpenAiAudioSpeechOptions.builder()
.model("tts-1")
.responseFormat(OpenAiAudioSpeechOptions.AudioResponseFormat.MP3)
.voice(OpenAiAudioSpeechOptions.Voice.ALLOY)
.speed(1.0)
.build();
TextToSpeechPrompt speechPrompt = new TextToSpeechPrompt(prompt, options);
TextToSpeechResponse response = textToSpeechModel.call(speechPrompt);
byte[] output = response.getResult().getOutput();
ByteArrayResource audioResource = new ByteArrayResource(output);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("audio/mpeg"))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"speech.mp3\"")
.body(audioResource);
}
RAG
Retrieval Augmented Generation
Neye gore bize Vector DB lazimdir?
Datanin 80% coxu non-structured formadadir. Mes: video, audio, shekil ve saire.
Proyektin arxitekturasi:
Step 1: docker-compose.yaml fayli
services:
postgres:
image: postgres:17
container_name: laptop-store
environment:
POSTGRES_DB: laptop_store
POSTGRES_USER: admin
POSTGRES_PASSWORD: admin
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- laptop-network
etcd:
image: quay.io/coreos/etcd:v3.5.5
container_name: milvus-etcd
environment:
ETCD_AUTO_COMPACTION_MODE: revision
ETCD_AUTO_COMPACTION_RETENTION: "1000"
ETCD_QUOTA_BACKEND_BYTES: "4294967296"
ETCD_SNAPSHOT_COUNT: "50000"
command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls=http://0.0.0.0:2379 --data-dir=/etcd
volumes:
- etcd_data:/etcd
networks:
- laptop-network
minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
container_name: milvus-minio
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
command: minio server /minio_data
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/minio_data
networks:
- laptop-network
milvus-standalone:
image: milvusdb/milvus:v2.3.21
container_name: milvus-standalone
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
ports:
- "19530:19530"
- "9091:9091"
volumes:
- milvus_data:/var/lib/milvus
depends_on:
- etcd
- minio
networks:
- laptop-network
attu:
image: zilliz/attu:latest
container_name: milvus-attu
ports:
- "3000:3000"
depends_on:
- milvus-standalone
networks:
- laptop-network
networks:
laptop-network:
driver: bridge
volumes:
postgres_data:
etcd_data:
minio_data:
milvus_data:
Step2: build.gradle fayli
plugins {
id 'java'
id 'org.springframework.boot' version '4.1.0'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'az.etibarli'
version = '0.0.1-SNAPSHOT'
description = 'demo-rag-2'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
ext {
set('springAiVersion', "2.0.0")
}
dependencies {
// Spring Boot
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
// Spring AI — OpenAI + Milvus
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.ai:spring-ai-starter-vector-store-milvus'
// Database
runtimeOnly 'org.postgresql:postgresql'
// Liquibase
implementation 'org.springframework.boot:spring-boot-starter-liquibase'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
dependencyManagement {
imports {
mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}"
}
}
tasks.named('test') {
useJUnitPlatform()
}
Step3: application.yaml fayllari
server:
port: 8080
spring:
profiles:
include:
- db
- ai
jpa:
open-in-view: false
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o-mini
embedding:
options:
model: text-embedding-3-small
vectorstore:
milvus:
client:
host: ${MILVUS_HOST:localhost}
port: ${MILVUS_PORT:19530}
database-name: default
collection-name: laptop_store_vectors
embedding-dimension: 1536
index-type: IVF_FLAT
metric-type: COSINE
initialize-schema: true
spring:
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:laptop_store}
username: ${DB_USER:admin}
password: ${DB_PASSWORD:admin}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
liquibase:
change-log: classpath:db/changelog/db.changelog-master.sql
enabled: true
Step4: Entityler
package az.etibarli.demorag2.entity;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Getter
@Setter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(nullable = false)
private LocalDateTime updatedAt;
}
package az.etibarli.demorag2.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "laptops")
public class Laptop extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String brand;
@Column(nullable = false)
private String model;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@Builder.Default
@Column(nullable = false)
private Integer stock = 0;
private String cpu;
private Integer ramGb;
private Integer storageGb;
@Enumerated(EnumType.STRING)
private StorageType storageType;
@Enumerated(EnumType.STRING)
private GpuType gpuType;
private String gpu;
private Double displayInch;
private String displayResolution;
private Integer batteryHours;
private Double weightKg;
@Enumerated(EnumType.STRING)
private OperatingSystem operatingSystem;
@Enumerated(EnumType.STRING)
private LaptopCategory category;
private String color;
@Column(columnDefinition = "TEXT")
private String description;
@Builder.Default
@Column(nullable = false)
private Boolean indexed = false;
}
package az.etibarli.demorag2.entity;
import az.etibarli.demorag2.enums.RecommendationSource;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "recommendations")
public class Recommendation extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_query_id", nullable = false)
private UserQuery userQuery;
@Column(nullable = false, columnDefinition = "TEXT")
private String responseText;
@Column(columnDefinition = "TEXT")
private String recommendedLaptopIds;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private RecommendationSource source;
private Double score;
}
package az.etibarli.demorag2.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "user_queries")
public class UserQuery extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, columnDefinition = "TEXT")
private String queryText;
private BigDecimal minBudget;
private BigDecimal maxBudget;
private String useCase;
@Column(nullable = false, length = 500)
private String normalizedQuery;
@Builder.Default
@OneToMany(mappedBy = "userQuery")
private List<Recommendation> recommendations = new ArrayList<>();
}
package az.etibarli.demorag2.enums;
public enum GpuType {
INTEGRATED, DEDICATED
}
package az.etibarli.demorag2.enums;
public enum LaptopCategory {
GAMING, BUSINESS, EVERYDAY, ULTRABOOK
}
package az.etibarli.demorag2.enums;
public enum OperatingSystem {
WINDOWS, MACOS, LINUX, FREEDOS
}
package az.etibarli.demorag2.enums;
public enum RecommendationSource {
RAG
}
package az.etibarli.demorag2.enums;
public enum StorageType {
SSD, HDD, NVME
}
Step5: Liquibase migration
resource.changelog folderinin altinda db.changelog-master.sql
--liquibase formatted sql
--changeset etibarli:1-create-laptops-table
CREATE TABLE laptops
(
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
brand VARCHAR(255) NOT NULL,
model VARCHAR(255) NOT NULL,
price NUMERIC(10, 2) NOT NULL,
stock INTEGER NOT NULL DEFAULT 0,
cpu VARCHAR(255),
ram_gb INTEGER,
storage_gb INTEGER,
storage_type VARCHAR(50),
gpu_type VARCHAR(50),
gpu VARCHAR(255),
display_inch DOUBLE PRECISION,
display_resolution VARCHAR(255),
battery_hours INTEGER,
weight_kg DOUBLE PRECISION,
operating_system VARCHAR(50),
category VARCHAR(50),
color VARCHAR(255),
description TEXT,
indexed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP
);
--rollback DROP TABLE laptops;
--changeset etibarli:2-create-user-queries-table
CREATE TABLE user_queries
(
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
query_text TEXT NOT NULL,
min_budget NUMERIC(10, 2),
max_budget NUMERIC(10, 2),
use_case VARCHAR(255),
normalized_query VARCHAR(500) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP
);
--rollback DROP TABLE user_queries;
--changeset etibarli:3-create-recommendations-table
CREATE TABLE recommendations
(
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_query_id BIGINT NOT NULL REFERENCES user_queries (id),
response_text TEXT NOT NULL,
recommended_laptop_ids TEXT,
source VARCHAR(20) NOT NULL,
score DOUBLE PRECISION,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP
);
--rollback DROP TABLE recommendations;
--changeset etibarli:4-seed-laptops-data
WITH raw AS (SELECT (ARRAY ['Dell', 'HP', 'Lenovo', 'ASUS', 'Apple', 'Acer', 'MSI', 'Razer', 'Samsung', 'LG'])
[floor(random() * 10 + 1)] AS brand,
'X' || floor(random() * 9000 + 1000)::int AS model_code,
round((random() * 4500 + 300)::numeric, 2) AS price,
floor(random() * 200)::int AS stock,
(ARRAY ['Intel Core i5', 'Intel Core i7', 'Intel Core i9', 'AMD Ryzen 5',
'AMD Ryzen 7', 'AMD Ryzen 9', 'Apple M2', 'Apple M3'])
[floor(random() * 8 + 1)] AS cpu,
(ARRAY [8, 16, 32, 64])[floor(random() * 4 + 1)] AS ram_gb,
(ARRAY [256, 512, 1024, 2048])[floor(random() * 4 + 1)] AS storage_gb,
(ARRAY ['SSD', 'HDD', 'NVME'])[floor(random() * 3 + 1)] AS storage_type,
(ARRAY ['INTEGRATED', 'DEDICATED'])[floor(random() * 2 + 1)] AS gpu_type,
(ARRAY ['Intel Iris Xe', 'NVIDIA RTX 4060', 'NVIDIA RTX 4070',
'AMD Radeon 780M', 'NVIDIA RTX 3050'])[floor(random() * 5 + 1)] AS gpu,
(ARRAY [13.3, 14.0, 15.6, 16.0, 17.3])[floor(random() * 5 + 1)] AS display_inch,
(ARRAY ['1920x1080', '2560x1440', '3840x2160'])[floor(random() * 3 + 1)] AS display_resolution,
floor(random() * 15 + 3)::int AS battery_hours,
round((random() * 2.5 + 1.0)::numeric, 2) AS weight_kg,
(ARRAY ['WINDOWS', 'MACOS', 'LINUX', 'FREEDOS'])[floor(random() * 4 + 1)] AS os,
(ARRAY ['GAMING', 'BUSINESS', 'EVERYDAY', 'ULTRABOOK'])[floor(random() * 4 + 1)] AS category,
(ARRAY ['Black', 'Silver', 'Space Gray', 'White', 'Blue'])[floor(random() * 5 + 1)] AS color
FROM generate_series(1, 100000)),
enriched AS (SELECT raw.*,
CASE
WHEN price < 800 THEN 'budget-friendly'
WHEN price < 1800 THEN 'mid-range'
WHEN price < 3000 THEN 'premium'
ELSE 'high-end flagship'
END AS price_band,
CASE category
WHEN 'GAMING' THEN 'gaming laptop'
WHEN 'BUSINESS' THEN 'business laptop'
WHEN 'EVERYDAY' THEN 'everyday laptop'
WHEN 'ULTRABOOK' THEN 'ultrabook'
END AS category_phrase,
CASE category
WHEN 'GAMING' THEN 'gamers and demanding multitasking'
WHEN 'BUSINESS' THEN 'professionals and office work'
WHEN 'EVERYDAY' THEN 'students and everyday browsing and daily tasks'
WHEN 'ULTRABOOK' THEN 'travelers who need portability and long battery life'
END AS use_case
FROM raw)
INSERT INTO laptops (brand, model, price, stock, cpu, ram_gb, storage_gb, storage_type,
gpu_type, gpu, display_inch, display_resolution, battery_hours,
weight_kg, operating_system, category, color, description,
indexed, created_at, updated_at)
SELECT brand,
brand || ' ' || model_code,
price,
stock,
cpu,
ram_gb,
storage_gb,
storage_type,
gpu_type,
gpu,
display_inch,
display_resolution,
battery_hours,
weight_kg,
os,
category,
color,
brand || ' ' || model_code || ' is a ' || price_band || ' ' || category_phrase ||
' powered by ' || cpu || ' with ' || ram_gb || 'GB RAM, ' || storage_gb || 'GB ' ||
storage_type || ' storage, a ' || display_inch || '-inch ' || display_resolution ||
' display, ' || gpu || ' graphics and up to ' || battery_hours ||
' hours of battery life, priced at $' || price || ' - ideal for ' || use_case || '.',
false,
now() - (random() * interval '365 days'),
now()
FROM enriched;
--rollback DELETE FROM laptops;
Step6: Repository
package az.etibarli.demorag2.repository;
import az.etibarli.demorag2.entity.Laptop;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface LaptopRepository extends JpaRepository<Laptop, Long>, JpaSpecificationExecutor<Laptop> {
}
package az.etibarli.demorag2.repository;
import az.etibarli.demorag2.entity.Recommendation;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RecommendationRepository extends JpaRepository<Recommendation, Long> {
}
package az.etibarli.demorag2.repository;
import az.etibarli.demorag2.entity.UserQuery;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserQueryRepository extends JpaRepository<UserQuery, Long> {
}
Step7: Search hissesi
package az.etibarli.demorag2.search;
import az.etibarli.demorag2.enums.GpuType;
import az.etibarli.demorag2.enums.LaptopCategory;
import az.etibarli.demorag2.enums.OperatingSystem;
import az.etibarli.demorag2.enums.StorageType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LaptopFilterRequest {
private String brand;
private BigDecimal minPrice;
private BigDecimal maxPrice;
private Integer minRamGb;
private Integer minStorageGb;
private StorageType storageType;
private GpuType gpuType;
private LaptopCategory category;
private OperatingSystem operatingSystem;
private Double maxWeightKg;
private Integer minBatteryHours;
private Double minDisplayInch;
private Double maxDisplayInch;
}
package az.etibarli.demorag2.search;
import az.etibarli.demorag2.entity.Laptop;
import jakarta.persistence.criteria.Predicate;
import org.springframework.data.jpa.domain.Specification;
import java.util.ArrayList;
import java.util.List;
public class LaptopSpecification {
public static Specification<Laptop> withFilter(LaptopFilterRequest filter) {
return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (filter.getBrand() != null) {
predicates.add(cb.like(
cb.lower(root.get("brand")),
"%" + filter.getBrand().toLowerCase() + "%"
));
}
if (filter.getMinPrice() != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("price"), filter.getMinPrice()));
}
if (filter.getMaxPrice() != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("price"), filter.getMaxPrice()));
}
if (filter.getMinRamGb() != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("ramGb"), filter.getMinRamGb()));
}
if (filter.getMinStorageGb() != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("storageGb"), filter.getMinStorageGb()));
}
if (filter.getStorageType() != null) {
predicates.add(cb.equal(root.get("storageType"), filter.getStorageType()));
}
if (filter.getGpuType() != null) {
predicates.add(cb.equal(root.get("gpuType"), filter.getGpuType()));
}
if (filter.getCategory() != null) {
predicates.add(cb.equal(root.get("category"), filter.getCategory()));
}
if (filter.getOperatingSystem() != null) {
predicates.add(cb.equal(root.get("operatingSystem"), filter.getOperatingSystem()));
}
if (filter.getMaxWeightKg() != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("weightKg"), filter.getMaxWeightKg()));
}
if (filter.getMinBatteryHours() != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("batteryHours"), filter.getMinBatteryHours()));
}
if (filter.getMinDisplayInch() != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("displayInch"), filter.getMinDisplayInch()));
}
if (filter.getMaxDisplayInch() != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("displayInch"), filter.getMaxDisplayInch()));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
}
Step8: Service hissesi
package az.etibarli.demorag2.service;
import az.etibarli.demorag2.dto.request.CreateLaptopRequest;
import az.etibarli.demorag2.dto.response.LaptopResponse;
import az.etibarli.demorag2.search.LaptopFilterRequest;
import java.util.List;
public interface LaptopService {
LaptopResponse create(CreateLaptopRequest request);
LaptopResponse getById(Long id);
List<LaptopResponse> search(LaptopFilterRequest filter);
List<LaptopResponse> getAll();
}
package az.etibarli.demorag2.service.impl;
import az.etibarli.demorag2.dto.request.CreateLaptopRequest;
import az.etibarli.demorag2.dto.response.LaptopResponse;
import az.etibarli.demorag2.entity.Laptop;
import az.etibarli.demorag2.repository.LaptopRepository;
import az.etibarli.demorag2.search.LaptopFilterRequest;
import az.etibarli.demorag2.search.LaptopSpecification;
import az.etibarli.demorag2.service.LaptopService;
import az.etibarli.demorag2.service.LaptopVectorService;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class LaptopServiceImpl implements LaptopService {
private final LaptopRepository laptopRepository;
private final LaptopVectorService laptopVectorService;
@Override
@Transactional
public LaptopResponse create(CreateLaptopRequest request) {
Laptop laptop = Laptop.builder()
.brand(request.getBrand())
.model(request.getModel())
.price(request.getPrice())
.stock(request.getStock())
.cpu(request.getCpu())
.ramGb(request.getRamGb())
.storageGb(request.getStorageGb())
.storageType(request.getStorageType())
.gpuType(request.getGpuType())
.gpu(request.getGpu())
.displayInch(request.getDisplayInch())
.displayResolution(request.getDisplayResolution())
.batteryHours(request.getBatteryHours())
.weightKg(request.getWeightKg())
.operatingSystem(request.getOperatingSystem())
.category(request.getCategory())
.color(request.getColor())
.description(request.getDescription())
.build();
Laptop saved = laptopRepository.save(laptop);
laptopVectorService.indexLaptop(saved);
return toResponse(saved);
}
@Override
public LaptopResponse getById(Long id) {
Laptop laptop = laptopRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Laptop not found: " + id));
return toResponse(laptop);
}
@Override
public List<LaptopResponse> search(LaptopFilterRequest filter) {
return laptopRepository.findAll(LaptopSpecification.withFilter(filter))
.stream()
.map(this::toResponse)
.toList();
}
@Override
public List<LaptopResponse> getAll() {
return laptopRepository.findAll()
.stream()
.map(this::toResponse)
.toList();
}
private LaptopResponse toResponse(Laptop laptop) {
return LaptopResponse.builder()
.id(laptop.getId())
.brand(laptop.getBrand())
.model(laptop.getModel())
.price(laptop.getPrice())
.stock(laptop.getStock())
.cpu(laptop.getCpu())
.ramGb(laptop.getRamGb())
.storageGb(laptop.getStorageGb())
.storageType(laptop.getStorageType())
.gpuType(laptop.getGpuType())
.gpu(laptop.getGpu())
.displayInch(laptop.getDisplayInch())
.displayResolution(laptop.getDisplayResolution())
.batteryHours(laptop.getBatteryHours())
.weightKg(laptop.getWeightKg())
.operatingSystem(laptop.getOperatingSystem())
.category(laptop.getCategory())
.color(laptop.getColor())
.description(laptop.getDescription())
.createdAt(laptop.getCreatedAt())
.updatedAt(laptop.getUpdatedAt())
.build();
}
}
package az.etibarli.demorag2.service;
import az.etibarli.demorag2.entity.Laptop;
import az.etibarli.demorag2.repository.LaptopRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class LaptopVectorService {
private final VectorStore vectorStore;
private final LaptopRepository laptopRepository;
private static final int DEFAULT_TOP_K = 5;
private static final int BATCH_SIZE = 200;
public void indexLaptop(Laptop laptop) {
if (laptop.getId() == null) return;
vectorStore.add(List.of(toDocument(laptop)));
laptop.setIndexed(true);
laptopRepository.save(laptop);
}
public int indexLaptops(List<Laptop> laptops) {
List<Laptop> valid = laptops.stream()
.filter(l -> l.getId() != null)
.toList();
List<Document> documents = valid.stream()
.map(this::toDocument)
.toList();
vectorStore.add(documents);
valid.forEach(l -> l.setIndexed(true));
laptopRepository.saveAll(valid);
return valid.size();
}
public int syncAllLaptops() {
List<Laptop> all = laptopRepository.findAll();
int total = 0;
for (int i = 0; i < all.size(); i += BATCH_SIZE) {
List<Laptop> batch = all.subList(i, Math.min(i + BATCH_SIZE, all.size()));
total += indexLaptops(batch);
log.info("Synced {}/{} laptops", total, all.size());
}
return total;
}
@Async
public void indexIfNotIndexed(Laptop laptop) {
if (Boolean.FALSE.equals(laptop.getIndexed())) {
indexLaptop(laptop);
}
}
public List<Laptop> semanticSearch(String query, int topK) {
if (query == null || query.isBlank()) return List.of();
int k = topK <= 0 ? DEFAULT_TOP_K : topK;
List<Document> results = vectorStore.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(k)
.build()
);
List<Long> ids = results.stream()
.map(doc -> ((Number) doc.getMetadata().get("laptopId")).longValue())
.toList();
Map<Long, Laptop> laptopMap = laptopRepository.findAllById(ids)
.stream()
.collect(Collectors.toMap(Laptop::getId, l -> l));
return ids.stream()
.map(laptopMap::get)
.filter(Objects::nonNull)
.toList();
}
private Document toDocument(Laptop laptop) {
Map<String, Object> metadata = new HashMap<>();
metadata.put("laptopId", laptop.getId());
metadata.put("brand", laptop.getBrand());
metadata.put("model", laptop.getModel());
metadata.put("price", laptop.getPrice());
metadata.put("category", laptop.getCategory() != null ? laptop.getCategory().name() : null);
metadata.put("operatingSystem", laptop.getOperatingSystem() != null ? laptop.getOperatingSystem().name() : null);
return new Document(
"laptop-" + laptop.getId(),
laptop.getDescription(),
metadata
);
}
}
package az.etibarli.demorag2.service;
import az.etibarli.demorag2.dto.request.AskRequest;
import az.etibarli.demorag2.dto.response.LaptopResponse;
import az.etibarli.demorag2.dto.response.RecommendResponse;
import az.etibarli.demorag2.entity.Laptop;
import az.etibarli.demorag2.entity.Recommendation;
import az.etibarli.demorag2.entity.UserQuery;
import az.etibarli.demorag2.enums.RecommendationSource;
import az.etibarli.demorag2.repository.RecommendationRepository;
import az.etibarli.demorag2.repository.UserQueryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class RecommendationService {
private final LaptopVectorService laptopVectorService;
private final UserQueryRepository userQueryRepository;
private final RecommendationRepository recommendationRepository;
private final ChatClient chatClient;
public RecommendResponse recommend(AskRequest request) {
// Addım 1 — Sorğunu normalize et
String normalizedQuery = request.getQuestion().trim().toLowerCase();
// Addım 2 — UserQuery saxla
UserQuery userQuery = userQueryRepository.save(
UserQuery.builder()
.queryText(request.getQuestion())
.normalizedQuery(normalizedQuery)
.build()
);
// Addım 3 — Semantic search
List<Laptop> candidates = laptopVectorService.semanticSearch(request.getQuestion(), 5);
// Addım 4 — recommendedLaptopIds string-i yarat
String recommendedIds = candidates.stream()
.map(l -> l.getId().toString())
.collect(Collectors.joining(","));
// Addım 5 — responseText yarat
String responseText = candidates.isEmpty()
? "Sorgunuza uygun laptop tapilmadi."
: "Sizin ucun " + candidates.size() + " laptop tapildi: " +
candidates.stream()
.map(l -> l.getBrand() + " " + l.getModel())
.collect(Collectors.joining(", "));
// Addım 6 — Recommendation saxla
Recommendation recommendation = recommendationRepository.save(
Recommendation.builder()
.userQuery(userQuery)
.responseText(responseText)
.recommendedLaptopIds(recommendedIds)
.source(RecommendationSource.RAG)
.score(candidates.isEmpty() ? 0.0 : 1.0)
.build()
);
// Addım 7 — RecommendResponse qaytar
List<LaptopResponse> laptopResponses = candidates.stream()
.map(l -> LaptopResponse.builder()
.id(l.getId())
.brand(l.getBrand())
.model(l.getModel())
.price(l.getPrice())
.stock(l.getStock())
.cpu(l.getCpu())
.ramGb(l.getRamGb())
.storageGb(l.getStorageGb())
.storageType(l.getStorageType())
.gpuType(l.getGpuType())
.gpu(l.getGpu())
.displayInch(l.getDisplayInch())
.displayResolution(l.getDisplayResolution())
.batteryHours(l.getBatteryHours())
.weightKg(l.getWeightKg())
.operatingSystem(l.getOperatingSystem())
.category(l.getCategory())
.color(l.getColor())
.description(l.getDescription())
.createdAt(l.getCreatedAt())
.updatedAt(l.getUpdatedAt())
.build())
.toList();
return RecommendResponse.builder()
.queryId(userQuery.getId())
.recommendationId(recommendation.getId())
.responseText(responseText)
.source(RecommendationSource.RAG)
.laptops(laptopResponses)
.build();
}
}
Step9: Controller
package az.etibarli.demorag2.controller;
import az.etibarli.demorag2.dto.request.AskRequest;
import az.etibarli.demorag2.dto.response.RecommendResponse;
import az.etibarli.demorag2.service.RecommendationService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/ask")
@RequiredArgsConstructor
public class AskController {
private final RecommendationService recommendationService;
@PostMapping
public RecommendResponse ask(@RequestBody AskRequest request) {
return recommendationService.recommend(request);
}
}
package az.etibarli.demorag2.controller;
import az.etibarli.demorag2.dto.request.CreateLaptopRequest;
import az.etibarli.demorag2.dto.response.LaptopResponse;
import az.etibarli.demorag2.search.LaptopFilterRequest;
import az.etibarli.demorag2.service.LaptopService;
import az.etibarli.demorag2.service.LaptopVectorService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/laptops")
@RequiredArgsConstructor
public class LaptopController {
private final LaptopService laptopService;
private final LaptopVectorService laptopVectorService;
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public LaptopResponse create(@RequestBody CreateLaptopRequest request) {
return laptopService.create(request);
}
@GetMapping("/{id}")
public LaptopResponse getById(@PathVariable Long id) {
return laptopService.getById(id);
}
@GetMapping
public List<LaptopResponse> getAll() {
return laptopService.getAll();
}
@PostMapping("/search")
public List<LaptopResponse> search(@RequestBody LaptopFilterRequest filter) {
return laptopService.search(filter);
}
@PostMapping("/vector/sync")
public Map<String, Object> sync() {
int synced = laptopVectorService.syncAllLaptops();
return Map.of("synced", synced, "status", "ok");
}
}
Step10: Main class
package az.etibarli.demorag2;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableJpaAuditing
@EnableAsync
public class DemoRag2Application {
public static void main(String[] args) {
SpringApplication.run(DemoRag2Application.class, args);
}
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder.build();
}
}
Комментарии
Отправить комментарий