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:
slice_idserves dual purposes: it's both the unique index for thetext_slicetable and theidof the Redis vector document, as well as thesliceIdin its metadata, ensuring a one-to-one correspondence between the relational and vector databases.
- Two status fields,
parse_statusandembedding_status, track progress, making it easy to troubleshoot failures and support retries.
- The physical file storage path is separated from the database record; both must be cleaned up when a file is deleted.
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
- Integrate Query Rewriting: Connect the
RewriteQueryTransformerinto the main pipeline to improve recall for colloquial questions.
- Hybrid Retrieval: Vector + BM25 + RRF fusion, balancing semantic and literal matching.
- Reranking: After recall, use a rerank model to reorder the topK results.
- Persistent Conversation Memory: Replace
ConcurrentHashMapwith Redis to support reviewing historical conversations.
- Streaming Citation Tracing: Have the AI's answers annotate their sources, with clickable links to the original text.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Try feeding it 1000 times and see what happens!