承上:上一篇我们搞定了 Query 改写,把检索准确率翻了一倍。但这些都是"单点能力"------我们还没有一个真正能用的产品。今天,我们要把前面学的所有东西串起来,做一个面试知识库实战项目:上传一份 PDF 八股文 → 自动解析切片 → 向量化入库 → 基于它和 AI 流式对话 → 随时跑召回测试看效果。
1. 产品目标:解决「背了找不到」的痛点
先想一个真实场景:
diff
markdown
你花了三个月整理了 20 份八股文 PDF,面试前想快速过一遍:
- "ThreadLocal 内存泄漏怎么回事?" → 记得看过,但在哪份 PDF 第几页?找不到
- "MySQL 索引失效的几种情况?" → 大概在某份文档里,但翻了半小时
- "Spring Boot 自动装配原理?" → 背过,但不确定自己记得准不准
更糟的是:
- 问 AI → 它张嘴就来,你不知道它说的对不对
- 搜自己的文档 → 全文检索只能按关键词匹配,语义层面搜不到
痛点很清楚:资料在自己手里,但 AI 回答不受自己控制。
RAG 就是为这个场景而生的:
arduino
markdown
把你的资料灌进向量库
↓
提问时先检索最相关的几段
↓
把检索结果作为上下文交给大模型
↓
AI 只能"开卷考试",不允许自由发挥
这一篇的目标就是把这个闭环跑通,做一个开箱即用的面试知识库系统。

2. 技术选型与整体架构
2.1. 为什么这么选?
| 层 | 选型 | 选型理由 |
|---|---|---|
| 大模型 | 阿里云百炼 qwen-max + text-embedding-v1 |
国内延迟低,Spring AI 原生兼容 |
| 向量库 | Redis(RedisVectorStore) | 单机可跑,元数据过滤支持好,不用额外部署 ES |
| 关系库 | MySQL + JPA | 管知识库/文件/切片元数据,和向量库各司其职 |
| 文档解析 | Apache Tika | 一行搞定 PDF/Word/Excel/PPT/TXT |
| 前端 | Vue3 + Element Plus | 响应式,组件化,开发快 |
2.2. 架构总览
scss
arduino
┌──────────────────── 前端 Vue3 ────────────────────┐
│ 知识库列表 │ 文档管理 │ AI对话 │ 召回测试 │ 设置 │
└───────┬──────────────────────────────────────────┘
│ HTTP (axios) + SSE (fetch + ReadableStream)
│
┌───────▼────────── 后端 Spring Boot ────────────────┐
│ ChatController RagController │
│ /chat/flux (SSE) /api/knowledge/** │
│ │ │ │
│ ChatService KnowledgeService │
│ 检索+拼上下文+流式生成 上传/切片/向量化/召回 │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ChatModel(Flux) Tika解析 TokenTextSplitter│
│ EmbeddingModel FileSystem VectorStore │
└────────┬──────────────────────┬──────────────┬─────┘
▼ ▼ ▼
阿里云百炼 MySQL Redis
(qwen + embedding) (元数据三表) (向量索引)
核心依赖:
bash
# pom.xml 关键依赖
spring-ai-starter-model-openai # 对接百炼(OpenAI兼容协议)
spring-ai-tika-document-reader # Tika文档解析
spring-ai-starter-vector-store-redis # Redis向量存储
spring-ai-rag # RAG子模块(查询重写等)
spring-boot-starter-data-jpa # MySQL持久化
jedis # Redis客户端
2.3. 接入百炼
yaml
# 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
💡 百炼的 qwen-max 支持 128K 上下文窗口,对 RAG 场景足够用。text-embedding-v1 是 1536 维,要注意和 Redis 索引维度匹配(后面踩坑会讲)。
3. 数据库设计:三张表撑起整个系统
3.1. 为什么用「双 ID」设计?
RAG 系统有个常见误区:用自增 ID 当 Redis 向量文档 ID。这会出大问题------
ini
sql
-- ❌ 错误做法
knowledge_base: id=1
file_info: id=101 (FK kb_id=1)
text_slice: id=1001, id=1002 ... (FK file_id=101)
-- Redis 向量文档 ID 用自增 ID
doc:1001 → 暴露了数据规模
doc:1002 → 删除时如果 ID 对不上会出问题
正确做法是自增主键管表间关联,UUID 管外部引用和 Redis:
ini
sql
-- ✅ 正确做法
knowledge_base: id=1 (自增), knowledge_base_id='550e8400-e29b-41d4-a716-446655440000' (UUID)
file_info: id=101 (自增), file_id='6ba7b810-9dad-11d1-80b4-00c04fd430c8' (UUID)
text_slice: id=1001 (自增), slice_id='7c9e6f7a-9dad-11d1-80b4-00c04fd430c8' (UUID)
↑ 这个UUID同时作为 Redis 向量文档 ID
3.2. 三张表结构
scss
markdown
knowledge_base (知识库)
├── id (PK, 自增) 表间外键用
├── knowledge_base_id (UUID) Redis metadata 用,全局唯一
├── name / category / icon
├── status (active / archived)
└── create_time
file_info (文件)
├── id (PK, 自增)
├── file_id (UUID) Redis metadata 用
├── knowledge_base_id (FK → knowledge_base.id)
├── file_name / file_type / file_size
├── storage_path (磁盘物理路径)
├── chunk_size / chunk_overlap
└── parse_status (pending / parsing / success / failed)
text_slice (切片)
├── id (PK, 自增)
├── slice_id (UUID) Redis 文档 ID + metadata 用,一物两用
├── knowledge_base_id (FK)
├── file_id (FK → file_info.id)
├── slice_index (在文件内的顺序)
├── content (MEDIUMTEXT)
├── token_count
└── embedding_status (pending / done / failed)
设计要点:
slice_id一物两用:既是text_slice表的唯一索引,也是 Redis 向量文档的id和 metadata 里的sliceId,保证关系库和向量库一一对应
parse_status和embedding_status两个状态字段,记录进度,方便排查失败、支持重试
- 文件物理存储路径和数据库记录分离,删文件时两边都要清
4. 知识库管理:CRUD 与级联删除
知识库的 CRUD 很常规,真正值得说的是删除时的级联清理。
4.1. 四级级联删除
perl
markdown
删除知识库
│
├── 1. 删 Redis 向量(按 sliceId 精确删,失败回退到按 knowledgeBaseId 过滤删)
│
├── 2. 删 MySQL 切片记录
│
├── 3. 删磁盘物理文件(删失败只 warn,不挡事务)
│
└── 4. 删 MySQL 文件记录 + 知识库记录
4.2. 代码实现
scss
@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. 删向量(两级降级)
removeVectors(kb.getKnowledgeBaseId(), sliceIds);
// 2. 删切片
textSliceRepository.deleteAll(slices);
// 3. 删物理文件
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("删除文件失败: {}", f.getStoragePath(), e); }
}
}
// 4. 删记录
fileInfoRepository.deleteAll(files);
knowledgeBaseRepository.delete(kb);
}
向量删除做了两级降级:
typescript
private void removeVectors(String kbId, List<String> sliceIds) {
try {
// 优先按 sliceId 精确删
vectorStore.delete(sliceIds);
} catch (Exception e) {
log.warn("按 sliceId 删除向量失败,回退到按知识库删除: {}", e.getMessage());
try {
// 失败了就按 knowledgeBaseId 整批删
FilterExpressionBuilder b = new FilterExpressionBuilder();
var filter = b.eq("knowledgeBaseId", kbId).build();
vectorStore.delete(filter);
} catch (Exception ex) {
log.warn("按知识库删除向量也失败", ex);
}
}
}
💡 物理文件删除只 warn 不抛异常------磁盘残留可以后续清理,但别让一个 IO 异常把整个删除事务挡住。
5. 文件上传三步流程:最满意的设计
5.1. 为什么要三步?
最朴素的实现是「一个接口干完所有事」:上传 → 解析 → 切片 → 向量化 → 落库。但这样有个致命问题------
markdown
用户:点「保存」
↓
系统:上传→解析→切片→向量化→落库(中间任何一步失败都会在数据库留下脏数据)
↓
用户:保存了,发现切片大小不合适(比如 800 把一个知识点切成了两半)
↓
用户:只能删掉重来,中间态数据需要手动清理
痛点:用户在保存前,没有任何机会预览解析结果和切片效果。
5.2. 三步设计
| 步骤 | 接口 | 是否落库 | 作用 |
|---|---|---|---|
| Step 1:解析预览 | POST /api/knowledge/parse-preview |
❌ 否 | Tika 解析出纯文本,用户预览 |
| Step 2:切片预览 | POST /api/knowledge/slice-preview |
❌ 否 | 内存里切片,用户调参看效果 |
| Step 3:最终保存 | POST /api/knowledge/{kbId}/finalize |
✅ 是(事务) | 上传+解析+切片+向量化一次落库 |
前两步完全不碰数据库 ,只返回结果给前端预览。用户在第二步可以拖滑块调整 chunkSize 和 chunkOverlap,实时看切片效果。直到第三步「保存并向量化」,才在一个 @Transactional 事务里完成所有落库------要么全成功,要么全回滚。
markdown
预览阶段 → 不产生任何数据库记录 → 看不爽直接关
保存阶段 → 一次事务全落库 → 要么全有要么全无



5.3. Step 1:解析预览(不落库)
scss
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(); // 预览阶段不保留物理文件
}
}
用临时文件解析完即删------避免磁盘堆满「看了预览却没保存」的孤儿文件。
5.4. Step 2:切片预览(不落库)
前端把第一步的 extractedText 和滑块参数一起传回来,后端在内存里切片:
scss
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 是 Spring AI 提供的基于 tokenizer 的切片器,比简单按字符切更尊重语义边界。
5.5. Step 3:最终保存(原子事务)
ini
@Transactional
public FileInfo finalize(Long knowledgeBaseId, MultipartFile file,
Integer chunkSize, Integer chunkOverlap) throws IOException {
// 1. 落盘
File dest = new File(uploadDir + "/" + knowledgeBaseId, storedName);
file.transferTo(dest);
// 2. Tika 解析
TikaDocumentReader reader = new TikaDocumentReader(
new FileSystemResource(dest));
String text = reader.read().stream()
.map(Document::getText)
.collect(Collectors.joining("\n\n"));
// 3. 保存文件记录
FileInfo fileInfo = fileInfoRepository.save(...);
// 4. 切片 + 保存切片记录
List<TextSlice> slices = ...;
// 5. 向量化:每个切片带三个 metadata
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. 更新切片向量化状态
slices.forEach(s -> s.setEmbeddingStatus("done"));
textSliceRepository.saveAll(slices);
return fileInfo;
}
整个方法用 @Transactional 包裹,任何一步抛异常都会回滚------不会出现「文件存了但切片没存」或「切片存了但向量没建」的半成品状态。
核心心法:metadata 是多知识库隔离的关键 。knowledgeBaseId、fileId、sliceId 三个字段后面要用来做检索过滤和精确删除。
6. 向量存储与多知识库隔离
6.1. RedisVectorStore 配置
typescript
@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();
}
三个 metadata 字段都声明为 tag 类型(Redis RediSearch 的 TAG 字段,适合精确匹配过滤)。initializeSchema(true) 让 Spring AI 自动建索引。
6.2. 多知识库隔离检索
系统支持多个知识库并存(Java、算法、八股文......),对话时必须只检索当前知识库的切片:
scss
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();
// 只在当前知识库范围内检索
var filter = b.eq("knowledgeBaseId", kb.getKnowledgeBaseId()).build();
builder.filterExpression(filter);
}
return vectorStore.similaritySearch(builder.build());
}
这样不同知识库的切片互不干扰,同一个问题在「Java 知识库」和「算法知识库」里会检索到完全不同的上下文。
7. RAG 检索增强问答
7.1. 系统提示词:把大模型拴在检索结果上
RAG 的灵魂不在于检索,而在于用提示词把大模型拴在检索结果上,防止它自由发挥:
ini
private static final String SYSTEM_PROMPT = """
你是一个专业的 AI 知识库问答助手。请严格基于【检索到的上下文】回答用户问题。
回答规则:
1. 仅使用【检索结果】中的信息,不要编造;
2. 当检索不到相关内容时,请回复:"抱歉,知识库中暂未找到相关内容";
3. 对关键结论请给出出处提示,如"根据文档 X 第 Y 段";
4. 若问题模糊,可反问用户进一步澄清。
""";
7.2. 检索 → 拼上下文 → 生成
ini
private List<Message> buildMessages(ChatRequest request, String ragContext) {
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage(SYSTEM_PROMPT));
// 注入历史对话(限制 10 条防 Token 膨胀)
messages.addAll(conversationHistory
.getOrDefault(request.getConversationId(), new ArrayList<>()));
// 当前问题 + RAG 上下文
String finalUserPrompt = String.format("""
用户问题:
%s
检索到的上下文:
%s
""", request.getMessage(), ragContext);
messages.add(new UserMessage(finalUserPrompt));
return messages;
}
会话历史用 ConcurrentHashMap 按 conversationId 维护,并限制最近 10 条,避免多轮对话后 Token 持续膨胀把上下文窗口撑爆。

💡 项目里配了 RewriteQueryTransformer Bean,这是 Spring AI RAG 模块提供的查询重写能力,可以把口语化提问转成更适合向量检索的结构化查询。目前作为预留扩展点保留------读者可以尝试在 retrieve 之前加一层 queryTransformer.transform(query) 来提升召回率。
8. SSE 流式 AI 对话(重点)
AI 问答如果用同步接口,用户要盯着 loading 转圈等十几秒。流式输出让回答逐字蹦出来,体感延迟大幅降低。
8.1. 自定义 SSE 协议
Spring AI 的 StreamingChatModel.stream() 返回 Flux<ChatResponse>。但有个问题:参考来源(检索到的切片)不是流式的,是一次性的。怎么把「参考来源」和「流式正文」塞进同一个 SSE 通道?
javascript
markdown
设计的协议:
第一条消息以 __SOURCES__: 开头,携带 JSON 格式的参考来源
后续消息是流式正文
scss
public Flux<String> chatStream(ChatRequest request) {
List<Document> docs = retrieve(request);
String ragContext = docs.stream()
.map(d -> "【参考片段】\n" + d.getText())
.collect(Collectors.joining("\n\n"));
// 参考来源 JSON
String sourcesJson = new ObjectMapper().writeValueAsString(
docs.stream().map(d -> Map.of("text", d.getText(),
"metadata", d.getMetadata())).toList());
StringBuilder answerBuffer = new StringBuilder();
// 第一条:参考来源
Flux<String> sourcesFlux = Flux.just("__SOURCES__:" + sourcesJson);
// 后续:流式正文
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[回答中断:" + e.getMessage() + "]"));
return Flux.concat(sourcesFlux, textFlux);
}
8.2. 前端 fetch + ReadableStream 解析 SSE
为什么不用 EventSource?因为 EventSource 只支持 GET、不能带自定义请求体。流式问答要 POST + JSON body,只能用 fetch + ReadableStream 手动解析:
javascript
// 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 按 \n 分隔,每行 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)
}
}
}
})
}
发送时先插入一条空的 AI 消息,收到 __SOURCES__: 就填进 references,收到正文就 += 追加:
javascript
// 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 // 逐字追加
}
})
chatMessages.value[aiIdx].streaming = false
8.3. 三态 Markdown 渲染
流式输出有个棘手问题:Markdown 是结构化语法,半截内容会让渲染器崩溃 。比如流到一半的内容是 ### 一、 或 **退货(加粗没闭合),直接 marked.parse() 会把残缺语法当原文显示。
scss
markdown
三态渲染策略:
① 流式中 → 纯文本 + 闪烁光标 ▋,不渲染 Markdown
② 流结束 → marked.parse() 渲染完整 Markdown
③ 等待中 → 打字动画 ···
xml
<!-- ① 流式中但内容已来:纯文本 + 闪烁光标 -->
<div v-if="msg.role === 'assistant' && msg.content"
class="markdown-body" v-html="renderMarkdown(msg.content)"></div>
<!-- ② 流式中但内容还没来:打字动画 -->
<div v-else-if="msg.role === 'assistant' && chatLoading"
class="typing-dots"><span></span><span></span><span></span></div>
<!-- ③ 闪烁光标 -->
<span v-if="msg.role === 'assistant' && msg.streaming"
class="streaming-cursor">▋</span>
streaming 为 true 时显示纯文本 + 闪烁光标,流结束后才走 marked.parse() 渲染。这样既避免了半截语法的渲染灾难,又保留了逐字打字的实时感。

9. 召回测试:让检索效果肉眼可见
9.1. 为什么需要独立的召回测试?
RAG 系统最大的黑盒是「检索到底准不准」。光看 AI 回答对不对不够------回答对可能只是大模型厉害,检索其实召回了垃圾。必须有一个独立入口:输入文本,直接看 topK 召回了哪些切片、相似度多少。
9.2. 实现
ini
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());
// 相似度分数
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); // distance 越小越相似
}
item.put("score", score);
// 反查文件名,让用户知道这条切片来自哪个文件
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;
}
前端把分数转成百分比并按区间上色(≥0.8 绿、≥0.6 紫、≥0.4 橙、<0.4 灰),一眼就能看出哪些切片是强相关、哪些是凑数的。

10. 踩坑总结(精华)
这一路踩的坑,每一个都值得记下来。
坑 1:流式响应的 null chunk 导致 NPE
scss
markdown
现象:流式对话时偶发 NullPointerException
堆栈:chunk.getResult().getOutput().getText()
根因:阿里云百炼的流式响应里会夹杂结束帧或心跳帧
这些帧的 getResult() 或 getOutput() 是 null
解法------三重空值保护:
kotlin
if (chunk == null || chunk.getResult() == null
|| chunk.getResult().getOutput() == null) {
return "";
}
核心心法:对接国内大模型 API 必须有防御------别假设每个 chunk 都符合「教科书」结构。
坑 2:choices is not set
vbnet
markdown
现象:偶发报错 choices is not set,来自 OpenAI Java 客户端
根因:百炼 API 在某些情况下返回没有 choices 字段的响应
通常是内容审核拦截、限流或 API Key 失效
解法:识别 choices 关键字给出用户友好提示:
ini
try {
response = chatModel.call(prompt);
answer = response.getResult().getOutput().getText();
} catch (Exception e) {
if (e.getMessage() != null && e.getMessage().contains("choices")) {
answer = "抱歉,AI 服务返回异常(可能是内容审核拦截或限流),请稍后重试。";
} else {
answer = "抱歉,AI 服务暂时不可用:" + e.getMessage();
}
}
坑 3:向量维度不匹配
rust
markdown
现象:JedisDataException: query vector blob size (4096) does not match
index's expected size (6144)
根因:Redis 索引是按某个 embedding 维度建的(6144 维)
但查询向量是另一个模型生成的(4096 维)
embedding 模型一旦换了,维度就对不上
解法:embedding 模型必须和建索引时保持一致;如果要换,必须删掉旧索引重建并重新向量化所有切片。
核心心法:text-embedding-v1(1536 维)、text-embedding-v2、text-embedding-v3(1024/768/1536 可选)维度都不同,生产环境一定要在配置里钉死 embedding 模型版本。
11. 本篇小结
这一篇把 Spring AI 的单点能力串成了一个能跑的闭环:
| 能力 | 实现 | 解决的问题 |
|---|---|---|
| 存储分层 | MySQL 管元数据,Redis 管向量 | 关系库和向量库各司其职 |
| 三步上传 | 预览不落库、保存走事务 | 杜绝中间态脏数据 |
| 多库隔离 | metadata + FilterExpression | 按知识库精准检索 |
| 提示词约束 | 系统提示词拴住大模型 | 防幻觉 |
| SSE 流式 | __SOURCES__ 协议 + 三态渲染 |
体验拉满 |
| 召回测试 | 独立入口 + 相似度上色 | 检索黑盒可视化 |
核心心法
erlang
RAG 不是魔法,它是一套工程权衡:
检索准不准 → 看 embedding 模型 + 切片策略
上下文拼得好不好 → 看 TopK + 提示词约束
生成稳不稳 → 看流式处理 + 异常保护
优化检索的投入产出比,远高于优化 Prompt。
后续可以深挖的方向
- 接入查询重写 :把
RewriteQueryTransformer接入主链路,提升口语化问题召回率
- 混合检索:向量 + BM25 + RRF 融合,兼顾语义和字面匹配
- 重排序(Rerank) :召回后用 rerank 模型对 topK 重排
- 持久化会话记忆 :把
ConcurrentHashMap换成 Redis,支持历史会话回看
- 流式引用溯源:让 AI 回答里标注引用出处,点击可跳转原文