跪拜 Guibai
← Back to the summary

A Three-Step Upload Pipeline That Eliminates Dirty Data in RAG Apps

Continuing from the previous article: we tackled Query rewriting and doubled retrieval accuracy. But those were all "single-point capabilities"—we still didn't have a truly usable product. Today, we're going to string together everything we've learned so far and build a real-world interview knowledge base project: upload a PDF of rote-learning material → automatically parse and chunk it → vectorize and store it → have a streaming AI conversation based on it → run recall tests anytime to see the results.

1. Product Goal: Solving the "Can't Find What I Memorized" Pain Point

First, consider a real-world scenario:

markdown

You spent three months organizing 20 PDFs of interview prep material. Before an interview, you want to quickly review them:

- "What's the deal with ThreadLocal memory leaks?" → You remember reading it, but in which PDF and on which page? Can't find it.
- "What are the situations where MySQL indexes fail?" → It's probably in some document, but you've been searching for half an hour.
- "What's the principle behind Spring Boot auto-configuration?" → You memorized it, but you're not sure if you remember it accurately.

Even worse:
- Asking an AI → It makes things up, and you don't know if it's correct.
- Searching your own documents → Full-text search only matches keywords, not semantic meaning.

The pain point is clear: The materials are in your hands, but the AI's answers are not under your control.

RAG is built for this exact scenario:

markdown

Feed your materials into a vector database
  ↓
When a question is asked, first retrieve the most relevant chunks
  ↓
Provide the retrieval results as context to the large language model
  ↓
The AI can only take an "open-book exam" and is not allowed to improvise

The goal of this article is to close this loop and create an out-of-the-box interview knowledge base system.

2. Technology Selection and Overall Architecture

2.1. Why These Choices?

Layer Selection Reason
LLM Alibaba Cloud Bailian qwen-max + text-embedding-v1 Low latency within China, native Spring AI compatibility
Vector DB Redis (RedisVectorStore) Can run on a single machine, good metadata filtering support, no need for an extra ES deployment
Relational DB MySQL + JPA Manages knowledge base/file/chunk metadata, each has its own role alongside the vector DB
Doc Parsing Apache Tika Handles PDF/Word/Excel/PPT/TXT with a single line of code
Frontend Vue3 + Element Plus Responsive, component-based, fast development

2.2. Architecture Overview

arduino

┌──────────────────── Frontend Vue3 ────────────────────┐
│ KB List │ Doc Mgmt │ AI Chat │ Recall Test │ Settings │
└───────┬──────────────────────────────────────────┘
        │  HTTP (axios) + SSE (fetch + ReadableStream)
        │
┌───────▼────────── Backend Spring Boot ────────────────┐
│  ChatController        RagController               │
│   /chat/flux (SSE)      /api/knowledge/**            │
│        │                    │                       │
│  ChatService            KnowledgeService            │
│  Retrieve+Context+Stream  Upload/Chunk/Vectorize/Recall │
│        │                    │          │             │
│        ▼                    ▼          ▼             │
│   ChatModel(Flux)        Tika Parse   TokenTextSplitter│
│   EmbeddingModel         FileSystem  VectorStore    │
└────────┬──────────────────────┬──────────────┬─────┘
         ▼                      ▼              ▼
     Alibaba Bailian         MySQL          Redis
    (qwen + embedding)    (3 Metadata Tables)    (Vector Index)

Core Dependencies:


# pom.xml key dependencies
spring-ai-starter-model-openai        # Connects to Bailian (OpenAI-compatible protocol)
spring-ai-tika-document-reader       # Tika document parsing
spring-ai-starter-vector-store-redis  # Redis vector storage
spring-ai-rag                        # RAG submodule (query rewriting, etc.)
spring-boot-starter-data-jpa         # MySQL persistence
jedis                                # Redis client

2.3. Connecting to Bailian


# application.yml
spring:
  ai:
    openai:
      api-key: ${DASHSCOPE_API_KEY}
      base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
      chat:
        options:
          model: qwen-max
      embedding:
        options:
          model: text-embedding-v1

💡 Bailian's qwen-max supports a 128K context window, which is sufficient for RAG scenarios. text-embedding-v1 is 1536 dimensions; you must ensure it matches the Redis index dimension (a pitfall we'll discuss later).

3. Database Design: Three Tables to Support the Entire System

3.1. Why a "Dual ID" Design?

A common pitfall in RAG systems is using auto-increment IDs as Redis vector document IDs. This causes big problems—

sql

-- ❌ Wrong approach
knowledge_base: id=1
file_info: id=101 (FK kb_id=1)
text_slice: id=1001, id=1002 ... (FK file_id=101)

-- Redis vector document IDs use auto-increment IDs
doc:1001 → Exposes data scale
doc:1002 → Issues can arise if IDs don't match during deletion

The correct approach is to use auto-increment primary keys for table relationships, and UUIDs for external references and Redis:

sql

-- ✅ Correct approach
knowledge_base: id=1 (auto-increment), knowledge_base_id='550e8400-e29b-41d4-a716-446655440000' (UUID)
file_info: id=101 (auto-increment), file_id='6ba7b810-9dad-11d1-80b4-00c04fd430c8' (UUID)
text_slice: id=1001 (auto-increment), slice_id='7c9e6f7a-9dad-11d1-80b4-00c04fd430c8' (UUID)
                                             ↑ This UUID also serves as the Redis vector document ID

3.2. Three Table Structures

markdown

knowledge_base
├── id (PK, auto-increment)           Used for inter-table foreign keys
├── knowledge_base_id (UUID)  Used for Redis metadata, globally unique
├── name / category / icon
├── status (active / archived)
└── create_time

file_info
├── id (PK, auto-increment)
├── file_id (UUID)           Used for Redis metadata
├── knowledge_base_id (FK → knowledge_base.id)
├── file_name / file_type / file_size
├── storage_path (physical disk path)
├── chunk_size / chunk_overlap
└── parse_status (pending / parsing / success / failed)

text_slice
├── id (PK, auto-increment)
├── slice_id (UUID)          Redis document ID + metadata, serving dual purposes
├── knowledge_base_id (FK)
├── file_id (FK → file_info.id)
├── slice_index (order within the file)
├── content (MEDIUMTEXT)
├── token_count
└── embedding_status (pending / done / failed)

Design Highlights:

4. Knowledge Base Management: CRUD and Cascading Deletion

Knowledge base CRUD operations are quite standard. What's really worth discussing is the cascading cleanup during deletion.

4.1. Four-Level Cascading Deletion

markdown

Delete Knowledge Base
  │
  ├── 1. Delete Redis vectors (precisely by sliceId, fallback to filtering by knowledgeBaseId)
  │
  ├── 2. Delete MySQL slice records
  │
  ├── 3. Delete physical files on disk (only warn on failure, don't block the transaction)
  │
  └── 4. Delete MySQL file records + knowledge base record

4.2. Code Implementation


@Transactional
public void deleteKnowledgeBase(Long id) {
    KnowledgeBase kb = getKnowledgeBase(id);
    List<TextSlice> slices = textSliceRepository
            .findByKnowledgeBaseIdOrderBySliceIndexAsc(id);
    List<String> sliceIds = slices.stream().map(TextSlice::getSliceId).toList();

    // 1. Delete vectors (two-level fallback)
    removeVectors(kb.getKnowledgeBaseId(), sliceIds);

    // 2. Delete slices
    textSliceRepository.deleteAll(slices);

    // 3. Delete physical files
    List<FileInfo> files = fileInfoRepository
            .findByKnowledgeBaseIdOrderByCreateTimeDesc(id);
    for (FileInfo f : files) {
        if (StringUtils.hasText(f.getStoragePath())) {
            try { Files.deleteIfExists(Path.of(f.getStoragePath())); }
            catch (IOException e) { log.warn("Failed to delete file: {}", f.getStoragePath(), e); }
        }
    }

    // 4. Delete records
    fileInfoRepository.deleteAll(files);
    knowledgeBaseRepository.delete(kb);
}

Vector deletion uses a two-level fallback:


private void removeVectors(String kbId, List<String> sliceIds) {
    try {
        // Prefer precise deletion by sliceId
        vectorStore.delete(sliceIds);
    } catch (Exception e) {
        log.warn("Deletion by sliceId failed, falling back to deletion by knowledge base: {}", e.getMessage());
        try {
            // If that fails, batch delete by knowledgeBaseId
            FilterExpressionBuilder b = new FilterExpressionBuilder();
            var filter = b.eq("knowledgeBaseId", kbId).build();
            vectorStore.delete(filter);
        } catch (Exception ex) {
            log.warn("Deletion by knowledge base also failed", ex);
        }
    }
}

💡 Physical file deletion only logs a warning and doesn't throw an exception—disk residue can be cleaned up later, but don't let a single IO exception block the entire deletion transaction.

5. Three-Step File Upload Process: The Most Satisfying Design

5.1. Why Three Steps?

The most naive implementation is to do everything in one API call: upload → parse → chunk → vectorize → persist. But this has a fatal flaw—

markdown

User: Clicks "Save"
  ↓
System: Upload→Parse→Chunk→Vectorize→Persist (failure at any step leaves dirty data in the database)
  ↓
User: Saved, but finds the chunk size is inappropriate (e.g., 800 splits a knowledge point in half)
  ↓
User: Has to delete and start over, manually cleaning up intermediate state data

The pain point: Before saving, the user has no opportunity to preview the parsing result and chunking effect.

5.2. Three-Step Design

Step API Endpoint Persisted to DB Purpose
Step 1: Parse Preview POST /api/knowledge/parse-preview ❌ No Tika parses plain text, user previews
Step 2: Chunk Preview POST /api/knowledge/slice-preview ❌ No Chunks in memory, user adjusts parameters to see the effect
Step 3: Final Save POST /api/knowledge/{kbId}/finalize ✅ Yes (transactional) Upload+Parse+Chunk+Vectorize all persisted at once

The first two steps don't touch the database at all, only returning results to the frontend for preview. In the second step, the user can drag sliders to adjust chunkSize and chunkOverlap and see the chunking effect in real time. Only in the third step, "Save and Vectorize", is everything persisted within a single @Transactional transaction—either all succeeds, or all rolls back.

markdown

Preview phase → No database records generated → Close if unsatisfied
Save phase → Single transaction persists everything → All or nothing

5.3. Step 1: Parse Preview (No Persistence)


public ParsePreviewResponse parsePreview(MultipartFile file) throws IOException {
    File tempFile = File.createTempFile("preview-", suffix);
    try {
        file.transferTo(tempFile);
        TikaDocumentReader reader = new TikaDocumentReader(
                new FileSystemResource(tempFile));
        List<Document> docs = reader.read();
        String text = docs.stream()
                .map(Document::getText)
                .collect(Collectors.joining("\n\n"));
        return ParsePreviewResponse.builder()
                .fileName(file.getOriginalFilename())
                .extractedText(text)
                .parseStatus("success")
                .build();
    } finally {
        tempFile.delete();  // Don't keep physical files during the preview phase
    }
}

Using a temporary file that is deleted after parsing avoids filling the disk with orphaned files from previews that were never saved.

5.4. Step 2: Chunk Preview (No Persistence)

The frontend sends back the extractedText from step one along with the slider parameters, and the backend chunks it in memory:


public List<SlicePreviewItem> slicePreview(SlicePreviewRequest request) {
    int chunkSize = request.getChunkSize() != null
            ? request.getChunkSize() : 500;
    TokenTextSplitter splitter = TokenTextSplitter.builder()
            .withChunkSize(chunkSize)
            .build();
    Document document = new Document(request.getExtractedText());
    List<Document> chunks = splitter.apply(List.of(document));
    return chunks.stream()
            .map(chunk -> SlicePreviewItem.builder()
                    .content(chunk.getText())
                    .tokenCount(chunk.getText().length())
                    .build())
            .toList();
}

TokenTextSplitter is a tokenizer-based splitter provided by Spring AI, which respects semantic boundaries better than simple character-based splitting.

5.5. Step 3: Final Save (Atomic Transaction)


@Transactional
public FileInfo finalize(Long knowledgeBaseId, MultipartFile file,
                         Integer chunkSize, Integer chunkOverlap) throws IOException {

    // 1. Save to disk
    File dest = new File(uploadDir + "/" + knowledgeBaseId, storedName);
    file.transferTo(dest);

    // 2. Tika parsing
    TikaDocumentReader reader = new TikaDocumentReader(
            new FileSystemResource(dest));
    String text = reader.read().stream()
            .map(Document::getText)
            .collect(Collectors.joining("\n\n"));

    // 3. Save file record
    FileInfo fileInfo = fileInfoRepository.save(...);

    // 4. Chunk + save chunk records
    List<TextSlice> slices = ...;

    // 5. Vectorization: each chunk carries three metadata fields
    List<Document> docs = slices.stream().map(s -> {
        Map<String, Object> metadata = new HashMap<>();
        metadata.put("knowledgeBaseId", kb.getKnowledgeBaseId());
        metadata.put("fileId", fileInfo.getFileId());
        metadata.put("sliceId", s.getSliceId());
        return new Document(s.getSliceId(), s.getContent(), metadata);
    }).toList();
    vectorStore.add(docs);

    // 6. Update chunk vectorization status
    slices.forEach(s -> s.setEmbeddingStatus("done"));
    textSliceRepository.saveAll(slices);

    return fileInfo;
}

The entire method is wrapped in @Transactional. Any exception at any step will trigger a rollback—preventing half-finished states like "file saved but chunks not saved" or "chunks saved but vectors not created."

The core principle: Metadata is the key to multi-knowledge-base isolation. The three fields knowledgeBaseId, fileId, and sliceId will later be used for retrieval filtering and precise deletion.

6. Vector Storage and Multi-Knowledge-Base Isolation

6.1. RedisVectorStore Configuration


@Bean
public RedisVectorStore vectorStore(RedisClient jedisRedisClient,
                                    EmbeddingModel embeddingModel) {
    return RedisVectorStore.builder(jedisRedisClient, embeddingModel)
            .indexName("interview-knowledge")
            .prefix("doc:")
            .metadataFields(
                    RedisVectorStore.MetadataField.tag("knowledgeBaseId"),
                    RedisVectorStore.MetadataField.tag("fileId"),
                    RedisVectorStore.MetadataField.tag("sliceId")
            )
            .initializeSchema(true)
            .build();
}

All three metadata fields are declared as tag type (Redis RediSearch's TAG field, suitable for exact match filtering). initializeSchema(true) lets Spring AI automatically create the index.

6.2. Multi-Knowledge-Base Isolated Retrieval

The system supports multiple concurrent knowledge bases (Java, Algorithms, Interview Prep...). During a conversation, it must only retrieve chunks from the current knowledge base:


public List<Document> retrieve(ChatRequest request) {
    var builder = SearchRequest.builder()
            .query(request.getMessage())
            .topK(request.getTopK() != null ? request.getTopK() : 3);

    if (request.getKnowledgeBaseId() != null) {
        KnowledgeBase kb = knowledgeBaseRepository
                .findById(request.getKnowledgeBaseId())
                .orElseThrow(...);
        FilterExpressionBuilder b = new FilterExpressionBuilder();
        // Only search within the current knowledge base
        var filter = b.eq("knowledgeBaseId", kb.getKnowledgeBaseId()).build();
        builder.filterExpression(filter);
    }
    return vectorStore.similaritySearch(builder.build());
}

This ensures chunks from different knowledge bases don't interfere with each other. The same question in the "Java Knowledge Base" and the "Algorithm Knowledge Base" will retrieve completely different contexts.

7. RAG Retrieval-Augmented Q&A

7.1. System Prompt: Tethering the LLM to the Retrieval Results

The soul of RAG isn't the retrieval, but using the prompt to tether the LLM to the retrieval results, preventing it from improvising:


private static final String SYSTEM_PROMPT = """
        You are a professional AI knowledge base Q&A assistant. Please answer user questions strictly based on the [Retrieved Context].
        Answering Rules:
        1. Only use information from the [Retrieval Results]; do not fabricate;
        2. When no relevant content is retrieved, please reply: "Sorry, no relevant content was found in the knowledge base";
        3. Provide source hints for key conclusions, such as "According to paragraph Y of document X";
        4. If the question is ambiguous, you can ask the user for further clarification.
        """;

7.2. Retrieve → Assemble Context → Generate


private List<Message> buildMessages(ChatRequest request, String ragContext) {
    List<Message> messages = new ArrayList<>();
    messages.add(new SystemMessage(SYSTEM_PROMPT));

    // Inject conversation history (limit to 10 to prevent token bloat)
    messages.addAll(conversationHistory
            .getOrDefault(request.getConversationId(), new ArrayList<>()));

    // Current question + RAG context
    String finalUserPrompt = String.format("""
            User Question:
            %s

            Retrieved Context:
            %s
            """, request.getMessage(), ragContext);
    messages.add(new UserMessage(finalUserPrompt));
    return messages;
}

Conversation history is maintained per conversationId using a ConcurrentHashMap and limited to the last 10 entries, preventing the context window from being overwhelmed by continuously expanding tokens in multi-turn dialogues.

💡 The project is configured with a RewriteQueryTransformer Bean, a query rewriting capability provided by the Spring AI RAG module that can transform colloquial questions into structured queries better suited for vector retrieval. It's currently reserved as an extension point—readers can try adding a queryTransformer.transform(query) layer before retrieve to improve recall rates.

8. SSE Streaming AI Conversation (Key Focus)

If AI Q&A uses a synchronous interface, users stare at a loading spinner for over ten seconds. Streaming output makes the answer appear word by word, drastically reducing perceived latency.

8.1. Custom SSE Protocol

Spring AI's StreamingChatModel.stream() returns a Flux<ChatResponse>. But there's a problem: the reference sources (retrieved chunks) are not streaming; they are one-off. How do you fit both "reference sources" and "streaming text" into the same SSE channel?

markdown

Designed Protocol:
The first message starts with __SOURCES__: and carries the reference sources in JSON format
Subsequent messages are the streaming text body

public Flux<String> chatStream(ChatRequest request) {
    List<Document> docs = retrieve(request);
    String ragContext = docs.stream()
            .map(d -> "【Reference Chunk】\n" + d.getText())
            .collect(Collectors.joining("\n\n"));

    // Reference sources JSON
    String sourcesJson = new ObjectMapper().writeValueAsString(
        docs.stream().map(d -> Map.of("text", d.getText(),
                                      "metadata", d.getMetadata())).toList());

    StringBuilder answerBuffer = new StringBuilder();

    // First: reference sources
    Flux<String> sourcesFlux = Flux.just("__SOURCES__:" + sourcesJson);

    // Subsequent: streaming text
    Flux<String> textFlux = streamingChatModel.stream(new Prompt(messages))
            .doOnNext(chunk -> {
                if (chunk != null && chunk.getResult() != null
                        && chunk.getResult().getOutput() != null) {
                    String text = chunk.getResult().getOutput().getText();
                    if (StringUtils.hasText(text)) answerBuffer.append(text);
                }
            })
            .doOnComplete(() -> {
                appendHistory(request.getConversationId(), new UserMessage(request.getMessage()));
                appendHistory(request.getConversationId(),
                        new AssistantMessage(answerBuffer.toString()));
            })
            .map(chunk -> {
                if (chunk == null || chunk.getResult() == null
                        || chunk.getResult().getOutput() == null) return "";
                String text = chunk.getResult().getOutput().getText();
                return text == null ? "" : text;
            })
            .onErrorResume(e -> Flux.just("\n\n[Answer interrupted: " + e.getMessage() + "]"));

    return Flux.concat(sourcesFlux, textFlux);
}

8.2. Frontend fetch + ReadableStream Parsing SSE

Why not use EventSource? Because EventSource only supports GET and cannot carry a custom request body. Streaming Q&A requires POST + JSON body, so you must use fetch + ReadableStream for manual parsing:


// api/index.js
askStream: (payload, onChunk) => {
  return fetch('/chat/flux', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  }).then(async (response) => {
    const reader = response.body.getReader()
    const decoder = new TextDecoder()
    let buffer = ''
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      buffer += decoder.decode(value, { stream: true })
      // SSE is delimited by \n, each line starts with data:
      const lines = buffer.split('\n')
      buffer = lines.pop()
      for (const line of lines) {
        if (line.startsWith('data:')) {
          const text = line.slice(5)
          if (text) onChunk(text)
        }
      }
    }
  })
}

When sending, first insert an empty AI message. Upon receiving __SOURCES__:, fill it into references; upon receiving the text body, append with +=:


// App.vue sendChat()
const aiIdx = chatMessages.value.length
chatMessages.value.push({ role: 'assistant', content: '', references: [], streaming: true })

await api.askStream({ knowledgeBaseId, message, conversationId }, (text) => {
  if (text.startsWith('__SOURCES__:')) {
    chatMessages.value[aiIdx].references = JSON.parse(text.slice(12))
  } else {
    chatMessages.value[aiIdx].content += text  // Append word by word
  }
})
chatMessages.value[aiIdx].streaming = false

8.3. Three-State Markdown Rendering

Streaming output has a tricky problem: Markdown is a structured syntax, and half-finished content can crash the renderer. For example, if the stream reaches ### 一、 or **bold text (bold not closed), directly calling marked.parse() will display the broken syntax as raw text.

markdown

Three-State Rendering Strategy:
① Streaming → Plain text + blinking cursor ▋, do not render Markdown
② Stream finished → marked.parse() renders complete Markdown
③ Waiting → Typing animation ···

<!-- ① Streaming but content has arrived: plain text + blinking cursor -->
<div v-if="msg.role === 'assistant' && msg.content"
     class="markdown-body" v-html="renderMarkdown(msg.content)"></div>

<!-- ② Streaming but content hasn't arrived yet: typing animation -->
<div v-else-if="msg.role === 'assistant' && chatLoading"
     class="typing-dots"><span></span><span></span><span></span></div>

<!-- ③ Blinking cursor -->
<span v-if="msg.role === 'assistant' && msg.streaming"
      class="streaming-cursor">▋</span>

When streaming is true, it displays plain text + a blinking cursor. Only after the stream ends does it call marked.parse() to render. This avoids the rendering disaster of half-finished syntax while preserving the real-time feel of word-by-word typing.

9. Recall Testing: Making Retrieval Effectiveness Visible

9.1. Why a Separate Recall Test is Needed?

The biggest black box in a RAG system is "how accurate is the retrieval?". Just looking at whether the AI's answer is correct isn't enough—a correct answer might just mean the LLM is powerful, while the retrieval actually pulled in garbage. There must be an independent entry point: input text, and directly see which topK chunks were recalled and their similarity scores.

9.2. Implementation


public List<Map<String, Object>> recallTest(
        Long knowledgeBaseId, String query, int topK) {

    KnowledgeBase kb = getKnowledgeBase(knowledgeBaseId);
    FilterExpressionBuilder b = new FilterExpressionBuilder();
    var filter = b.eq("knowledgeBaseId", kb.getKnowledgeBaseId()).build();

    List<Document> docs = vectorStore.similaritySearch(
            SearchRequest.builder()
                    .query(query)
                    .topK(topK)
                    .filterExpression(filter)
                    .build());

    List<Map<String, Object>> results = new ArrayList<>();
    for (Document doc : docs) {
        Map<String, Object> item = new LinkedHashMap<>();
        item.put("sliceId", doc.getId());
        item.put("content", doc.getText());

        // Similarity score
        double score = 0.0;
        if (doc.getScore() != null) {
            score = doc.getScore();
        } else if (doc.getMetadata() != null
                && doc.getMetadata().get("distance") != null) {
            double distance = ((Number) doc.getMetadata().get("distance")).doubleValue();
            score = 1.0 / (1.0 + distance);  // Smaller distance means more similar
        }
        item.put("score", score);

        // Reverse lookup filename, so the user knows which file this chunk is from
        String fileIdStr = doc.getMetadata().get("fileId").toString();
        FileInfo fi = fileInfoRepository.findByFileId(fileIdStr).orElse(null);
        item.put("fileName", fi != null ? fi.getFileName() : null);
        results.add(item);
    }
    return results;
}

The frontend converts the score to a percentage and colors it by range (≥0.8 green, ≥0.6 purple, ≥0.4 orange, <0.4 gray), making it immediately obvious which chunks are highly relevant and which are just filler.

10. Pitfall Summary (The Essentials)

Every pitfall encountered along the way is worth recording.

Pitfall 1: Null Chunk in Streaming Response Causes NPE

markdown

Symptom: Occasional NullPointerException during streaming conversation
Stack trace: chunk.getResult().getOutput().getText()

Root Cause: Alibaba Cloud Bailian's streaming response includes end frames or heartbeat frames
For these frames, getResult() or getOutput() is null

The solution—triple null protection:


if (chunk == null || chunk.getResult() == null
        || chunk.getResult().getOutput() == null) {
    return "";
}

Core principle: When integrating with domestic LLM APIs, you must be defensive—don't assume every chunk conforms to the "textbook" structure.

Pitfall 2: choices is not set

markdown

Symptom: Occasional error "choices is not set", originating from the OpenAI Java client
Root Cause: The Bailian API, under certain circumstances, returns a response without a choices field.
This is usually due to content moderation interception, rate limiting, or an invalid API Key.

The solution: Identify the choices keyword and give a user-friendly prompt:


try {
    response = chatModel.call(prompt);
    answer = response.getResult().getOutput().getText();
} catch (Exception e) {
    if (e.getMessage() != null && e.getMessage().contains("choices")) {
        answer = "Sorry, the AI service returned an anomaly (possibly content moderation interception or rate limiting). Please try again later.";
    } else {
        answer = "Sorry, the AI service is temporarily unavailable: " + e.getMessage();
    }
}

Pitfall 3: Vector Dimension Mismatch

markdown

Symptom: JedisDataException: query vector blob size (4096) does not match
      index's expected size (6144)

Root Cause: The Redis index was built for one embedding dimension (6144 dims),
but the query vector was generated by a different model (4096 dims).
Once the embedding model is changed, the dimensions won't match.

The solution: The embedding model must be consistent with the one used when building the index; if you need to change it, you must delete the old index and rebuild it, and re-vectorize all chunks.

Core principle: text-embedding-v1 (1536 dims), text-embedding-v2, and text-embedding-v3 (options for 1024/768/1536) all have different dimensions. In a production environment, you must pin down the embedding model version in the configuration.

11. Summary of This Article

This article strung together the single-point capabilities of Spring AI into a working closed loop:

Capability Implementation Problem Solved
Storage Tiering MySQL for metadata, Redis for vectors Relational and vector DBs each do their own job
Three-Step Upload Preview without persistence, save in a transaction Eliminates intermediate dirty data
Multi-KB Isolation metadata + FilterExpression Precise retrieval by knowledge base
Prompt Constraint System prompt tethers the LLM Prevents hallucination
SSE Streaming __SOURCES__ protocol + three-state rendering Maximizes user experience
Recall Testing Independent entry point + similarity coloring Visualizes the retrieval black box

Core Principles

erlang

RAG is not magic; it's a set of engineering trade-offs:

Retrieval accuracy → depends on the embedding model + chunking strategy
Context assembly quality → depends on TopK + prompt constraints
Generation stability → depends on stream handling + exception protection

The ROI of optimizing retrieval is far higher than optimizing the Prompt.

Directions for Further Exploration

  1. Integrate Query Rewriting: Connect the RewriteQueryTransformer into the main pipeline to improve recall for colloquial questions.
  1. Hybrid Retrieval: Vector + BM25 + RRF fusion, balancing semantic and literal matching.
  1. Reranking: After recall, use a rerank model to reorder the topK results.
  1. Persistent Conversation Memory: Replace ConcurrentHashMap with Redis to support reviewing historical conversations.
  1. Streaming Citation Tracing: Have the AI's answers annotate their sources, with clickable links to the original text.
Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

PasteCode

Try feeding it 1000 times and see what happens!