qwen与向量数据库搭建个人微知识库2
@
概要
上一篇已经用py实现了使用qwen模型读取本地向量库回复,今天整理一下整条前端到后端的链路
整体架构流程
前端html=》后端java=》问答后端python
技术细节
前端代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>企业知识库 AI 助手</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
<!-- Tailwind 配置 -->
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#165DFF',
secondary: '#36D399',
neutral: '#1E293B',
"neutral-light": '#F1F5F9',
"neutral-dark": '#0F172A'
},
fontFamily: {
inter: ['Inter', 'system-ui', 'sans-serif'],
},
},
}
}
</script>
<!-- 自定义工具类 -->
<style type="text/tailwindcss">
@layer utilities {
.content-auto {
content-visibility: auto;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.text-shadow {
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.bg-gradient-primary {
background: linear-gradient(135deg, #165DFF 0%, #0040C1 100%);
}
}
</style>
</head>
<body class="font-inter bg-gray-50 text-neutral min-h-screen flex flex-col">
<!-- 顶部导航栏 -->
<header class="bg-white shadow-md fixed top-0 left-0 right-0 z-50 transition-all duration-300">
<div class="container mx-auto px-4 py-3 flex items-center justify-between">
<div class="flex items-center space-x-2">
<i class="fa fa-book text-primary text-2xl"></i>
<h1 class="text-xl font-bold text-neutral">企业知识库 AI 助手</h1>
</div>
<div class="flex items-center space-x-4">
<button id="uploadBtn" class="hidden md:flex items-center px-4 py-2 rounded-md border border-gray-300 hover:bg-gray-50 transition-colors">
<i class="fa fa-cloud-upload mr-2 text-primary"></i>
<span>上传文档</span>
</button>
<button id="refreshBtn" class="p-2 rounded-full hover:bg-gray-100 transition-colors">
<i class="fa fa-refresh text-primary"></i>
</button>
<button id="settingsBtn" class="p-2 rounded-full hover:bg-gray-100 transition-colors">
<i class="fa fa-cog text-primary"></i>
</button>
</div>
</div>
</header>
<!-- 主内容区 -->
<main class="flex-1 pt-16 pb-20">
<div class="container mx-auto px-4 py-6">
<!-- 聊天区域 -->
<div id="chatContainer" class="bg-white rounded-xl shadow-lg max-w-4xl mx-auto h-[70vh] flex flex-col overflow-hidden">
<!-- 消息历史 -->
<div id="messageContainer" class="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-hide">
<!-- 欢迎消息 -->
<div class="flex items-start space-x-3 animate-fade-in">
<div class="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-white flex-shrink-0">
<i class="fa fa-robot"></i>
</div>
<div class="bg-neutral-light rounded-lg rounded-tl-none p-4 max-w-[90%] shadow-sm">
<p class="text-neutral">你好!我是企业知识库AI助手,我可以回答你关于公司文档的问题。请输入你的问题,我会实时为你解答。</p>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="border-t border-gray-200 p-4">
<form id="questionForm" class="flex items-center space-x-2">
<textarea
id="questionInput"
placeholder="输入你的问题..."
class="flex-1 border border-gray-300 rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none h-16 transition-all"
rows="1"
></textarea>
<button
type="submit"
id="submitBtn"
class="bg-primary hover:bg-primary/90 text-white rounded-lg p-3 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<i class="fa fa-paper-plane"></i>
</button>
</form>
<!-- 语音输入按钮 -->
<div class="flex justify-end mt-2">
<button id="voiceBtn" class="text-gray-500 hover:text-primary transition-colors p-2">
<i class="fa fa-microphone"></i>
</button>
</div>
</div>
</div>
</div>
</main>
<!-- 底部版权信息 -->
<footer class="bg-white border-t border-gray-200 py-4">
<div class="container mx-auto px-4 text-center text-gray-500 text-sm">
<p>© 2025 企业知识库 AI 助手 | 由 Ollama 提供支持</p>
</div>
</footer>
<!-- 文件上传模态框 -->
<div id="uploadModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 transform transition-all duration-300 scale-95 opacity-0" id="modalContent">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-neutral">上传文档</h3>
<button id="closeModalBtn" class="text-gray-500 hover:text-gray-700">
<i class="fa fa-times"></i>
</button>
</div>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-primary transition-colors cursor-pointer" id="dropZone">
<i class="fa fa-cloud-upload text-4xl text-gray-400 mb-3"></i>
<p class="text-gray-500">拖放文件到此处,或点击选择文件</p>
<input type="file" id="fileInput" class="hidden" multiple accept=".txt,.pdf,.docx,.xlsx,.csv">
</div>
<div class="mt-4">
<button id="uploadFilesBtn" class="w-full bg-primary hover:bg-primary/90 text-white py-2 rounded-lg transition-colors">
上传文件
</button>
</div>
</div>
</div>
</div>
<!-- JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// DOM元素
const questionForm = document.getElementById('questionForm');
const questionInput = document.getElementById('questionInput');
const submitBtn = document.getElementById('submitBtn');
const messageContainer = document.getElementById('messageContainer');
const uploadBtn = document.getElementById('uploadBtn');
const uploadModal = document.getElementById('uploadModal');
const modalContent = document.getElementById('modalContent');
const closeModalBtn = document.getElementById('closeModalBtn');
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const uploadFilesBtn = document.getElementById('uploadFilesBtn');
const voiceBtn = document.getElementById('voiceBtn');
const refreshBtn = document.getElementById('refreshBtn');
// 消息ID计数器
let messageId = 0;
// 打开上传模态框
uploadBtn.addEventListener('click', () => {
uploadModal.classList.remove('hidden');
setTimeout(() => {
modalContent.classList.remove('scale-95', 'opacity-0');
modalContent.classList.add('scale-100', 'opacity-100');
}, 10);
});
// 关闭上传模态框
closeModalBtn.addEventListener('click', closeModal);
function closeModal() {
modalContent.classList.remove('scale-100', 'opacity-100');
modalContent.classList.add('scale-95', 'opacity-0');
setTimeout(() => {
uploadModal.classList.add('hidden');
}, 300);
}
// 点击空白区域关闭模态框
uploadModal.addEventListener('click', (e) => {
if (e.target === uploadModal) {
closeModal();
}
});
// 拖放文件
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, unhighlight, false);
});
function highlight() {
dropZone.classList.add('border-primary');
dropZone.classList.add('bg-primary/5');
}
function unhighlight() {
dropZone.classList.remove('border-primary');
dropZone.classList.remove('bg-primary/5');
}
dropZone.addEventListener('drop', handleDrop, false);
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
handleFiles(files);
}
// 点击选择文件
dropZone.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', () => {
handleFiles(fileInput.files);
});
// 处理文件
let selectedFiles = [];
function handleFiles(files) {
if (files.length > 0) {
selectedFiles = Array.from(files);
// 显示文件列表(简化版)
dropZone.innerHTML = `
<i class="fa fa-check-circle text-green-500 text-3xl mb-2"></i>
<p class="text-gray-700">已选择 ${selectedFiles.length} 个文件</p>
<p class="text-sm text-gray-500 mt-1">点击上传按钮开始处理</p>
`;
}
}
// 上传文件
uploadFilesBtn.addEventListener('click', async () => {
if (selectedFiles.length === 0) {
alert('请选择文件');
return;
}
// 创建表单数据
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append('files', file);
});
// 显示上传中消息
showMessage('正在上传和处理文件,请稍候...', 'system');
try {
// 发送请求
const response = await fetch('/api/documents/upload', {
method: 'POST',
body: formData
});
if (response.ok) {
showMessage('文件上传成功!正在处理文档内容...', 'system');
// 处理文档
await processDocuments();
} else {
showMessage('文件上传失败: ' + await response.text(), 'error');
}
} catch (error) {
showMessage('发生错误: ' + error.message, 'error');
}
// 关闭模态框
closeModal();
});
// 处理文档
async function processDocuments() {
try {
const response = await fetch('/api/documents/process-all', {
method: 'POST'
});
if (response.ok) {
showMessage('文档处理完成!现在可以查询相关内容了。', 'system');
} else {
showMessage('文档处理失败: ' + await response.text(), 'error');
}
} catch (error) {
showMessage('发生错误: ' + error.message, 'error');
}
}
// 提交问题
questionForm.addEventListener('submit', async (e) => {
e.preventDefault();
const question = questionInput.value.trim();
if (!question) return;
// 清空输入框
questionInput.value = '';
questionInput.rows = 1;
// 显示用户问题
showMessage(question, 'user');
// 禁用提交按钮
submitBtn.disabled = true;
// 创建SSE连接
const eventSource = new EventSource(`http://127.0.0.1:12002/api/backend/user/stream-query?question=${encodeURIComponent(question)}`);
// 显示AI正在思考的消息
const aiMessageId = `message-${messageId++}`;
let aiResponse = '';
const aiMessageElement = document.createElement('div');
aiMessageElement.id = aiMessageId;
aiMessageElement.className = 'flex items-start space-x-3 animate-fade-in';
aiMessageElement.innerHTML = `
<div class="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-white flex-shrink-0">
<i class="fa fa-robot"></i>
</div>
<div class="bg-neutral-light rounded-lg rounded-tl-none p-4 max-w-[90%] shadow-sm">
<div class="text-neutral" id="${aiMessageId}-content"></div>
<div class="mt-2 text-xs text-gray-500" id="${aiMessageId}-status">正在思考...</div>
</div>
`;
messageContainer.appendChild(aiMessageElement);
messageContainer.scrollTop = messageContainer.scrollHeight;
// 处理SSE消息
eventSource.onmessage = function(event) {
aiResponse += event.data;
const contentElement = document.getElementById(`${aiMessageId}-content`);
contentElement.innerHTML = formatResponse(aiResponse);
messageContainer.scrollTop = messageContainer.scrollHeight;
};
// 处理SSE错误
eventSource.onerror = function(error) {
eventSource.close();
const statusElement = document.getElementById(`${aiMessageId}-status`);
statusElement.textContent = '连接已关闭';
statusElement.className = 'mt-2 text-xs text-red-500';
submitBtn.disabled = false;
};
// 处理SSE完成
eventSource.onopen = function() {
const statusElement = document.getElementById(`${aiMessageId}-status`);
statusElement.textContent = '正在生成答案...';
};
// 监听SSE连接关闭
eventSource.addEventListener('close', function() {
eventSource.close();
const statusElement = document.getElementById(`${aiMessageId}-status`);
statusElement.textContent = '回答完成';
statusElement.className = 'mt-2 text-xs text-green-500';
submitBtn.disabled = false;
});
});
// 自动调整文本框高度
questionInput.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = (this.scrollHeight > 80 ? 80 : this.scrollHeight) + 'px';
});
// 语音输入
voiceBtn.addEventListener('click', function() {
if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
recognition.lang = 'zh-CN';
recognition.onstart = function() {
voiceBtn.innerHTML = '<i class="fa fa-microphone-slash text-red-500"></i>';
voiceBtn.classList.add('animate-pulse');
};
recognition.onresult = function(event) {
const transcript = event.results[0][0].transcript;
questionInput.value = transcript;
};
recognition.onend = function() {
voiceBtn.innerHTML = '<i class="fa fa-microphone"></i>';
voiceBtn.classList.remove('animate-pulse');
};
recognition.onerror = function(event) {
console.error('语音识别错误:', event.error);
voiceBtn.innerHTML = '<i class="fa fa-microphone"></i>';
voiceBtn.classList.remove('animate-pulse');
};
recognition.start();
} else {
alert('您的浏览器不支持语音输入功能');
}
});
// 刷新知识库
refreshBtn.addEventListener('click', async function() {
showMessage('正在刷新知识库索引...', 'system');
try {
const response = await fetch('/api/knowledge/refresh', {
method: 'POST'
});
if (response.ok) {
showMessage('知识库已刷新!', 'system');
} else {
showMessage('刷新失败: ' + await response.text(), 'error');
}
} catch (error) {
showMessage('发生错误: ' + error.message, 'error');
}
});
// 显示消息
function showMessage(content, type = 'user') {
const curMessageId = `message-${messageId++}`;
let messageHtml = '';
if (type === 'user') {
messageHtml = `
<div id="${curMessageId}" class="flex items-start space-x-3 justify-end animate-fade-in">
<div class="bg-primary text-white rounded-lg rounded-tr-none p-4 max-w-[90%] shadow-sm">
<div class="text-white">${formatResponse(content)}</div>
</div>
<div class="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-white flex-shrink-0">
<i class="fa fa-user"></i>
</div>
</div>
`;
} else if (type === 'system') {
messageHtml = `
<div id="${curMessageId}" class="flex items-center justify-center animate-fade-in">
<div class="bg-gray-100 text-gray-600 rounded-full px-4 py-2 text-sm">
${content}
</div>
</div>
`;
} else if (type === 'error') {
messageHtml = `
<div id="${curMessageId}" class="flex items-center justify-center animate-fade-in">
<div class="bg-red-100 text-red-600 rounded-full px-4 py-2 text-sm">
${content}
</div>
</div>
`;
}
messageContainer.innerHTML += messageHtml;
messageContainer.scrollTop = messageContainer.scrollHeight;
// 添加动画效果
setTimeout(() => {
document.getElementById(curMessageId).classList.add('opacity-100');
}, 10);
}
// 格式化响应内容(支持Markdown基本格式)
function formatResponse(text) {
// 替换换行符为<br>
text = text.replace(/\n/g, '<br>');
// 替换粗体 **text**
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// 替换斜体 *text*
text = text.replace(/\*(.*?)\*/g, '<em>$1</em>');
// 替换链接 [text](url)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" class="text-primary hover:underline">$1</a>');
// 替换代码块 `code`
text = text.replace(/`([^`]+)`/g, '<code class="bg-gray-100 px-1 py-0.5 rounded text-sm">$1</code>');
return text;
}
// 监听滚动事件,改变导航栏样式
window.addEventListener('scroll', function() {
const header = document.querySelector('header');
if (window.scrollY > 10) {
header.classList.add('py-2', 'shadow-lg');
header.classList.remove('py-3', 'shadow-md');
} else {
header.classList.add('py-3', 'shadow-md');
header.classList.remove('py-2', 'shadow-lg');
}
});
});
</script>
</body>
</html>
java后端
package com.xxxxx.platform.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* AJAX请求跨域
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
static final String ORIGINS[] = new String[]{"GET", "POST", "PUT", "DELETE"};
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**").allowedOriginPatterns("*").allowCredentials(false).allowedMethods(ORIGINS).maxAge(3600);
}
}
接口
@GetMapping("/stream-query")
public SseEmitter streamQuery(@RequestParam String question, HttpServletResponse response) {
// 手动设置 CORS 头
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Credentials", "false");
// 手动设置 CORS 响应头
return streamQueryKnowledgeBase2(question);
}
public SseEmitter streamQueryKnowledgeBase2(String question) {
SseEmitter emitter = new SseEmitter(60_000L);
CompletableFuture.runAsync(() -> {
try {
String url = "http://127.0.0.1:8000/api/knowledge/stream-query3";
WebClient client = WebClient.builder().baseUrl(url).build();
Map<String, String> requestBody = new HashMap<>();
requestBody.put("question", question);
log.info("Sending request to URL: {}, Body: {}", url, requestBody);
client.post()
.bodyValue(requestBody)
.retrieve()
.bodyToFlux(String.class)
.subscribe(
chunk -> {
try {
emitter.send(SseEmitter.event().data(chunk));
} catch (IOException e) {
emitter.completeWithError(e);
}
},
error -> {
log.error("Error during WebClient call: {}", error.getMessage());
emitter.completeWithError(error);
},
() -> emitter.complete()
);
} catch (Exception e) {
log.error("Exception occurred: {}", e.getMessage());
emitter.completeWithError(e);
}
});
return emitter;
}
python后端(stream-query3方法)
import os
os.environ["CHROMA_TELEMETRY"] = "FALSE"
from fastapi import FastAPI, HTTPException
from langchain_community.embeddings import OllamaEmbeddings
from langchain_chroma import Chroma
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain.chains import RetrievalQA
from langchain_community.llms import Ollama
from typing import List, Dict
import os
import traceback
import requests
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from langchain.callbacks.streaming_stdout import StreamingStdOutCallbackHandler
import logging
import asyncio
from pydantic import BaseModel
app = FastAPI(title="企业知识库AI服务")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 或指定你的前端地址如 ["http://localhost:8000"]
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 初始化Ollama模型和嵌入
llm = Ollama(model="qwen:7b-chat") # 替换为你的模型名称
embeddings = OllamaEmbeddings(model="qwen:7b-chat")
# 初始化向量数据库
def init_vector_db(docs_folder: str):
if not os.path.exists(docs_folder):
raise FileNotFoundError(f"指定的文件夹不存在: {docs_folder}")
# 检查chroma_db文件夹是否存在,如果不存在则创建
chroma_db_path = "./chroma_db"
if not os.path.exists(chroma_db_path):
os.makedirs(chroma_db_path)
loader = DirectoryLoader(
docs_folder,
glob="**/*.txt",
loader_cls=lambda path: TextLoader(path, encoding="utf-8") # 或 "gbk",“utf-8”
)
documents = loader.load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
texts = text_splitter.split_documents(documents)
vectordb = Chroma.from_documents(
documents=texts,
embedding=embeddings,
persist_directory=chroma_db_path
)
vectordb.persist()
return vectordb
# 初始化知识库(首次运行或更新文档时调用)
@app.post("/api/knowledge/init")
async def initialize_knowledge_base(docs_folder: str):
try:
vectordb = init_vector_db(docs_folder)
return {"status": "success", "message": "知识库初始化完成"}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
# 问答接口
@app.post("/api/knowledge/query")
async def query_knowledge_base(question: str):
try:
vectordb = Chroma(embedding_function=embeddings, persist_directory="./chroma_db")
retriever = vectordb.as_retriever()
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=retriever,
return_source_documents=True
)
result = qa_chain({"query": question})
return {
"answer": result["result"],
"sources": [doc.metadata for doc in result["source_documents"]]
}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
@app.post("/api/knowledge/stream-query")
async def stream_query_knowledge_base(question: str):
try:
vectordb = Chroma(embedding_function=embeddings, persist_directory="./chroma_db")
retriever = vectordb.as_retriever()
qa_chain = RetrievalQA.from_chain_type(
llm=llm, # 直接用全局llm
chain_type="stuff",
retriever=retriever
)
def event_stream():
# 这里直接同步返回结果(如需流式,需用requests对接Ollama HTTP接口)
result = qa_chain({"query": question})
yield result["result"].encode("utf-8")
return StreamingResponse(event_stream(), media_type="text/plain")
except Exception as e:
return StreamingResponse(iter([f"错误: {str(e)}".encode("utf-8")]), media_type="text/plain")
class QuestionRequest(BaseModel):
question: str
@app.post("/api/knowledge/stream-query3")
async def stream_query_knowledge_base3(request: QuestionRequest):
def event_stream():
# 1. 检索相关文档
vectordb = Chroma(embedding_function=embeddings, persist_directory="./chroma_db")
retriever = vectordb.as_retriever()
# 取前3个相关文档
docs = retriever.get_relevant_documents(request.question)
context = "\n".join([doc.page_content for doc in docs])
# 2. 构造带上下文的prompt
prompt = f"已知信息如下:\n{context}\n请根据上述内容回答:{request.question}"
# 3. 调用Ollama流式接口
url = "http://localhost:11434/api/generate"
payload = {
"model": "qwen:7b-chat",
"prompt": prompt,
"stream": True
}
with requests.post(url, json=payload, stream=True) as resp:
for line in resp.iter_lines():
if line:
try:
import json
data = json.loads(line.decode("utf-8"))
if "response" in data:
yield data["response"]
import time
time.sleep(0.05)
except Exception:
continue
return StreamingResponse(event_stream(), media_type="text/plain")
效果截图

小结
这边已经实现了较为简单的整个逻辑流程,但还有几个问题,如java那没有流式输出导致html每次都有连接已关闭,这是因为java没有按照标准的sse输出;模型结合向量数据库后会输出太慢,这个估计也有优化空间。

浙公网安备 33010602011771号