Java RAG 实战(第 11 篇):RAG 知识工作台网页

系列导航

当前进度:已完成。浏览器已经可以完成知识入库、更新、删除和 RAG 问答。

本篇的网页不是新的 RAG 实现。它是前两篇 HTTP API 的可视化入口:左边管理知识,右边提问并查看回答来源。

为什么要学

第 8、9 篇完成了后端 API,但每次操作都要手写 curl 和 JSON。这样适合验证接口,不适合日常管理知识库,也不容易让其他人体验项目。

这一篇给已有 API 增加一个单页工作台:

text 复制代码
浏览器页面
├── 知识文档表单 ──→ KnowledgeController ──→ Qdrant
└── 问题表单     ──→ RagController       ──→ Ollama + Qdrant

网页没有替代后端。它只是把用户填写的内容组装成 JSON,通过 HTTP 调用前面已经实现的 Controller。

本篇目标

  • 在 Spring Boot 中直接提供 HTML、CSS 和 JavaScript。
  • 用网页提交或替换 Markdown 文档。
  • 用网页删除指定 documentId 的知识。
  • 提问并显示模型答案、来源和相似度。
  • 处理等待、成功、失败和无来源状态。
  • 在刷新页面后恢复尚未提交的表单草稿。

完成进度

  • 1. 启动依赖和 Spring Boot 应用
  • 2. 打开工作台,认识左右两条调用链
  • 3. 提交一篇带 ## 二级标题的 Markdown
  • 4. 提问并查看答案、来源、相似度和 Point ID
  • 5. 使用相同 documentId 更新文档
  • 6. 删除测试文档并确认来源消失
  • 7. 运行自动化测试

第 1 步:运行依赖和应用

确认 Ollama 已安装模型:

bash 复制代码
ollama list

确认 Qdrant 正在运行:

bash 复制代码
docker ps --filter name=qdrant-study
curl -s http://localhost:6333/collections | jq

启动 Spring Boot:

bash 复制代码
mvn -f 05-spring-rag/pom.xml spring-boot:run

浏览器打开:

text 复制代码
http://localhost:8080

只需要启动一个 Spring Boot 服务,不需要安装 Node.js,也不需要再启动一个前端开发服务器。

第 2 步:理解静态资源目录

页面文件位于:

text 复制代码
05-spring-rag/src/main/resources/static/
├── index.html   页面结构
├── app.css      布局、颜色和移动端适配
└── app.js       表单状态和 API 调用

Spring Boot 会自动寻找 classpath:/static/index.html,并把它作为 / 的欢迎页。因此:

text 复制代码
GET http://localhost:8080/
                 ↓
Spring Boot 静态资源处理器
                 ↓
static/index.html

index.html 再加载同源的 /app.css/app.js。网页与 API 都使用 localhost:8080,所以不需要额外配置 CORS。

第 3 步:页面怎样提交知识

用户填写:

text 复制代码
documentId  文档的稳定身份
source      展示给使用者的资料来源
content     完整 Markdown

点击"提交入库"后,app.js 执行:

javascript 复制代码
fetch("/api/knowledge/documents", {
  method: "POST",
  headers: {"Content-Type": "application/json"},
  body: JSON.stringify({
    documentId: documentId,
    source: source,
    content: content
  })
});

完整调用链:

text 复制代码
知识文档表单
      ↓ submit 事件
saveDocument()
      ↓ fetch POST
/api/knowledge/documents
      ↓
KnowledgeController.ingest()
      ↓
KnowledgeIngestionService.ingest()
      ↓
切分 Chunk → Embedding → 删除旧 Point → 写入新 Point

使用同一个 documentId 再次提交就是更新。服务端会先删除这个文档的旧 Chunk,再写入本次内容,避免残留。

第 4 步:页面怎样删除知识

点击"删除文档"后,网页会先显示确认框。确认后调用:

http 复制代码
DELETE /api/knowledge/documents/{documentId}

encodeURIComponent(documentId) 会把文档 ID 中不适合直接出现在 URL 的字符进行编码。成功响应是 204 No Content,表示删除成功但没有 JSON 正文,因此前端不能继续调用 response.json()

删除只根据 documentId 执行。source 和 Markdown 输入框不会参与定位。

第 5 步:页面怎样完成 RAG 问答

点击"开始提问"后,网页发送:

javascript 复制代码
fetch("/api/rag/ask", {
  method: "POST",
  headers: {"Content-Type": "application/json"},
  body: JSON.stringify({question: question})
});

完整链路是:

text 复制代码
问题输入框
    ↓
askQuestion()
    ↓ POST /api/rag/ask
RagController.ask()
    ↓
RagService.ask()
    ├── bge-m3:问题向量化
    ├── Qdrant:检索相似 Chunk
    ├── Java:组装 Prompt
    └── qwen3:14b:根据资料生成回答
    ↓
RagResponse { answer, sources }
    ↓
renderAnswer() 安全渲染答案和来源

页面中的 answer 来自模型;sources 来自 Java 对 Qdrant 真实检索结果的映射。来源包含:

text 复制代码
title     Chunk 标题
source    原文来源
score     向量相似度
pointId   Qdrant Point 身份

前端使用 textContent 写入这些内容,不把模型回答当 HTML 执行,避免回答中的文本变成页面脚本。

第 6 步:理解页面状态

一次网络请求不是瞬间完成的,网页需要明确区分:

状态 页面行为
等待 表单可以编辑,顶部显示"等待首次请求"
请求中 按钮禁用,显示正在入库、检索或生成
成功 显示 Chunk 数量、答案和来源
API 失败 优先显示后端返回的 message
无来源 显示回答,并明确来源数量为 0

requestJson() 统一处理所有 HTTP 请求。这样三个操作不用重复解析错误 JSON,也能正确区分 204 和普通 JSON 响应。

关键代码一:统一处理 HTTP 成功和失败

对应源码:

text 复制代码
05-spring-rag/src/main/resources/static/app.js
javascript 复制代码
async function requestJson(path, options) {
  let response;
  try {
    response = await fetch(path, {
      ...options,
      headers: {
        "Content-Type": "application/json",
        ...(options.headers || {})
      }
    });
  } catch (cause) {
    throw new RequestError("无法连接 Spring Boot 服务", cause);
  }

  if (response.ok) {
    if (response.status === 204) {
      return null;
    }
    return response.json();
  }

  let message = `请求失败(HTTP ${response.status})`;
  try {
    const apiError = await response.json();
    if (apiError.message) {
      message = apiError.message;
    }
  } catch (_) {
    // 非 JSON 错误响应保留 HTTP 状态提示。
  }
  throw new RequestError(message);
}

这个函数把三类结果收口:网络层无法连接、HTTP 成功、HTTP 错误。页面的入库、删除和问答都只需关心各自的业务数据。

关键代码二:答案和来源分开渲染

javascript 复制代码
function renderAnswer(result) {
  const sources = Array.isArray(result.sources) ? result.sources : [];
  elements.answerText.textContent = result.answer || "没有返回回答内容。";
  elements.sourceCount.textContent = `${sources.length} 条来源`;
  elements.sourceList.replaceChildren();

  if (sources.length === 0) {
    const emptyItem = document.createElement("li");
    emptyItem.textContent = "本次回答没有可展示的检索来源";
    elements.sourceList.append(emptyItem);
  } else {
    sources.forEach((source, index) => {
      elements.sourceList.append(createSourceItem(source, index));
    });
  }
}

answersources 从 API 响应的两个不同字段读取,不会从模型回答文字中猜来源。所有可变文本使用 textContent,不会当作 HTML 执行。

输入草稿保存在浏览器 localStorage 中。它只是本机浏览器的使用体验功能,不会写入 Qdrant;真正入库仍然要点击"提交入库"。

第 7 步:自己验证完整闭环

先提交一篇独立测试文档:

text 复制代码
文档 ID:web-demo
来源:web-demo.md
markdown 复制代码
## Ingress

Ingress 用来声明进入集群的 HTTP 和 HTTPS 路由规则。

然后提问:

text 复制代码
Ingress 用来做什么?

应当看到:

  • 页面返回 AI 回答。
  • 来源标题中出现 Ingress
  • 来源文件是 web-demo.md
  • 页面显示相似度和 Point ID。

最后填写 web-demo,点击"删除文档"。再次提问时,这篇文档不应再出现在来源列表中。

第 8 步:运行测试

bash 复制代码
mvn -f 05-spring-rag/pom.xml test

SpringRagApplicationTest 会启动随机端口,通过真实 HTTP 请求验证:

  • / 返回 HTTP 200。
  • 响应类型兼容 text/html
  • 页面包含工作台标题。

API Controller、入库服务、Qdrant 映射和 Ollama 客户端仍由原有测试覆盖。

常见问题

打开根地址仍然是 404

网页文件只有在 Spring Boot 重新启动后才会进入运行时 classpath。先停止旧进程,再重新执行:

bash 复制代码
mvn -f 05-spring-rag/pom.xml spring-boot:run

检查首页响应:

bash 复制代码
curl -I http://localhost:8080/

应返回 HTTP 200Content-Type: text/html

页面显示"外部服务暂时不可用"

这对应后端 HTTP 503。依次检查:

bash 复制代码
curl http://localhost:11434/api/tags
docker ps --filter name=qdrant-study
curl http://localhost:6333/collections/kubernetes_chunks

Ollama 和 Qdrant 是独立进程,Spring Boot 启动成功不代表它们一定可用。

提交文档时提示没有可入库章节

当前拆分规则只索引 ## 二级标题。下面的内容不能入库:

markdown 复制代码
# 只有一级标题

这段正文没有二级标题。

至少增加一个二级标题:

markdown 复制代码
## 可检索章节

这段内容会生成 Chunk。

入库成功但回答资料不足

先确认问题与文档内容确实相关,再检查:

  • 页面是否显示写入了至少 1 个 Chunk。
  • documentId 是否误删或被其他内容替换。
  • rag.search.minimum-score 是否设置过高。
  • 回答的 sources 是否为空。

阈值过滤发生在模型调用之前。没有 Chunk 达到阈值时,程序会直接返回"根据现有资料无法确定"。

端口 8080 已被占用

查找占用进程:

bash 复制代码
lsof -nP -iTCP:8080 -sTCP:LISTEN

也可以临时改用 8081:

bash 复制代码
mvn -f 05-spring-rag/pom.xml spring-boot:run \
  -Dspring-boot.run.arguments=--server.port=8081

浏览器地址随之改为 http://localhost:8081

刷新后仍然出现以前填写的内容

这是草稿恢复功能,不代表内容又被写入 Qdrant。清空输入框后会同步清空对应的浏览器草稿。

删除按钮无法使用

先填写要删除的 documentId。删除接口只通过文档 ID 定位知识,不使用 source 或 Markdown 内容定位。

本篇完成检查

  • 可以打开 http://localhost:8080
  • 可以提交 Markdown 并看到 Chunk 数量
  • 可以更新相同 documentId 的文档
  • 可以提问并看到答案和来源
  • 可以删除测试文档
  • 刷新页面后表单草稿仍在
  • mvn -f 05-spring-rag/pom.xml test 全部通过

完成这一篇后,项目已经从命令行练习发展成可直接体验的本地 RAG 应用。下一阶段可以学习 Docker 镜像、配置外置和 Kubernetes 部署,不必继续增加前端框架复杂度。

概念混淆时可以回到**《第12篇:AI 与 RAG 术语索引》**,按照"模型调用、检索、完整 RAG、动态入库"四条主线复习。

本篇自测

  1. 网页会自己调用 Qdrant SDK 吗?
  2. 为什么前端要区分普通 JSON 响应和 HTTP 204?
  3. 为什么模型回答要用 textContent 渲染,不直接使用 innerHTML
  4. localStorage 中有 Markdown 是否代表 Qdrant 中已经有这篇文档?
  5. 判断一次 RAG 验证是否可信,为什么要同时看回答和 sources

参考答案:网页只调用 Spring Boot HTTP API,Qdrant SDK 在 Java 后端;204 没有 JSON 正文,继续解析会失败;textContent 不会把模型文本当成 HTML 执行;localStorage 只是浏览器草稿;回答可能看起来合理,但 sources 才能证明本次确实检索到了哪些资料。


本篇小结

  1. Spring Boot 直接托管 static/ 下的 HTML、CSS 和 JavaScript,不需要 Node.js,也不需要额外的前端开发服务器。
  2. 网页没有替代后端,它只是把表单内容组装成 JSON,调用已经实现好的 KnowledgeControllerRagController
  3. 页面要显式区分等待、请求中、成功、API 失败和无来源五种状态;requestJson() 统一处理响应,并正确区分 204 与普通 JSON。
  4. 答案和来源使用 textContent 渲染,不把模型输出当 HTML 执行,避免回答内容变成页面脚本。
  5. localStorage 只保存表单草稿,草稿恢复不等于内容已经写入 Qdrant。

下一篇

👉 本专栏下一篇:《第12篇:AI 与 RAG 术语索引》

完整代码都在 GitHub(欢迎 Star ⭐)

本专栏的全部示例代码都已开源,包含 5 个可独立运行的 Maven 模块、自动化测试和完整分篇教程。建议 Fork / Clone 下来,边读边跑:

🔗 https://github.com/bysbsh/ai-rag-learning-guide

  • 代码与教程同步更新,对照每一篇动手实践效果最好。
  • 如果这份教程帮到了你,点个 Star 就是对我最大的支持,也方便你之后找回最新版本。
  • 遇到问题或发现错漏,欢迎在仓库提 Issue / PR。项目采用 MIT 协议,可自由学习与二次创作。
相关推荐
离陌在学C#3 小时前
C# 事件(Event)详解:从概念到实战
开发语言·c#
0x534 小时前
网站通信(一)
java
Terra.K4 小时前
Java异常学习[特殊字符]
java·开发语言·学习
fthux4 小时前
招聘季实测:我用 TraeWork 搭了一套 AI 简历初筛系统
人工智能·ai编程·trae
SLD_Allen4 小时前
NVIDIA KAI Scheduler深度解析——Kubernetes原生AI调度器的架构、原理与实践
人工智能·架构·kubernetes
小玮看世界4 小时前
从“画图”到“Mermaid”:AI语义理解与架构绘制的演进之路
人工智能·深度学习·计算机视觉
小岐AI观5 小时前
智赋岐黄适合养生馆吗?
大数据·人工智能·物联网
YOLO数据集集合5 小时前
煤炭物料检测数据集:基于YOLO26的矿山传送带智能“哨兵”
人工智能·yolo·数据挖掘·煤炭·煤炭检测·矿山传送带·传送带异物