Chunking to cały system wyszukiwania: pułapka Spring AI 800 kontra 128
Gdy splitter wytwarza fragmenty po 800 tokenów, a embedder odczytuje tylko 128, system wyszukiwania nie może zobaczyć skróconej części na końcu. Rozmiar fragmentów, okno i nakładanie się to elementy systemu wyszukiwania.
Domyślny podzielacz w Spring AI dzieli tekst na fragmenty po około 800 tokenów, natomiast dołączony lokalny model embeddingu czyta jedynie pierwsze 128 tokenów z każdego takiego fragmentu. Brak parametru
topK, progu podobieństwa ani narzędzia do ponownego sortowania nie pozwala na wyświetlenie tekstu, który nigdy nie trafił do wektora.
To narzędzie do dzielenia tekstu decyduje o tym, co dany wektor może zawierać. Każdy kolejny etap po przechowywaniu wektorów jedynie przestawia w kolejności kandydaty, które to narzędzie uznaje za dostępne do wyszukiwania.
Problem: osiemset tokenów, sto dwadzieścia osiem wektorów
Trzy linijki kodu w Spring AI 2.0.1 tworzą indeks używany w wielu demonstracjach RAG dla JVM, a dwa ukryte parametry decydują o tym, co ten indeks może znaleźć:
List<Document> pages = new TikaDocumentReader(handbook).read();
List<Document> chunks = new TokenTextSplitter().apply(pages);
vectorStore.add(chunks);
Pierwsza liczba znajduje się w narzędziu do dzielenia tekstu. Funkcja new TokenTextSplitter() dzieli tekst na około 800 tokenów przy kodowaniu cl100k_base firmy OpenAI, a następnie cofa się do ostatniego znaku ., ?, ! lub nowej linii, jeśli pozostało co najmniej około 350 znaków – to odpowiada mniej więcej sześciuset słowom w języku angielskim na każdy fragment.
Druga liczba znajduje się w pliku JSON z tokenizatorem. Gdy sklep korzysta z lokalnego modelu TransformersEmbeddingModel firmy Spring AI, domyślnie ładowany jest model all-MiniLM-L6-v2. Plik konfiguracyjny tokenizer.json określa zasady skracania tekstu w następujący sposób:
"truncation": { "direction": "Right", "max_length": 128, "strategy": "LongestFirst", "stride": 0 }
DJL 0.36.0, warstwa tokenizacji w Spring AI 2.0.1, utrzymuje funkcję obcinania tekstu i przestrzega wartości max_length. Każdy wektor fragmentu jest więc obliczany na podstawie pierwszych 128 tokenów WordPiece, z których dwa to [CLS] i [SEP]. Różne narzędzia tokenizacji dają przybliżone, a nie dokładne stosunki – które nadal są nierówne. Wektor obejmuje mniej więcej pierwsze sto słów z fragmentu składającego się ze szysta setek słów. Pozostała część jest przechowywana, odzyskiwana i przekazywana modelowi do rozmowy, ale nigdy nie bierze udziału w procesie wyszukiwania.
Podział na fragmenty to nie zwykłe przetwarzanie wstępne, które ustawiasz raz przed „prawdziwą” optymalizacją narzędzia wyszukiwania. To on decyduje, co w ogóle może zostać odzyskane. Zwiększenie wartości topK z domyślnych 4 powoduje pojawienie się większej liczby fragmentów, których końce są równie niewidoczne, co może doprowadzić do umieszczenia tysięcy tokenów w wiadomości wejściowej. Narzędzie cross-encoder reranker jedynie przestawia kolejność kandydatów już zwróconych przez wyszukiwanie wektorowe; tekst niewidoczny nie może zostać uwzględniony. Próg podobieństwa ma niewiele miejsca na modyfikacje, gdy SearchRequest domyślnie akceptuje wszystko przy wartości 0.0.
Przejście na model OpenAI text-embedding-3-small eliminuje problem skracania tekstu, ponieważ jego limit wejściowy znacznie przewyższa rozmiar fragmentów, ale nadal występują mniej widoczne problemy – omówione poniżej. Wszystko po magazynie wektorowym może jedynie przestawić kolejność elementów, które pozwolił na to narzędzie do dzielenia na fragmenty.
Główne koncepcje architektoniczne
Cztery fakty dotyczące tego, jak fragment staje się hitem, znajdują bezpośrednie odzwierciedlenie w zachowaniu Spring AI.
Fragment to jeden wektor, a jeden wektor to średnia
TransformersEmbeddingModel uruchamia model ONNX, odczytuje last_hidden_state (384 wymiary na token) i przeprowadza operację mean-pooling z użyciem maski uwagi, pomijając elementy paddingu. Bez względu na długość fragmentu, system wyszukiwania widzi jeden wektor:
v = Σ mᵢ·hᵢ / Σ mᵢ hᵢ = vector for token i, mᵢ = 1 for a real token, 0 for padding
score(question, chunk) = cos(q, v)
Operacja mean-pooling jeszcze bardziej rozcieńcza charakterystyczne frazy znajdujące się poza oknem skracania – one nigdy nie trafiają do sumy. Wyniki wyszukiwania porównują wektor zapytania z tą średnią, więc dominujące tokeny wpływają zarówno na to, co zostanie uwzględnione w embeddingu, jak i na stopień podobieństwa, który może zostać zauważony.
Trzy tokenizery, a nic ich nie porównuje
Grenice fragmentów, dane wejściowe do embeddingu oraz często sam prompt do czatu używają różnych języków tokenów. Praktyczną zasadą przy konfiguracji lokalnych wersji MiniLM jest umieszczanie tokenów embeddingu w obrębie okna modelu, zachowując miejsce na specjalne tokeny:
chunk tokens (embedding tokenizer) ≤ window − 2 // 126 for the bundled MiniLM tokenizer
prompt tokens ≈ topK × chunk tokens + system + question + history
Jeśli narzędzie do dzielenia na fragmenty liczy tokeny typu cl100k_base, podczas gdy narzędzie do embeddingu używa formatu WordPiece, „800 tokenów” nie oznacza „800 tokenów embeddingu”. Zespoły, które nigdy nie porównują tych dwóch metod, tworzą indeksy, które wyglądają dobrze w metrykach przechowywania, ale zawodzą przy pytaniach wymagających odpowiedzi znajdującej się tylko na końcu fragmentów.
Pana/moje metadane są albo włączane do embeddingu, albo ignorowane – w zależności od jednego enum
Fragmenty zawierają metadane takie jak:
source: refund-policy.pdf
parent_document_id: 5b1f0c2e-8d7a-4c1e-9f3b-2a6d4e8c1b90
chunk_index: 3
total_chunks: 9
Annual plans are refunded pro rata from the day the request is received...
To, czy te pola są łączone w jeden ciąg tekstowy z embeddingiem, czy też wykluczane, zależy od ustawień formatera:
ContentFormatter formatter = DefaultContentFormatter.builder()
.withExcludedEmbedMetadataKeys("parent_document_id", "chunk_index", "total_chunks") // bookkeeping, not meaning
.build();
chunks.forEach(chunk -> chunk.setContentFormatter(formatter));
Wbudowywanie ścieżek source lub UUID może zanieczyszczać przestrzeń semantyczną; wykluczanie nagłówków może usunąć jedyne sygnały służące do rozróżniania treści. Starannie wybieraj między wbudowywaniem a pobieraniem metadanych – jedna zmiana w enumeracji wpływa zarówno na dokładność odzyskiwania informacji, jak i jakość cytatów.
Domyślny doradca pomija twoje cytaty
Typowa rozmowa typu Q&A wydaje się prosta:
ChatResponse response = chatClient.prompt()
.user(question)
.advisors(QuestionAnswerAdvisor.builder(vectorStore).build())
.call()
.chatResponse();
List<Document> used = response.getMetadata().get(QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS);
List<String> citations = used.stream()
.map(d -> d.getMetadata().get("source") + " § " + d.getMetadata().getOrDefault("title", ""))
.distinct()
.toList();
Domyślni doradcy mogą przekazywać tekst kontekstowy modelowi bez zachowania pól źródłowych dla poszczególnych fragmentów w strukturyzowanej odpowiedzi. Chociaż odzyskujesz właściwy akapit, nadal nie możesz pokazać strony PDF ani nagłówka, chyba że dostosujesz doradcę lub przetworzysz dokumenty przed wysłaniem zapytania. Cytaty to kwestia całego procesu, a nie element dekoracyjny interfejsu.
Przewodnik po praktycznej implementacji
Zmierz efekty odzyskiwania informacji, zanim dotkniesz narzędzia do dzielenia tekstu
Stwórz mały zbiór pytań do oceny, połączonych z fragmentami odpowiedzi, które muszą się znajdować w wyświetlonym tekście:
public record RetrievalCase(String question, String answerSpan) {}
public final class RetrievalEval {
private final VectorStore vectorStore;public RetrievalEval(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
public double recallAt(int k, List<RetrievalCase> cases) {
long hits = cases.stream().filter(c -> retrieved(c, k)).count();
return (double) hits / cases.size();
}
private boolean retrieved(RetrievalCase c, int k) {
List<Document> top = vectorStore.similaritySearch(
SearchRequest.builder().query(c.question()).topK(k).build());
String span = normalize(c.answerSpan());
return top.stream().anyMatch(d -> normalize(d.getText()).contains(span)); // a span split across chunks fails
}
private static String normalize(String s) {
return s == null ? "" : s.replaceAll("\\s+", " ").strip().toLowerCase(Locale.ROOT);
}
public static List<RetrievalCase> load(Path tsv) throws IOException {
return Files.readAllLines(tsv).stream()
.filter(line -> !line.isBlank())
.map(line -> line.split("\t", 2))
.map(p -> new RetrievalCase(p[0], p[1]))
.toList();
}
}
Zrób przegląd bieżącego indeksu, zapisz wyniki trafień lub niepowodzeń przy ustalonej wartości topK, a dopiero potem zmień ustawienia dzielacza. Bez punktu odniesienia każda modyfikacja rozmiaru fragmentów jest jedynie spekulacją.
Umieść każdy fragment w oknie embeddingu
Zabezpiecz dokumenty, aby długość fragmentów nie mogła być potajemnie skracana:
public final class EmbeddingWindowGuard implements DocumentTransformer {
private final HuggingFaceTokenizer tokenizer;
private final int window;
public EmbeddingWindowGuard(Path tokenizerJson, int window) throws IOException {
// truncation off, so we measure the real length rather than the clipped one
this.tokenizer = HuggingFaceTokenizer.newInstance(tokenizerJson, Map.of("truncation", "false"));
this.window = window;
}
public int count(String text) {
return tokenizer.encode(text).getIds().length; // includes [CLS] and [SEP]
}
@Override
public List<Document> apply(List<Document> chunks) {
for (Document chunk : chunks) {
int n = count(chunk.getText());
if (n > window) {
throw new IllegalStateException("%s chunk %s is %d tokens; the model reads %d".formatted(
chunk.getMetadata().get("source"), chunk.getMetadata().get("chunk_index"), n, window));
}
}
return chunks;
}
}
Konfiguracja może w odpowiednim momencie zwiększyć powiązane opcje tokenera:
spring.ai.embedding.transformer.tokenizer.options.maxLength=256
Jeśli model widzi tylko 128 tokenów, albo zmniejsz rozmiar fragmentów, aby się zmieściły, albo zmień model. Nie udawaj, że narzędzie do ponownego sortowania stworzy brakujące informacje.
Najpierw dziel według struktury, potem według rozmiaru
Nadaj priorytet nagłówkom, akapitom i granicom list przed prostym liczeniem tokenów:
public final class RecursiveTextSplitter extends TextSplitter {
private static final List<String> SEPARATORS = List.of("\n\n", "\n", ". ", " ");
private final ToIntFunction<String> tokens;
private final int maxTokens;
public RecursiveTextSplitter(ToIntFunction<String> tokens, int maxTokens) {
this.tokens = tokens; // the embedding model's tokenizer, not cl100k
this.maxTokens = maxTokens;
}
@Override
protected List<String> splitText(String text) {
return split(text, 0).stream().filter(s -> !s.isBlank()).toList();
}
private List<String> split(String text, int level) {
if (tokens.applyAsInt(text) <= maxTokens || level == SEPARATORS.size()) {
return List.of(text.strip());
}
String sep = SEPARATORS.get(level);
List<String> chunks = new ArrayList<>();
StringBuilder current = new StringBuilder();
for (String piece : text.split(Pattern.quote(sep))) {
String candidate = current.isEmpty() ? piece : current + sep + piece;
if (tokens.applyAsInt(candidate) <= maxTokens) {
current.setLength(0);
current.append(candidate);
continue;
}
if (!current.isEmpty()) {
chunks.add(current.toString().strip());
current.setLength(0);
}
if (tokens.applyAsInt(piece) <= maxTokens) {
current.append(piece);
} else {
chunks.addAll(split(piece, level + 1)); // only this piece drops to a finer boundary
}
}
if (!current.isEmpty()) {
chunks.add(current.toString().strip());
}
return chunks;
}
}
Następnie przestrzegaj ograniczeń okna embeddingu:
EmbeddingWindowGuard guard = new EmbeddingWindowGuard(Path.of("tokenizer.json"), 128);
TextSplitter splitter = new RecursiveTextSplitter(guard::count, 120); // headroom under the 126 usable
vectorStore.add(guard.apply(splitter.apply(sections)));
Rozbicie uwzględniające strukturę zachowuje definicje wraz z ich terminami, a kroki wraz z procedurami – dokładnie te jednostki, o które pytają użytkownicy.
Nadmiar przekrycia przynosi jedną korzyść, ale kosztuje ją wszędzie
Nadmiar przekrycia zwiększa szanse na to, że fraza graniczna pojawi się w całości wewnątrz jakiegoś fragmentu, ale kosztem powstania większej liczby fragmentów:
chunks ≈ ⌈ (N − o) / (s − o) ⌉
s = 800, o = 0 → about N / 800
s = 800, o = 400 → about N / 400 // twice the vectors, almost every sentence stored twice
Podwojenie stopnia przekrycia mniej więcej podwaja liczbę przechowywanych wektorów oraz koszty związane z ich generowaniem. Używaj umiarkowanego stopnia przekrycia, gdy granice przecinają zdania; nie traktuj dużego stopnia przekrycia jako zamiennika odpowiednio dobranego okna embeddingu.
Tabele i bloki kodu psują każdą strategię
Tabele w Markdown oraz kod zamknięty wymagają specjalnego potraktowania – rozbicia wzdłuż wierszy lub dedykowanych narzędzi do wyodrębniania danych – aby wektor nie spłaszczył całej tabeli do jednolitej masy:
static List<String> tableRows(String markdown) {
Parser parser = Parser.builder().extensions(List.of(TablesExtension.create())).build();
List<String> rows = new ArrayList<>();
parser.parse(markdown).accept(new AbstractVisitor() {
@Override
public void visit(CustomBlock block) {
if (!(block instanceof TableBlock table) || !(table.getLastChild() instanceof TableBody body)) {
visitChildren(block);
return;
}
List<String> headers = cells((TableRow) table.getFirstChild().getFirstChild());
for (Node row = body.getFirstChild(); row != null; row = row.getNext()) {
List<String> values = cells((TableRow) row);
StringJoiner line = new StringJoiner(" · ");
for (int i = 0; i < Math.min(headers.size(), values.size()); i++) {
line.add(headers.get(i) + ": " + values.get(i)); // every value keeps its column name
}
rws.add(line.toString());
}
}
});
return rows;
}
static List<String> cells(TableRow row) {
List<String> out = new ArrayList<>();
for (Node cell = row.getFirstChild(); cell != null; cell = cell.getNext()) {
StringBuilder text = new StringBuilder();
cell.accept(new AbstractVisitor() {
@Override public void visit(Text t) { text.append(t.getLiteral()); }
@Override public void visit(Code c) { text.append(c.getLiteral()); }
});
out.add(text.toString().strip());
}
return out;
}
Pozwól nagłówkom być przenoszonym dalej, aby fragment rozpoczynający się w środku sekcji nadal miał dostęp do swojego kontekstu:
public final class CarryHeadingForward implements DocumentTransformer {
@Override
public List<Document> apply(List<Document> documents) {
String heading = null;
for (Document doc : documents) {
Object title = doc.getMetadata().get("title");
if (title != null) {
heading = title.toString();
} else if (heading != null) {
doc.getMetadata().put("title", heading); // code blocks and the prose after them
}
}
return documents;
}
}
Bez umieszczenia nagłówka proces wydobywania zwraca niepowiązane wiersze, których model czatowy nie jest w stanie zlokalizować.
Zawieraj małe elementy, zwracaj duże
Korzystnym podejściem jest oddzielenie tego, co zawierasz, od tego, co zwracasz. Zindeksuj kompaktowe sekcje do celów wyszukiwania, a następnie rozszerz je do sekcji nadrzędnych dla treści promptu:
public record Section(String id, String source, String heading, String body) {}
public List<Section> retrieve(String question) {
List<Document> children = vectorStore.similaritySearch(
SearchRequest.builder().query(question).topK(20).build()); // search wide on small chunks
Map<String, Double> bestByParent = new HashMap<>();
for (Document child : children) {
String parentId = (String) child.getMetadata().get("parent_document_id");
double score = child.getScore() == null ? 0.0 : child.getScore();
bestByParent.merge(parentId, score, Math::max);
}
List<String> top = bestByParent.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(3) // answer from a few whole sections
.map(Map.Entry::getKey)
.toList();
return jdbc.sql("SELECT id, source, heading, body FROM section WHERE id IN (:ids)")
.param("ids", top)
.query(Section.class)
.list()
.stream()
.sorted(Comparator.comparingInt(s -> top.indexOf(s.id()))) // SQL does not keep our ranking
.toList();
}
Proces przyjmowania danych może analizować Markdown na sekcje i przechowywać zarówno szczegółowe elementy, jak i całe sekcje nadrzędne:
public void ingest(Resource markdown, String source) {
MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()
.withAdditionalMetadata("source", source)
.build();
List<Document> sections = new CarryHeadingForward().apply(new MarkdownDocumentReader(markdown, config).get());
for (Document s : sections) { // parents: stored whole, never embedded
jdbc.sql("INSERT INTO section (id, source, heading, body) VALUES (?, ?, ?, ?)")
.params(s.getId(), source, s.getMetadata().get("title"), s.getText())
.update();
}
List<Document> children = splitter.apply(sections); // each child: parent_document_id = its section
children.forEach(child -> child.setContentFormatter(formatter));
vectorStore.add(guard.apply(children)); // fail the job rather than truncate silently
}
Wyszukiwanie funkcjonuje skutecznie przy małych embeddingach; generowanie otrzymuje szerszy kontekst bez nadmiernego wypełniania wektora tokenami, które narzędzie do tworzenia embeddingów by zignorowało.
Pooling średnich wartości i maski uwagi w praktyce
Po przetworzeniu ONNX każdy pozostały token posiada wektor ukryty. Procedura mean pooling sumuje te wektory i dzieli wynik przez liczbę pozycji niezamaskowanych. Tokeny wypełniające nie przyczyniają się do wyniku, gdy maska jest poprawna; tokeny skrócone również nie mają wpływu, ponieważ nigdy nie istniały podczas przetwarzania. Czasami ludzie zakładają, że „model odczytał obiekt Document”. W rzeczywistości odczytał wyjście tokeryzatora. Jeśli w logach widzisz pełny tekst fragmentu obok wektora, patrzysz jednocześnie na rzeczywistość przechowywania danych i rzeczywistość wyszukiwania – a te dwa aspekty się różnią, gdy długość tekstu przekracza określony limit.
Następnie wyszukiwanie podobieństwa sortuje wyniki według odległości lub podobieństwa kosinowego względem tego połączonego wektora. Zapytanie dotyczące wyjątku związанego ze zwrotem pieniędzy, umieszczonego w drugiej połowie fragmentu polityki składającego się z 800 tokenów, ma słabe szanse w konkurencji z fragmentami, które od razu wspominają o zwrotach. Autorzy nie napisali złych polityk; problem polega na tym, że indeks odrzucał klauzulę dotyczącą wyjątków.
Kanale doradcze i higiena cytowań
Klienci czatowi, które wykorzystują doradców do wyszukiwania informacji, często łączą treści dokumentów w jeden ciąg tekstowy. Jeśli klucze z mapy metadanych nie trafiają do obiektu odpowiedzi ani do specjalnych znaczników cytowań w szablonie zapytania, model może udzielić poprawnej odpowiedzi, podczas gdy aplikacja nie będzie mogła wyróżnić odpowiedniej sekcji podręcznika. W testach traktuj pola cytowań jako część umowy dotyczącej wyszukiwania informacji: po każdej odpowiedzi sprawdź, czy każdy użyty identyfikator fragmentu odnosi się do source, chunk_index oraz nagłówka, jeśli taki istnieje.
Doradcy dostosowani pod potrzeby użytkownika mogą dodawać przypisy lub bloki JSON, które interfejs graficzny potrafi zinterpretować. Domyślne przykłady pomijają tę funkcjonalność, dlatego tak wiele wewnętrznych narzędzi RAG dostarczanych jest z „odpowiedziami, ale bez linków”.
Ekonomia nakładania się danych poza wzorem
Wzór na liczbę fragmentów przy nakładaniu się jest narzędziem planistycznym, a nie gwarancją jakości. Nakładanie się jest przydatne, gdy zdanie przekracza granicę; nie może jednak przywrócić tokenów, które zostały pominięte podczas embedowania. Wydawanie pieniędzy na nakładanie się przy zachowaniu wartości max_length na poziomie 128 oznacza wydawanie środków na duplikowanie niewidocznych fragmentów tekstu. Najpierw popraw zgodność z oknem, a dopiero potem dodaj niewielkie nakładanie się, jeśli podczas oceny nadal występuje problem z przekraczaniem granic.
Szczegóły podziału z naciskiem na strukturę
Recyrkularne narzędzia dzielące tekst, które najpierw próbują podzielić go na nagłówki, potem akapity, następnie zdania, a w końcu surowe znaki, działają poprawnie. Narzędzia uwzględniające nagłówki zachowują „## Refunds” wraz z jego punktami. Dzielenie na akapity zapewnia spójność argumentów. Dzielenie na znaki jest ostatecznością – powinno zawodzić w testach przyjmowania danych, jeśli dominuje w korpusie, co wskazuje, że dokumenty nie mają struktury nadającej się do indeksowania jako proza. W przypadku Markdownu używanego w referencjach API rozważ podział na punktach ##, aby każda metoda była oddzielnym wektorem.
Ścieżka wyszukiwania Embed-small/return-big
Podczas wyszukiwania systemy porównywania podobieństwa zwracają identyfikatory małych sekcji. Tabela łącząca lub identyfikator rodzica w metadanych załadowuje pełniejszy tekst sekcji dla danego zapytania. Taki model umożliwia MiniLM skupienie się na małych jednostkach semantycznych, podczas gdy LLM bierze pod uwagę otaczające je wyjaśnienia. Kosztem implementacji jest dodatkowe przechowywanie danych lub pole zawierające informacje o rodzicu – co jest tańsze niż udawanie, że okno 128 tokenów rozumie całą stronę.
Kultura oceny
Zbiory referencyjne powinny zawierać pytania, których odpowiedzi znajdują się na początku, w środku i na końcu długich sekcji. Nawet jeśli przechodzą tylko te z odpowiedziami umieszczonymi na początku, problem skracania treści nadal istnieje. Należy uwzględnić co najmniej jedno pytanie dotyczące tabel oraz jedno pytanie związane z kodem, jeśli takie formaty występują w korpusie. Oznacz powolne testy, które pobierają wagi w formacie ONNX, aby CI mogło je pominąć podczas standardowych testów PR, przy jednoczesnym zachowaniu kontroli czułości wektorowej w kompilacjach nocnych.
Konfiguracja i najlepsze praktyki
Należy dostosować zasady tokenizacji rozdzielacza do narzędzia tokenizującego embeddera, zachować treść fragmentów wewnątrz okna embeddingu, wykluczyć szumowe metadane z embeddingów, zachowując je jednak do celów cytowania, preferować strukturalne podziały, używać nakładania się elementów z umiarem oraz traktować specjalnie tabele i kod. Po każdej zmianie należy ponownie przeprowadzić ustalony zestaw testów oceniających.
Strategia testowania
Testy automatyczne powinny sprawdzać właściwości, które zazwyczaj pomijane są w demonstracjach.
Dowiedź, że koniec fragmentu zmienia jego wektor
@Test
@Tag("slow") // downloads the ONNX model on the first run
void theEndOfAChunkReachesTheVector() throws Exception {
TransformersEmbeddingModel model = new TransformersEmbeddingModel();
model.afterPropertiesSet();
String chunk = Files.readString(Path.of("src/test/resources/chunks/longest-chunk.txt"));
float[] original = model.embed(chunk);
float[] extended = model.embed(chunk + " The refund window is thirty days.");
assertThat(cosine(original, extended))
.as("identical vectors mean the appended sentence was truncated away")
.isLessThan(0.9999);
}
static double cosine(float[] a, float[] b) {
double dot = 0, na = 0, nb = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
Jeśli dodanie charakterystycznego tekstu poza oknem nie powoduje zmiany wektora, potwierdza to konieczność skracania fragmentów.
Dowiedź, że precyzja nie spada po zmianie narzędzia dzielenia na fragmenty
@Test
void recallAtFiveHoldsTheFloor() throws IOException {
List<RetrievalCase> cases = RetrievalEval.load(Path.of("src/test/resources/retrieval-cases.tsv"));
double recall = new RetrievalEval(vectorStore).recallAt(5, cases);
assertThat(recall).isGreaterThanOrEqualTo(RECALL_AT_5_FLOOR); // recorded from the current chunker; only ever raised
}
Należy utrzymać minimalną wartość precyzji@K we wszystkich eksperymentach z rozdzielaczami, aby „poprawy”, które niszczą znane odpowiedzi, nie mogły przejść niezauważone.
Sprawdź, czy każdy zindeksowany fragment można jest cytować
@Test
void everyIndexedChunkCarriesItsSourceAndHeading() {
List<Document> sample = vectorStore.similaritySearch(
SearchRequest.builder().query("refund").topK(50).build());
assertThat(sample).allSatisfy(d ->
assertThat(d.getMetadata()).containsKeys("source", "title", "parent_document_id"));
}
Przykłady wyników podobieństwa pokazują, że metadane source oraz nagłówków pozostają nienaruszone. Brak odnotowania cytatu jest wadą produktu, nawet jeśli tekst odpowiedzi wygląda poprawnie.
Zasoby
Dokumentacja Spring AI dotycząca narzędzi do dzielenia tekstu, modeli embeddingowych oraz asystentów; karta modelu ONNX MiniLM wraz z plikiem tokenizer.json; zachowanie tokenera DJL; oraz własne narzędzia do oceny procesu wyszukiwania. Kluczowa lekcja dotyczy architektury: jakość wyszukiwania zaczyna się od podziału tekstu na fragmenty. Dostosowywanie wartości topK nie może przywrócić tekstu, którego model embeddingowy nigdy nie widział.
Dlaczego demonstracje ukrywają te rozbieżności
Poradniki promują indeksowanie trzywierszowe, ponieważ działa na laptopie i zwraca jakiś wynik. Rzadko wydrukowują konfiguracje tokenizatora lub porównują długość WordPiece z długością podziałnika. W takich przypadkach awarie w produkcji wyglądają jak „RAG jest niewiarygodne”, mimo że indeks po prostu nigdy nie włączył fragmentu odpowiedzi. Poświęcenie dnia na przygotowanie narzędzi oceny przed pracą nad „poezją promptów” zazwyczaj skraca całkowity czas realizacji projektu.
Lista kontrolna operacyjna
- Wydrukuj wartość
max_lengthw narzędziu embeddingowym oraz dodatkowe obciążenia związane ze specjalnymi tokenami. - Zmierz medianę oraz długość 95% tokenów w chunkach w tokenizatorze narzędzia embeddingowego.
- Zatrzymaj proces przyjmowania danych, jeśli jakiś chunk przekroczy bezpieczne ograniczenia.
- Zachowaj zbiór idealnych pytań wraz z fragmentami odpowiedzi i wymaganiami dotyczącymi cytowania.
- Po każdej zmianie podziałnika lub metadanych przeprowadź testy idealnej pamięci i cytowania.
Zespoły, które traktują te kroki jako opcjonalne, odkrywają je na nowo podczas prezentacji dla kierownictwa, gdy odpowiedź z podręcznika znajduje się na drugiej stronie fragmentu składającego się z 800 tokenów.
Zakończenie
Podział na fragmenty stanowi wąskie gardło systemu wyszukiwania informacji. Wektory reprezentują średnią tego, co im pokazano; nie mogą wymyślić elementów, których nie widziały. Należy dostosować języki tokenów, dopasować okno przetwarzania, dzielić treść według struktury, obsługiwać tabele, zachować cytaty oraz udowodnić zachowanie za pomocą testów. Dopiero wtedy topK, progi oraz narzędzia do ponownego rankowania mogą działać na zbiorze kandydatów, który na to zasługuje.
Niespójność tokenizatorów jako klasa błędów pierwszej klasy
Kultury inżynieryjne, które traktują „800 tokenów” jako uniwersalną jednostkę, będą na zawsze błędnie określać rozmiar fragmentów danych. Wypisz zarówno kodowanie służące do dzielenia, jak i kodowanie do wstawiania danych w logi uruchamiania. Przetwórz próbkę fragmentów z produkcji za pomocą obu tokenizerów oraz na podstawie długości histogramów. Jeśli wartość p95 dla mechanizmu wstawiania danych przekroczy max_length - specials, doszukujesz się problemów w produkcji, nawet jeśli średnia długość wydaje się prawidłowa.
Zespoły przenoszące się z embeddingów OpenAI na lokalne MiniLM (lub odwrotnie) muszą ponownie podzielić dane na fragmenty, a nie tylko je ponownie wstawić. Korpus przygotowany dla jednego formatu nie nadaje się do drugiego.
Dlaczego topK i mechanizmy rerankingu nie mogą to skompensować
Zwiększenie wartości topK podnosi szansę na odzyskanie jakiegoś częściowo istotnego fragmentu, ale każdy fragment nadal ukrywa swój własny skrócony koniec. Budżety zapytań gwałtownie rosną: dziesięć fragmentów po 800 tokenów to tysiące tokenów tekstu, który w większości nigdy nie był wyszukiwany. Kodery krzyżowe, które oceniają cały tekst fragmentu, widzą jedynie kandydatów już zaproponowanych na etapie wektorowym; nie mogą promować fragmentu, którego wyszukiwalny prefiks nigdy się nie pokrył. Progi bliskie zera już przyjmują niemal wszystko, co zwraca wyszukiwanie wektorowe; ich zaostrzenie odrzuca kandydatów bez przywracania niewidocznych fragmentów.
Rozwiązanie leży w procesie pobierania danych: dostosuj okno, zmień model lub dziel dane, aż zostanie zagwarantowane dopasowanie.
Matryca polityki metadanych
Decyduj według pola: włączyć, tylko pobrać lub usunąć. Ścieżki i UUID-y zazwyczaj psują struktury embedów. Nagłówki często pomagają w rozróżnianiu znaczeń i powinny być czasami włączane, a zawsze pobierane. Wartości logiczne takie jak deprecated lepiej umieścić w filtrach niż w tekście. Udokumentuj tabelę obok narzędzia do budowania formaterów, aby przyszłe edycje nie przypadkowo zmieniały wartości zmiennych typu enum.
Umowy cytowania w doradczikach Spring AI
Jeśli domyślny doradczik Q&A przekształca dokumenty w łańcuchy znaków, otocz go lub zastąp tak, aby każdy użyty fragment zachowywał pola source, chunk_index oraz nagłówek. Przypisy w interfejsie użytkownika powinny odwoływać się do tych pól. Dodaj test, który zawiedzie, gdy cytaty będą puste mimo niepustego kontekstu. Zaufanie do produktu zależy od tego bardziej niż od wyników BLEU.
Sposoby strukturalnego dzielenia
Dla dokumentacji Markdown: dziel na ##, a następnie na akapity. Dla komentarzy w stylu Javadoc: dziel według granic deklaracji. Dla logów: nie umieszczaj całych ścieżek wywołań jako jednego fragmentu; dziel według dat lub identyfikatorów błędów. Dla tabel: jeden wiersz lub jedna logiczna rekord na fragment, z nagłówkami kolumn jako tekstem prefiksu. Dla kodu: preferuj granulację na poziomie funkcji zamiast dowolnych okien tokenów, aby pytania dotyczące miejsca wywołania mogły odnaleźć całe funkcje.
Nadmiar jako narzędzie chirurgiczne
Wykorzystuj nadmiar, gdy ocena pokazuje pominięcie granic w zdaniach znajdujących się na styku fragmentów. Zwykłą wartością początkową jest od pięciu do piętnastu procent wielkości fragmentu; zmierz koszt przechowywania i włączania przed przejściem na pięćdziesiąt procent. Nadmiar nigdy nie naprawia skracania treści; jedynie kopiuje widoczne prefiksy, jeśli okno jest już pełne.
Operacyjne zastosowanie strategii Embed-small/return-big
Zachowaj section_id, parent_id oraz body_small / body_full (lub połącz je z tabelą sekcji). Wyszukuj w embeddingach body_small; używaj body_full do uzyskiwania dodatkowych informacji. Monitoruj przypadki, gdy krótkie treści nie zawierają wystarczająco dużo sygnału — w takich sytuacjach dostosuj mechanizm dzielenia treści, a nie tylko wartość topK.
Zestaw testów zapewniający spójność zachowania
- Czułość na przycięcie: zmień sufiks fragmentu poza określonym zakresem; sprawdź zasadę zmian wektorów (powinny pozostać niezmienione po przycięciu).
- Minimalny poziom trafności: pytania wzorcowe z fragmentami w części początkowej, środkowej i końcowej; odrzuć budowy, w których po zmianie narzędzia dzielenia spada trafność w częściach środkowej i końcowej.
- Przetrwanie cytatów: przeprowadź próbne wyodrębnianie informacji; upewnij się, że klucze metadanych są obecne.
- Ochrona przed zbyt dużą długością: proces przyjmowania danych zawodzi, jeśli liczba tokenów po embedowaniu przekracza bezpieczną wartość maksymalną.
Powolne testy, które pobierają wagi ONNX, powinny być wykonywane w trybie nocnym; zachowaj prosty ścieżkę jednostki do testów CI w PR.
Lista kontrolna operacyjna (rozszerzona)
Zgodnij języki tokenów; zapewnij dopasowanie okna; dziel dane najpierw według struktury; używaj rozproszonego nakładania się danych; wyznacz specjalistów od tabel i kodu; utrzymuj macierz metadanych; zastosuj narzędzia chroniące cytaty; wykonaj testy referencyjne przed i po zmianach; używaj strategii embed-small/return-big, gdy generowanie wymaga kontekstu niedostępnego dla mechanizmu embedowania. Dzielenie danych na fragmenty pozostaje wąskim gardłem systemu wyszukiwania — traktuj to wyraźnie jako priorytet.