问答系统需求文档
一、项目概述
本项目旨在开发一个能够上传 PDF 文件,并基于 PDF 内容进行问答互动的系统。用户可以上传 PDF 文件,系统将解析 PDF 内容,并允许用户通过对话框进行问答互动,获取有关 PDF 文件内容的信息。
二、功能需求
2.1 用户上传 PDF
- 功能描述:用户可以通过文件选择框上传一个 PDF 文件。
- 前端需求 :
- 提供文件选择框。
- 显示文件上传进度。
- 上传成功后显示文件名和上传成功提示。
- 后端需求 :
- 接收并保存用户上传的 PDF 文件。
- 确保上传的文件格式正确(仅支持 PDF)。
- 限制文件大小(如最大 50 MB)。
2.2 PDF 内容解析
- 功能描述:系统解析上传的 PDF 文件内容,将其转换为可处理的文本格式。
- 前端需求 :
- 显示解析进度。
- 提示用户解析成功或失败。
- 后端需求 :
- 使用 PDF 解析库(如 PyMuPDF、pdfminer)提取 PDF 文本内容。
- 处理解析错误并返回相应提示。
2.3 用户问答交互
- 功能描述:用户可以在对话框中输入问题,系统基于解析的 PDF 内容回答问题。
- 前端需求 :
- 提供输入框供用户输入问题。
- 显示用户问题和系统回答。
- 后端需求 :
- 基于解析的 PDF 内容构建问答模型(如使用 NLP 模型)。
- 处理用户问题并生成答案。
- 返回答案给前端显示。
三、接口设计
5.1 上传 PDF 文件接口
- URL :
/upload
- 方法:POST
- 请求参数 :
file
:用户上传的 PDF 文件
- 响应参数 :
- 成功:
{ "status": "success", "message": "File uploaded successfully.", "file_id": "unique_file_id" }
- 失败:
{ "status": "error", "message": "File upload failed." }
- 成功:
5.2 提取 PDF 内容接口
- URL :
/parse
- 方法:POST
- 请求参数 :
file_id
:已上传文件的唯一标识符
- 响应参数 :
- 成功:
{ "status": "success", "message": "File parsed successfully.", "content": "parsed_content" }
- 失败:
{ "status": "error", "message": "File parsing failed." }
- 成功:
5.3 问答接口
- URL :
/ask
- 方法:POST
- 请求参数 :
file_id
:已上传文件的唯一标识符question
:用户输入的问题
- 响应参数 :
- 成功:
{ "status": "success", "answer": "answer_to_question" }
- 失败:
{ "status": "error", "message": "Unable to retrieve answer." }
- 成功:
技术实现
系统架构
- 前端
- 文件上传界面
- 问答交互界面
- 后端
- 文件接收与存储模块
- PDF 内容解析模块
- 问答处理模块(基于 LangChain)
- 数据库
- 存储上传文件信息和解析内容
技术栈
- 前端
- HTML/CSS/JavaScript
- Vue.js
- 后端
- 编程语言:Python
- 框架:Flask
- PDF 解析库:PyMuPDF、pdfminer
- 问答引擎:LangChain
- 数据库
- SQLite
前端实现
安装 Vue CLI
bash
npm install -g @vue/cli
vue create pdf-qa-frontend
cd pdf-qa-frontend
创建组件
在 src/components
目录下创建 FileUpload.vue
和 QuestionAnswer.vue
。
FileUpload.vue
html
<template>
<div class="upload-container">
<input type="file" @change="onFileChange" class="file-input" accept=".pdf,.md"/>
<button @click="uploadFile" class="upload-button">Upload</button>
<p v-if="message" class="upload-message">{{ message }}</p>
<div v-if="uploadProgress > 0" class="progress-container">
<div class="progress-bar" :style="{ width: uploadProgress + '%' }"></div>
<p>{{ uploadProgress }}%</p>
</div>
</div>
</template>
<script>
export default {
name: 'FileUpload',
data() {
return {
file: null,
message: '',
uploadProgress: 0
};
},
methods: {
onFileChange(event) {
this.file = event.target.files[0];
},
uploadFile() {
if (!this.file) {
this.message = 'Please select a file first.';
return;
}
let formData = new FormData();
formData.append('file', this.file);
let xhr = new XMLHttpRequest();
xhr.open('POST', 'http://localhost:5000/upload', true);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
this.uploadProgress = Math.round((event.loaded / event.total) * 100);
}
};
xhr.onload = () => {
if (xhr.status === 200) {
let response = JSON.parse(xhr.responseText);
this.message = response.message;
this.uploadProgress = 0;
} else {
this.message = 'Error uploading file.';
this.uploadProgress = 0;
}
};
xhr.onerror = () => {
this.message = 'Error uploading file.';
this.uploadProgress = 0;
};
xhr.send(formData);
}
}
};
</script>
<style scoped>
.upload-container {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20px;
}
.file-input {
margin-bottom: 10px;
}
.upload-button {
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.upload-button:hover {
background-color: #0056b3;
}
.upload-message {
margin-top: 10px;
color: #28a745;
}
.progress-container {
width: 100%;
max-width: 600px;
border: 1px solid #ccc;
border-radius: 4px;
overflow: hidden;
margin-top: 10px;
position: relative;
}
.progress-bar {
height: 20px;
background-color: #28a745;
transition: width 0.4s ease;
}
.progress-container p {
position: absolute;
width: 100%;
text-align: center;
margin: 0;
line-height: 20px;
color: white;
font-weight: bold;
}
</style>
QuestionAnswer.vue
html
<template>
<div class="qa-container">
<div class="input-container">
<input
type="text"
v-model="question"
placeholder="Ask a question..."
@keyup.enter="askQuestion"
class="question-input"
/>
<button @click="askQuestion" class="ask-button">Ask</button>
</div>
<div class="history-container" v-if="dialogHistory.length">
<div class="dialog" v-for="(dialog, index) in dialogHistory" :key="index">
<p><strong>You:</strong> {{ dialog.question }}</p>
<p><strong>Bot:</strong> {{ dialog.answer }}</p>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'QuestionAnswer',
data() {
return {
question: '',
answer: '',
dialogHistory: [],
fileId: 'your-file-id' // Replace with actual file ID after upload
};
},
methods: {
async askQuestion() {
if (!this.question) {
return;
}
try {
let response = await fetch('http://localhost:5000/ask', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
question: this.question,
file_id: this.fileId
})
});
let result = await response.json();
this.answer = result.answer;
this.dialogHistory.push({
question: this.question,
answer: this.answer
});
this.question = '';
} catch (error) {
console.error('Error asking question:', error);
}
}
}
};
</script>
<style scoped>
.qa-container {
display: flex;
flex-direction: column;
align-items: center;
}
.input-container {
display: flex;
width: 100%;
max-width: 600px;
margin-bottom: 20px;
}
.question-input {
flex: 1;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px 0 0 4px;
font-size: 16px;
}
.ask-button {
padding: 10px 20px;
background-color: #28a745;
color: white;
border: none;
border-radius: 0 4px 4px 0;
cursor: pointer;
}
.ask-button:hover {
background-color: #218838;
}
.history-container {
width: 100%;
max-width: 600px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 10px;
background-color: #f9f9f9;
}
.dialog {
margin-bottom: 10px;
}
.dialog p {
margin: 5px 0;
}
</style>
App.vue
xml
<template>
<div id="app" class="app-container">
<FileUpload />
<QuestionAnswer />
</div>
</template>
<script>
import FileUpload from './components/FileUpload.vue';
import QuestionAnswer from './components/QuestionAnswer.vue';
export default {
name: 'App',
components: {
FileUpload,
QuestionAnswer
}
};
</script>
<style>
.app-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
</style>
后端实现
安装 Flask 及相关依赖
bash
pip install Flask flask-cors PyMuPDF pdfminer.six semantic-kernel
创建 Flask 应用
在项目根目录下创建 app.py
。确保后端 Flask 代码可以正确处理并解析 MD 文件:
python
import time
import markdown
from flask import Flask, request, jsonify
from flask_cors import CORS
import fitz # PyMuPDF
import sqlite3
import os
# 加载 .env 到环境变量
from dotenv import load_dotenv, find_dotenv
_ = load_dotenv(find_dotenv())
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY')
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import FAISS
from langchain_openai import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain_community.document_loaders import PyPDFLoader
app = Flask(__name__)
CORS(app)
UPLOAD_FOLDER = 'uploads/'
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return jsonify({'status': 'error', 'message': 'No file part'})
file = request.files['file']
if file.filename == '':
return jsonify({'status': 'error', 'message': 'No selected file'})
if file:
filename = file.filename
file_id = filename # In a real app, use a unique identifier
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(file_path)
if filename.endswith('.pdf'):
content = parse_pdf(file_path)
elif filename.endswith('.md'):
content = parse_md(file_path)
else:
return jsonify({'status': 'error', 'message': 'Unsupported file type'})
save_file_to_db(file_id, filename, content)
return jsonify({'status': 'success', 'message': 'File uploaded and parsed successfully', 'file_id': file_id})
def parse_pdf(file_path):
doc = fitz.open(file_path)
text = ""
for page_num in range(len(doc)):
page = doc.load_page(page_num)
text += page.get_text()
return text
def parse_md(file_path):
with open(file_path, 'r', encoding='utf-8') as file:
text = file.read()
return markdown.markdown(text)
# Prompt模板
template = """Answer the question based only on the following context:
{context}
Question: {question}
"""
prompt = ChatPromptTemplate.from_template(template)
# 模型
model = ChatOpenAI(model="gpt-4-turbo", temperature=0)
from langchain.schema.output_parser import StrOutputParser
from langchain.schema.runnable import RunnablePassthrough
rag_chain = ()
import tempfile
def save_file_to_db(file_id, filename, content):
# 加载文档
# relative_temp_file_path = os.path.relpath(f"./uploads/{filename}")
# print(relative_temp_file_path)
loader = PyPDFLoader(f"./uploads/{filename}")
pages = loader.load_and_split()
# 文档切分
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=200,
chunk_overlap=100,
length_function=len,
add_start_index=True,
)
texts = text_splitter.create_documents(
[page.page_content for page in pages[:4]]
)
# 灌库
embeddings = OpenAIEmbeddings(model="text-embedding-ada-002")
db = FAISS.from_documents(texts, embeddings)
# 检索 top-1 结果
retriever = db.as_retriever(search_kwargs={"k": 5})
global rag_chain
# Chain
rag_chain = (
{"question": RunnablePassthrough(), "context": retriever}
| prompt
| model
| StrOutputParser()
)
@app.route('/ask', methods=['POST'])
def ask_question():
data = request.json
file_id = data.get('file_id')
question = data.get('question')
print("file_id:", file_id, "question:", question)
content = get_file_content_from_db(file_id, question)
print("content:", str(content))
return jsonify({'status': 'success', 'answer': str(content)})
def get_file_content_from_db(file_id, question):
result = rag_chain.invoke(question)
return result
if __name__ == '__main__':
app.run(debug=True)
运行项目
部署与运行
-
前端
-
运行开发服务器
bashnpm run serve
-
-
后端
-
运行 Flask 应用
bashpython app.py
-
实现效果
选择md文件
上传成功
问答
- Llama 2有多少参数
- Llama2Chat有哪些模型参数
- TrainingDetails在第几页
回答基于上传的LIama2.pdf
文档。