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。
# macOS / Linuxcurl -fsSL https://ollama.com/install.sh | sh
# Windows 下载安装包# https://ollama.com/download/windowsNoteOllama 默认监听
localhost:11434,确保该端口未被占用。
2. 拉取所需模型
# 拉取聊天模型(推荐 qwen2.5 或 llama3.2)ollama pull qwen2.5:7b
# 拉取嵌入模型(用于文档向量化)ollama pull nomic-embed-text3. 验证安装
# 启动 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>应用配置
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@RequiredArgsConstructorpublic 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@RequiredArgsConstructorpublic 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. 启动类与配置
@SpringBootApplicationpublic 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
# 创建数据目录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.5b | 1GB | 极快 | 一般 | 纯 CPU 部署 |
| qwen2.5:7b | 8GB | 快 | 良好 | 平衡选择 |
| qwen2.5:14b | 16GB | 中等 | 优秀 | 高质量回答 |
| llama3.2:3b | 4GB | 快 | 良好 | 英文场景 |
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部署与运行
# 1. 启动 Ollamaollama serve
# 2. 启动 Chroma (如使用 Docker)docker-compose up -d chroma
# 3. 构建并运行 Spring Boot 应用./mvnw spring-boot:run
# 4. 访问 http://localhost:8080总结
本文实现了一个最小可用的全离线知识库问答系统,核心特点:
- 完全离线: 无需联网,数据不出本地
- 易于扩展: Spring AI 抽象层支持随时切换其他模型或向量库
- 生产就绪: 包含完整的错误处理、日志和监控点
Important下一篇预告:《将本系统封装为 MCP Server,实现与 Claude/Cursor 等 IDE 的无缝集成》
参考资源: