第16篇:《把八股文喂给了AI:我搭建了专属 AI 面试知识库》

承上:上一篇我们搞定了 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_statusembedding_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 ✅ 是(事务) 上传+解析+切片+向量化一次落库

前两步完全不碰数据库 ,只返回结果给前端预览。用户在第二步可以拖滑块调整 chunkSizechunkOverlap,实时看切片效果。直到第三步「保存并向量化」,才在一个 @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 是多知识库隔离的关键knowledgeBaseIdfileIdsliceId 三个字段后面要用来做检索过滤和精确删除。

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;
}

会话历史用 ConcurrentHashMapconversationId 维护,并限制最近 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-v2text-embedding-v3(1024/768/1536 可选)维度都不同,生产环境一定要在配置里钉死 embedding 模型版本。

11. 本篇小结

这一篇把 Spring AI 的单点能力串成了一个能跑的闭环:

能力 实现 解决的问题
存储分层 MySQL 管元数据,Redis 管向量 关系库和向量库各司其职
三步上传 预览不落库、保存走事务 杜绝中间态脏数据
多库隔离 metadata + FilterExpression 按知识库精准检索
提示词约束 系统提示词拴住大模型 防幻觉
SSE 流式 __SOURCES__ 协议 + 三态渲染 体验拉满
召回测试 独立入口 + 相似度上色 检索黑盒可视化

核心心法

复制代码
erlang

RAG 不是魔法,它是一套工程权衡:

检索准不准 → 看 embedding 模型 + 切片策略
上下文拼得好不好 → 看 TopK + 提示词约束
生成稳不稳 → 看流式处理 + 异常保护

优化检索的投入产出比,远高于优化 Prompt。

后续可以深挖的方向

  1. 接入查询重写 :把 RewriteQueryTransformer 接入主链路,提升口语化问题召回率
  1. 混合检索:向量 + BM25 + RRF 融合,兼顾语义和字面匹配
  1. 重排序(Rerank) :召回后用 rerank 模型对 topK 重排
  1. 持久化会话记忆 :把 ConcurrentHashMap 换成 Redis,支持历史会话回看
  1. 流式引用溯源:让 AI 回答里标注引用出处,点击可跳转原文
相关推荐
文心快码BaiduComate2 小时前
不限额度!「文心快码测试版」限免体验来了
agent·ai编程·文心快码
黄林晴2 小时前
被“按钮明显点”坑过之后,我用 TRAE Work 把群聊整理成了产品方案
ai编程
xiakq2 小时前
5 分钟接入 GPT-5.6 完整指南
gpt·openai·claude·gemini·anthropic
子昕2 小时前
Qwen 3.8 Max 实测:和 Kimi K3 打平,有一点还更强
ai编程
在水一缸2 小时前
从“反向技能“到协作革命:当开源重新定义AI编程助手
开源·github·ai编程·开发者工具·ai编程助手·ai协作·claude cowork
乐之者v3 小时前
AI编程-- Codex ,要用哪些模型和推理强度
ai编程
梓䈑3 小时前
【用 Vibe Coding 实现的 C++17 在线判题系统】OpenCode 入门指南:从环境搭建到上下文管理
c++·ai编程
ClouGence3 小时前
无需 API 配置,DeepSeek-V4-Flash 正式版落地使用指南
agent·ai编程·deepseek
deepseek234 小时前
GPT-5.6 Luna 降价 80%:从 24% 到 90% 的缓存命中率,OpenAI 的价格战打在了工程层
大模型·openai·ai agent