Spring AI · RAG ilə sənədlərə əsaslanan cavablar · Dərs 3

Öz sənədlərin əsasında sual-cavab tətbiqi

Spring AI advisor mexanizmi ilə axtarışla zənginləşdirilmiş generasiya (RAG) qur. Modelin təlim məlumatlarında olmayan sənədləri cavablar üçün mənbə kimi istifadə et.

Öz sənədlərin əsasında sual-cavab tətbiqi

Yaddaşda qurulan sənəd dəstini iki icazəli mətn resursu ilə əvəz edəcək, onları bölüb indeksləyəcək, sonra ayrıca aktiv edilən lokal RAG endpoint-i açacağıq. Öyrənənin Study Coach layihəsində davam et. Bu, Backend Academy-nin öz backend-inə və ya məzmun bazasına dəyişiklik deyil.

1. İcazəli UTF-8 mənbələri saxla

src/main/resources/rag/inheritance.txt faylını yarat:

Struktur
A Java class other than Object has one direct superclass.
A class may implement multiple interfaces.

src/main/resources/rag/resources.txt faylını yarat:

Struktur
A try-with-resources statement closes its resources when control leaves the try block.
A resource must implement AutoCloseable.

Bunlar əvvəl istifadə etdiyimiz eyni qısa tədris qeydləridir. Fayl adlarını sabit saxla; məşq yüklənmiş fayl, ixtiyari fayl yolu və ya URL qəbul etmir. Xarici modellərlə təcrübə aparanda onları qapalı kurs/istifadəçi sənədləri ilə əvəz etmə.

2. Embedding-dən əvvəl oxu, böl və yoxla

src/main/java/academy/study/StudyDocumentLoader.java faylını saxla:

Java
package academy.study;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.ClassPathResource;

public final class StudyDocumentLoader {
    private static final int MAX_BYTES = 16000;

    private StudyDocumentLoader() {}

    public static List<Document> load() throws IOException {
        var documents = new ArrayList<Document>();
        documents.addAll(read("rag/inheritance.txt", "inheritance-v1"));
        documents.addAll(read("rag/resources.txt", "resources-v1"));
        return List.copyOf(documents);
    }

    private static List<Document> read(String path, String sourceId) throws IOException {
        try (var input = new ClassPathResource(path).getInputStream()) {
            return parse(sourceId, input.readNBytes(MAX_BYTES + 1));
        }
    }

    static List<Document> parse(String sourceId, byte[] bytes) {
        if (sourceId == null || !sourceId.matches("[a-z0-9-]{1,40}")
                || bytes == null || bytes.length == 0 || bytes.length > MAX_BYTES) {
            throw new IllegalArgumentException("Invalid source input");
        }
        var reader = new TextReader(new ByteArrayResource(bytes, "approved lab note"));
        reader.setCharset(StandardCharsets.UTF_8);
        reader.getCustomMetadata().putAll(Map.of("source_id", sourceId, "visibility", "public"));
        var splitter = TokenTextSplitter.builder().withChunkSize(80)
                .withMinChunkSizeChars(0).withMinChunkLengthToEmbed(0)
                .withMaxNumChunks(32).withKeepSeparator(true).build();
        var chunks = splitter.apply(reader.read());
        if (chunks.isEmpty() || chunks.size() > 16) {
            throw new IllegalArgumentException("Unexpected chunk count");
        }
        var result = new ArrayList<Document>();
        for (int i = 0; i < chunks.size(); i++) {
            Document chunk = chunks.get(i);
            String id = sourceId + "-chunk-" + (i + 1);
            String text = "[source:" + id + "]\n" + chunk.getText();
            if (chunk.getText() == null || chunk.getText().isBlank() || text.length() > 1500) {
                throw new IllegalArgumentException("Invalid chunk text");
            }
            var metadata = new HashMap<String, Object>(chunk.getMetadata());
            metadata.put("chunk_id", id);
            result.add(Document.builder().id(id).text(text).metadata(metadata).build());
        }
        return List.copyOf(result);
    }
}

Reader UTF-8 mətni oxuyur və mənbə metadatasını əlavə edir. Splitter mətn parçaları yaradarkən metadatanı qoruyur. Loader yaradılmış ID-ləri sabit mənbə-versiya/parça ID-ləri ilə əvəz edir və həmin işarələri modelə göndərilən mətnə yerləşdirir. Spring AI sənəd reader və splitter-ləri

TextReader məzmunu almamış bayt oxunuşu məhdudlaşdırılır. Hər mənbə ən çox 16 000 baytdır; bölünəndən sonra 1-16 parça olmalı, hər parça mənbə işarəsi daxil ən çox 1 500 UTF-16 vahidi tutmalıdır. topK(2) ilə tapılmış mətn təlimat və sual xaric ən çox 3 000 vahid olur. Bunlar tətbiq limitləridir, modelin token büdcəsi deyil.

Splitter-in 80 tokenlik hədəfi öz tokenizer və durğu qaydalarından istifadə edir. Parçaların dəqiq bərabər ölçüdə olacağını və ya tokenizer-in seçdiyin modelə uyğun gəldiyini düşünmə. 1.1.8-də maxNumChunks həddinə çatanda sonda qalıq parça yarana bilər, ona görə bu parametr təkbaşına sərt çıxış həddi deyil. Bölünmədən sonrakı yoxlamalar mənbə mətnini səssizcə atmaq əvəzinə uyğun olmayan nəticəni rədd edir. Kod blokları və abzaslar birlikdə qalmalı olduqda sərhədlərə yenidən bax.

3. Açıq şəkildə aktiv edilən məşq profilini qur

src/main/java/academy/study/RagLabConfiguration.java faylını saxla:

Java
package academy.study;

import java.io.IOException;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration
@Profile("rag-lab")
public class RagLabConfiguration {
    @Bean
    SimpleVectorStore studyVectorStore(EmbeddingModel embeddingModel) throws IOException {
        var store = SimpleVectorStore.builder(embeddingModel).build();
        store.add(StudyDocumentLoader.load());
        return store;
    }

    @Bean
    RagStudyClient ragStudyClient(ChatModel chatModel, SimpleVectorStore studyVectorStore) {
        return new RagStudyClient(chatModel, studyVectorStore);
    }
}

src/main/java/academy/study/RagController.java faylını saxla:

Java
package academy.study;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.springframework.context.annotation.Profile;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Profile("rag-lab")
public class RagController {
    private final RagStudyClient client;

    public RagController(RagStudyClient client) {
        this.client = client;
    }

    @PostMapping("/api/study/rag/questions")
    public ResponseEntity<RagStudyClient.RagAnswer> ask(@Valid @RequestBody Question request) {
        var result = client.answer(request.question(), request.language());
        int status = result.status() == RagStudyClient.Status.NO_CONTEXT ? 422 : 200;
        return ResponseEntity.status(status).body(result);
    }

    public record Question(@NotBlank @Size(max = 2000) String question,
                           @NotNull StudyPromptFactory.Language language) {}
}

src/main/resources/application-rag-lab.yml faylını saxla:

yaml
spring:
  ai:
    openai:
      embedding:
        options:
          model: ${OPENAI_EMBEDDING_MODEL}

Profil standart olaraq sönülüdür. Aktiv ediləndə tətbiq başlayarkən faylları oxuyur və onları indeksləmək üçün konfiqurasiya edilmiş EmbeddingModel çağırır. OpenAI starter mövcud açar konfiqurasiyasını istifadə edir; OPENAI_EMBEDDING_MODEL dəyişənini OPENAI_API_KEY və OPENAI_MODEL ilə yanaşı eyni lokal mühit mexanizmi ilə ver. Embedding və chat modelləri ayrı seçimlərdir. İnteqrasiyadan və metadata rejimindən asılı olaraq embedding sorğusuna sənəd metadatası da daxil ola bilər; nəyin göndəriləcəyini yoxla. Giriş açarlarını bu fayllara yazma. OpenAI embedding konfiqurasiyası

Bu konfiqurasiya hər başlanğıcda yeni yaddaşdaxili indeks yaradır. Yenidən indeksləmə embedding xərci yarada bilər. Davamlı production indeksi versiyalı hazırlama prosesi, köhnəlmiş parçaların silinməsi və tam hazırlanmış yeni indeksə idarə olunan keçid tələb edir; yalnız yeni parçalar əlavə etmək köhnəlmiş məzmunun axtarışda qalmasına səbəb ola bilər.

4. Sənəd hazırlığını və HTTP müqaviləsini offline test et

src/test/java/academy/study/StudyDocumentLoaderTest.java faylını saxla:

Java
package academy.study;

import java.nio.charset.StandardCharsets;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SimpleVectorStore;

import static academy.study.StudyPromptFactory.Language.ENGLISH;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

class StudyDocumentLoaderTest {
    @Test
    void loadsApprovedFilesWithStableIds() throws Exception {
        var first = StudyDocumentLoader.load();
        var second = StudyDocumentLoader.load();
        assertEquals(List.of("inheritance-v1-chunk-1", "resources-v1-chunk-1"),
                first.stream().map(Document::getId).toList());
        assertEquals(first.stream().map(Document::getId).toList(),
                second.stream().map(Document::getId).toList());
        assertTrue(first.stream().allMatch(d -> "public".equals(d.getMetadata().get("visibility"))));
    }

    @Test
    void splitsLongerTextAndPreservesSourceIdentity() {
        byte[] bytes = "A class has one direct superclass.\n".repeat(60)
                .getBytes(StandardCharsets.UTF_8);
        var chunks = StudyDocumentLoader.parse("long-v1", bytes);
        assertTrue(chunks.size() > 1 && chunks.size() <= 16);
        for (Document chunk : chunks) {
            assertEquals("long-v1", chunk.getMetadata().get("source_id"));
            assertTrue(chunk.getText().startsWith("[source:" + chunk.getId() + "]"));
            assertTrue(chunk.getText().length() <= 1500);
        }
    }

    @Test
    void rejectsInvalidAndOversizedInput() {
        assertThrows(IllegalArgumentException.class,
                () -> StudyDocumentLoader.parse("one", new byte[0]));
        assertThrows(IllegalArgumentException.class,
                () -> StudyDocumentLoader.parse("one", new byte[16001]));
        assertThrows(IllegalArgumentException.class,
                () -> StudyDocumentLoader.parse("bad id", "Text".getBytes(StandardCharsets.UTF_8)));
        assertThrows(IllegalArgumentException.class,
                () -> StudyDocumentLoader.parse("one", "   ".getBytes(StandardCharsets.UTF_8)));
    }

    @Test
    void answersFromLoadedChunksThroughTheAdvisor() throws Exception {
        var store = SimpleVectorStore.builder(new KeywordEmbeddingModel()).build();
        store.add(StudyDocumentLoader.load());
        var model = mock(ChatModel.class);
        when(model.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(
                new Generation(new AssistantMessage("Resources are closed. [source:resources-v1-chunk-1]")))));
        var answer = new RagStudyClient(model, store).answer("Explain AutoCloseable resources", ENGLISH);
        assertEquals(List.of("resources-v1-chunk-1"), answer.sourceIds());
        assertEquals(RagStudyClient.Status.ANSWERED, answer.status());
    }
}

src/test/java/academy/study/RagControllerTest.java faylını saxla:

Java
package academy.study;

import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static academy.study.StudyPromptFactory.Language.ENGLISH;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

class RagControllerTest {
    @Test
    void mapsAnswersNoContextAndInvalidInput() throws Exception {
        var client = mock(RagStudyClient.class);
        when(client.answer("inheritance", ENGLISH)).thenReturn(new RagStudyClient.RagAnswer(
                RagStudyClient.Status.ANSWERED, "One superclass.", List.of("inheritance-v1-chunk-1")));
        when(client.answer("galaxies", ENGLISH)).thenReturn(new RagStudyClient.RagAnswer(
                RagStudyClient.Status.NO_CONTEXT, null, List.of()));
        var mvc = MockMvcBuilders.standaloneSetup(new RagController(client)).build();
        String path = "/api/study/rag/questions";
        mvc.perform(post(path).contentType(MediaType.APPLICATION_JSON)
                        .content("{\"question\":\"inheritance\",\"language\":\"ENGLISH\"}"))
                .andExpect(status().isOk()).andExpect(jsonPath("$.sourceIds[0]").value("inheritance-v1-chunk-1"));
        mvc.perform(post(path).contentType(MediaType.APPLICATION_JSON)
                        .content("{\"question\":\"galaxies\",\"language\":\"ENGLISH\"}"))
                .andExpect(status().isUnprocessableEntity()).andExpect(jsonPath("$.answer").isEmpty());
        mvc.perform(post(path).contentType(MediaType.APPLICATION_JSON).content("{}"))
                .andExpect(status().isBadRequest());
        verify(client).answer("inheritance", ENGLISH);
        verify(client).answer("galaxies", ENGLISH);
        verifyNoMoreInteractions(client);
    }
}

Testlər sabit mənbə ID-lərini, bölünməni, metadatanı, giriş hədlərini, axtarışdan cavaba bağlantını və HTTP 200/422/400 davranışını yoxlayır. Xarici hesablar deyil, test embedding əvəzedicisi və chat mock-u istifadə olunur. İcra et:

sh
env -u RUN_LIVE_EVALUATION mvn clean verify

Qırx dörd offline metod keçməli, bir real qiymətləndirmə metodu buraxılmalıdır. Offline dəst rag-lab profilini aktiv etmir və uzaq model vasitəsilə indeksləmir.

5. Endpoint-i aktiv etməyin nə edəcəyini bil

Yalnız icazəli provayderləri və ya açıq şəkildə konfiqurasiya edilmiş lokal provayder əvəzedicisini hazırladıqdan sonra profili başlat:

sh
java -jar target/study-coach-0.0.1-SNAPSHOT.jar --spring.profiles.active=rag-lab

Başlanğıcda hər iki icazəli sənəd embedding provayderinə göndərilə bilər. Sorğu sualın embedding-ini hesablayır, saxlanclarda axtarır və chat modelini çağırır. Giriş modulundakı loopback ünvanını və spring.ai.retry.max-attempts: 1 parametrini saxla. Məşqdə autentifikasiya, sorğu tezliyi limiti və tam production xəta cavabları yoxdur.

İkinci terminaldan sorğunun forması belədir:

sh
curl -i http://127.0.0.1:8080/api/study/rag/questions \
  -H 'Content-Type: application/json' \
  --data '{"question":"Explain class inheritance","language":"ENGLISH"}'

Uğurlu cavabda status, cavab mətni və tapılmış sourceIds olur. Uyğun kontekst olmadıqda HTTP 422 ilə NO_CONTEXT, null cavab və boş mənbə siyahısı qaytarılır; advisor yenə generasiya çağırışını edir. Etibarsız giriş HTTP 400 verir. Digər tutulmayan xətalar adətən HTTP 500 ilə nəticələnir. Real axtarış nəticəsi və cavabın ifadəsi bu testlərlə sabitlənmir.

6. Mənbə mənşəyini və qalan məhdudiyyətləri nəzərdən keçir

Tapşırıq: lokal resurs mətnini dəyiş və yenidən build et. Mənbə versiyasının niyə dəyişməli olduğunu və davamlı indeksdə köhnə parçaların niyə silinməli olduğunu izah et. Sonra qapalı sənədlərdən istifadə üçün hansı yoxlamaların çatmadığını müəyyən et.

Cavabın yoxlanması: parça ID-lərində mənbə versiyası var ki, cavabı konkret sənəd dəsti versiyasına bağlamaq mümkün olsun. Bu məşq yeni saxlanclar qurur, davamlı saxlanclar isə açıq əvəzləmə siyasəti tələb edir. Qapalı sənədlər üçün hazırlama, axtarış və provayderə açıqlamadan əvvəl serverdə giriş hüquqları yoxlanmalı, uyğun məlumat emalı qaydaları tətbiq edilməlidir. Metadata işarəsi və ya prompt təlimatı kifayət deyil.

Tapılmış sənədlərdə yanlış faktlar və ya prompt-injection təlimatları da ola bilər. Axtarış uyğunluğunu, iddiaların dəstəklənməsini və istinad davranışını ayrı qiymətləndir. Lokal model əvəzediciləri və lokal provayder testləri mexanizmi yoxlaya bilər; real embedding keyfiyyəti, çoxdilli axtarış, gecikmə, xərc və cavabın düzgünlüyü burada yoxlanılmamış qalır.