返回文章列表

基于 Spring AI 的个人知识库问答系统

m
moonpeak
| | 15 分钟
Tip

本文是「AI 工程化实践」系列的第一篇,聚焦最小可用产品(MVP)级别的生产落地。

引言

随着大语言模型(LLM)技术的快速发展,越来越多的开发者开始探索如何将 AI 能力集成到自己的应用中。然而,公有云 API 方案存在数据隐私、网络依赖、调用成本等问题。本文将介绍如何构建一套完全离线运行的个人知识库问答系统,核心技术栈包括:

  • Spring AI: Spring 官方推出的 AI 应用开发框架
  • Ollama: 本地运行大模型的轻量级工具
  • 向量数据库: 存储和检索文档嵌入向量
  • RAG 架构: 检索增强生成,让大模型基于私有知识回答

系统架构概览

┌─────────────────────────────────────────────────────────────┐
│ 用户查询界面 │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Spring Boot 应用层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Chat API │ │ Embedding │ │ Document Store │ │
│ │ Controller │ │ Service │ │ Repository │ │
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
└─────────┼────────────────┼────────────────────┼─────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Spring AI 抽象层 │
│ (ChatClient, EmbeddingClient, VectorStore) │
└─────────────────────┬───────────────────────────────────────┘
┌───────────┴───────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Ollama │ │ 向量数据库 │
│ (本地 LLM 服务) │ │ (Chroma/PGVector) │
└──────────────────┘ └──────────────────┘

环境准备

1. 安装 Ollama

Ollama 是在本地运行大模型的最简单方式,支持 macOS、Linux 和 Windows。

Terminal window
# macOS / Linux
curl -fsSL https://ollama.com/install.sh | sh
# Windows 下载安装包
# https://ollama.com/download/windows
Note

Ollama 默认监听 localhost:11434,确保该端口未被占用。

2. 拉取所需模型

Terminal window
# 拉取聊天模型(推荐 qwen2.5 或 llama3.2)
ollama pull qwen2.5:7b
# 拉取嵌入模型(用于文档向量化)
ollama pull nomic-embed-text

3. 验证安装

Terminal window
# 启动 Ollama 服务
ollama serve
# 测试聊天接口
ollama run qwen2.5:7b "你好,请介绍一下自己"

项目初始化

Maven 依赖配置

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>knowledge-base-rag</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<java.version>21</java.version>
<spring-ai.version>1.0.0-M2</spring-ai.version>
</properties>
<dependencies>
<!-- Spring Boot Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring AI Ollama Starter -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
<!-- Spring AI Vector Store - Chroma -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-chroma-store-spring-boot-starter</artifactId>
</dependency>
<!-- 文档解析 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>

应用配置

application.yml
spring:
ai:
ollama:
base-url: http://localhost:11434
chat:
model: qwen2.5:7b
options:
temperature: 0.7
num-predict: 2048
embedding:
model: nomic-embed-text
options:
num-batch: 512
vectorstore:
chroma:
client:
host: http://localhost
port: 8000
collection-name: knowledge-base
initialize-schema: true
server:
port: 8080

核心代码实现

1. 文档向量化服务

@Service
@Slf4j
@RequiredArgsConstructor
public class DocumentIngestionService {
private final EmbeddingModel embeddingModel;
private final VectorStore vectorStore;
/**
* 将文档分块并存储到向量数据库
*/
public void ingestDocument(Resource resource) {
try {
// 1. 读取文档内容
TikaDocumentReader reader = new TikaDocumentReader(resource);
List<Document> documents = reader.get();
log.info("成功读取文档,页数/段落数: {}", documents.size());
// 2. 配置文本分割器
TokenTextSplitter splitter = new TokenTextSplitter(
512, // 每块最大 token 数
100, // 最小 token 数
50, // 重叠 token 数
1000, // 最大字符数
true // 保留格式
);
// 3. 分割文档
List<Document> chunks = splitter.apply(documents);
log.info("文档分割完成,共 {} 个片段", chunks.size());
// 4. 生成嵌入向量并存储
vectorStore.add(chunks);
log.info("文档已成功存入向量数据库");
} catch (Exception e) {
log.error("文档处理失败", e);
throw new RuntimeException("文档向量化失败", e);
}
}
}

2. RAG 问答服务

@Service
@Slf4j
@RequiredArgsConstructor
public class KnowledgeBaseService {
private final ChatClient chatClient;
private final VectorStore vectorStore;
// RAG 系统提示模板
private static final String RAG_SYSTEM_PROMPT = """
你是一个专业的知识库助手,基于以下检索到的相关信息回答用户问题。
回答规则:
1. 严格基于提供的参考资料回答,不要引入外部知识
2. 如果参考资料无法回答问题,请明确告知"根据现有资料无法回答"
3. 回答时引用参考信息的来源片段
4. 保持回答简洁准确
参考资料:
{context}
""";
/**
* RAG 增强问答
*/
public String answer(String question) {
// 1. 检索相关文档片段
SearchRequest searchRequest = SearchRequest.builder()
.query(question)
.topK(5) // 返回前 5 个最相关的片段
.similarityThreshold(0.7) // 相似度阈值
.build();
List<Document> relevantDocs = vectorStore.similaritySearch(searchRequest);
log.info("检索到 {} 个相关文档片段", relevantDocs.size());
// 2. 组装上下文
String context = relevantDocs.stream()
.map(doc -> String.format("[来源: %s]\n%s",
doc.getMetadata().getOrDefault("source", "未知"),
doc.getText()))
.collect(Collectors.joining("\n\n---\n\n"));
// 3. 构建增强提示
String augmentedPrompt = RAG_SYSTEM_PROMPT.replace("{context}", context);
// 4. 调用大模型生成回答
return chatClient.prompt()
.system(augmentedPrompt)
.user(question)
.call()
.content();
}
/**
* 流式回答(SSE 推送)
*/
public Flux<String> streamAnswer(String question) {
// 同样先检索,然后流式生成
SearchRequest searchRequest = SearchRequest.builder()
.query(question)
.topK(5)
.build();
List<Document> relevantDocs = vectorStore.similaritySearch(searchRequest);
String context = formatContext(relevantDocs);
String augmentedPrompt = RAG_SYSTEM_PROMPT.replace("{context}", context);
return chatClient.prompt()
.system(augmentedPrompt)
.user(question)
.stream()
.content();
}
private String formatContext(List<Document> docs) {
return docs.stream()
.map(Document::getText)
.collect(Collectors.joining("\n\n"));
}
}

3. REST API 控制器

@RestController
@RequestMapping("/api/knowledge")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class KnowledgeBaseController {
private final KnowledgeBaseService knowledgeService;
private final DocumentIngestionService ingestionService;
/**
* 上传文档到知识库
*/
@PostMapping("/upload")
public ResponseEntity<String> uploadDocument(
@RequestParam("file") MultipartFile file) {
try {
Resource resource = new InputStreamResource(file.getInputStream());
ingestionService.ingestDocument(resource);
return ResponseEntity.ok("文档上传成功,已建立索引");
} catch (Exception e) {
return ResponseEntity.status(500)
.body("上传失败: " + e.getMessage());
}
}
/**
* 同步问答接口
*/
@PostMapping("/ask")
public ResponseEntity<AnswerResponse> ask(@RequestBody AskRequest request) {
String answer = knowledgeService.answer(request.getQuestion());
return ResponseEntity.ok(new AnswerResponse(answer));
}
/**
* 流式问答接口(SSE)
*/
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamAsk(
@RequestParam String question) {
return knowledgeService.streamAnswer(question)
.map(content -> ServerSentEvent.builder(content).build());
}
// DTO 类
@Data
public static class AskRequest {
private String question;
}
@Data
@AllArgsConstructor
public static class AnswerResponse {
private String answer;
}
}

4. 启动类与配置

@SpringBootApplication
public class KnowledgeBaseApplication {
public static void main(String[] args) {
SpringApplication.run(KnowledgeBaseApplication.class, args);
}
/**
* 配置 ChatClient.Builder
*/
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder.build();
}
}

前端交互示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>知识库问答系统</title>
<style>
body { max-width: 800px; margin: 0 auto; padding: 20px; font-family: system-ui; }
.chat-container { border: 1px solid #ddd; border-radius: 8px; padding: 20px; }
.message { margin: 10px 0; padding: 12px; border-radius: 8px; }
.user { background: #e3f2fd; text-align: right; }
.assistant { background: #f5f5f5; }
.input-area { display: flex; gap: 10px; margin-top: 20px; }
input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 4px; }
button { padding: 10px 20px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background: #1565c0; }
.upload-area { margin-bottom: 20px; padding: 20px; border: 2px dashed #ccc; border-radius: 8px; text-align: center; }
</style>
</head>
<body>
<h1>📚 个人知识库问答系统</h1>
<div class="upload-area">
<input type="file" id="fileInput" accept=".pdf,.doc,.docx,.txt,.md" />
<button onclick="uploadFile()">上传文档</button>
<span id="uploadStatus"></span>
</div>
<div class="chat-container" id="chatContainer">
<div class="message assistant">
你好!我已经准备好回答关于您上传文档的问题。请先上传文档或直接提问。
</div>
</div>
<div class="input-area">
<input type="text" id="questionInput" placeholder="输入您的问题..."
onkeypress="if(event.key==='Enter')sendQuestion()">
<button onclick="sendQuestion()">发送</button>
</div>
<script>
const API_BASE = 'http://localhost:8080/api/knowledge';
async function uploadFile() {
const fileInput = document.getElementById('fileInput');
const status = document.getElementById('uploadStatus');
if (!fileInput.files.length) {
alert('请先选择文件');
return;
}
const formData = new FormData();
formData.append('file', fileInput.files[0]);
status.textContent = '上传中...';
try {
const response = await fetch(`${API_BASE}/upload`, {
method: 'POST',
body: formData
});
const result = await response.text();
status.textContent = result;
} catch (e) {
status.textContent = '上传失败';
}
}
async function sendQuestion() {
const input = document.getElementById('questionInput');
const question = input.value.trim();
if (!question) return;
addMessage(question, 'user');
input.value = '';
// 使用流式响应
const eventSource = new EventSource(
`${API_BASE}/stream?question=${encodeURIComponent(question)}`
);
let answerDiv = null;
eventSource.onmessage = (event) => {
if (!answerDiv) {
answerDiv = addMessage('', 'assistant');
}
answerDiv.textContent += event.data;
};
eventSource.onerror = () => {
eventSource.close();
};
}
function addMessage(text, role) {
const container = document.getElementById('chatContainer');
const div = document.createElement('div');
div.className = `message ${role}`;
div.textContent = text;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
return div;
}
</script>
</body>
</html>

向量数据库部署

使用 Docker 启动 Chroma

Terminal window
# 创建数据目录
mkdir -p ~/chroma-data
# 启动 Chroma 容器
docker run -d \
--name chroma-server \
-p 8000:8000 \
-v ~/chroma-data:/chroma/chroma \
--restart always \
chromadb/chroma:latest

性能优化建议

1. 模型选择权衡

模型显存需求速度质量适用场景
qwen2.5:0.5b1GB极快一般纯 CPU 部署
qwen2.5:7b8GB良好平衡选择
qwen2.5:14b16GB中等优秀高质量回答
llama3.2:3b4GB良好英文场景

2. 分块策略调优

// 根据文档类型调整分块参数
TokenTextSplitter codeSplitter = new TokenTextSplitter(256, 50, 20, 500, true);
TokenTextSplitter docSplitter = new TokenTextSplitter(1024, 200, 100, 2000, true);

3. 检索优化

// 混合检索:向量相似度 + 关键词匹配
SearchRequest request = SearchRequest.builder()
.query(question)
.topK(10)
.similarityThreshold(0.6)
.filterExpression("type == 'technical'") // 元数据过滤
.build();

完整项目结构

knowledge-base-rag/
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/example/knowledge/
│ │ │ ├── KnowledgeBaseApplication.java
│ │ │ ├── config/
│ │ │ │ └── AiConfig.java
│ │ │ ├── controller/
│ │ │ │ └── KnowledgeBaseController.java
│ │ │ ├── service/
│ │ │ │ ├── KnowledgeBaseService.java
│ │ │ │ └── DocumentIngestionService.java
│ │ │ └── dto/
│ │ │ └── ChatMessage.java
│ │ └── resources/
│ │ ├── application.yml
│ │ └── static/
│ │ └── index.html
│ └── test/
├── pom.xml
├── docker-compose.yml
└── README.md

部署与运行

Terminal window
# 1. 启动 Ollama
ollama serve
# 2. 启动 Chroma (如使用 Docker)
docker-compose up -d chroma
# 3. 构建并运行 Spring Boot 应用
./mvnw spring-boot:run
# 4. 访问 http://localhost:8080

总结

本文实现了一个最小可用的全离线知识库问答系统,核心特点:

  1. 完全离线: 无需联网,数据不出本地
  2. 易于扩展: Spring AI 抽象层支持随时切换其他模型或向量库
  3. 生产就绪: 包含完整的错误处理、日志和监控点
Important

下一篇预告:《将本系统封装为 MCP Server,实现与 Claude/Cursor 等 IDE 的无缝集成》


参考资源: