一、项目背景
在大模型时代,如何让 AI 基于特定领域的知识库准确回答问题,而不是"胡编乱造"?RAG(Retrieval-Augmented Generation,检索增强生成)是目前最主流的解决方案。本文将带你从零实现一个完整的 RAG 知识检索系统,以汽车参数知识库为场景,涵盖后端、前端、向量数据库的完整链路。
二、什么是 RAG?
RAG 的核心思想很简单:先检索,再生成。
用户提问 → Embedding 向量化 → 向量数据库检索相关文档 → 检索结果 + 问题送入 LLM → 生成精准回答
传统 LLM 只能依赖训练时的知识,而 RAG 让它能"查阅资料"后再回答,大幅减少幻觉,回答更准确、更可控。
三、技术选型
| 组件 | 选型 | 说明 |
|---|---|---|
| LLM | 通义千问(qwen-plus) | 阿里云大模型,兼容 OpenAI 接口 |
| Embedding | text-embedding-v2 | 通义千问向量模型,1536维 |
| 向量数据库 | FAISS | Facebook 开源,本地轻量,无需额外部署 |
| 关系数据库 | MySQL | 存储知识原文和元数据 |
| 后端框架 | Flask | Python 轻量 Web 框架 |
| 前端 | HTML + JavaScript | 简洁聊天界面 + 知识管理侧边栏 |
| AI 框架 | LangChain | LLM 应用开发框架,串联整个 RAG 链路 |
四、系统架构
┌─────────────┐ ┌──────────────────────────────────────────┐
│ 前端页面 │────→│ Flask 后端 │
│ 聊天 + 管理 │←────│ │
└─────────────┘ │ /api/chat → RAG 检索 + LLM 生成 │
│ /api/knowledge → 知识 CRUD │
│ /api/rebuild → 重建向量索引 │
└──────┬──────────────┬──────────────────────┘
│ │
┌──────▼──────┐ ┌─────▼──────┐
│ FAISS │ │ MySQL │
│ 向量索引 │ │ knowledge │
│ (本地文件) │ │ 表 │
└─────────────┘ └────────────┘
五、数据库设计
在 MySQL 中创建 knowledge 表,存储知识原文:
CREATE TABLE knowledge (
id INT AUTO_INCREMENT PRIMARY KEY,
question VARCHAR(500) NOT NULL COMMENT '问题或关键词描述',
answer TEXT NOT NULL COMMENT '知识内容/答案',
image_url VARCHAR(500) DEFAULT NULL COMMENT '图片链接',
category VARCHAR(100) DEFAULT '通用' COMMENT '分类',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
为什么不直接用 MySQL 做检索? MySQL 是关系型数据库,擅长精确匹配和范围查询,但不支持向量相似度搜索。所以用 MySQL 存原文,FAISS 存向量,两者配合使用。
六、RAG 核心实现
6.1 Embedding 与 LLM 初始化
通过 LangChain 的 OpenAIEmbeddings 和 ChatOpenAI,配合 DashScope 兼容接口:
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
def get_embeddings():
return OpenAIEmbeddings(
model="text-embedding-v2",
openai_api_key=os.getenv("DASHSCOPE_API_KEY"),
openai_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
check_embedding_ctx_length=False, # 兼容 DashScope
)
def get_llm():
return ChatOpenAI(
model="qwen-plus",
openai_api_key=os.getenv("DASHSCOPE_API_KEY"),
openai_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
6.2 从 MySQL 加载知识 → 构建向量索引
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
def load_knowledge_from_db():
"""从 MySQL 加载知识,转为 LangChain Document"""
conn = pymysql.connect(**DB_CONFIG)
cursor = conn.cursor()
cursor.execute("SELECT id, question, answer, image_url, category FROM knowledge")
documents = []
for doc_id, question, answer, image_url, category in cursor.fetchall():
content = f"问题: {question}\n答案: {answer}"
documents.append(Document(
page_content=content,
metadata={"id": doc_id, "category": category, "image_url": image_url or ""}
))
conn.close()
return documents
def build_vector_store():
"""构建 FAISS 向量索引"""
documents = load_knowledge_from_db()
embeddings = get_embeddings()
vectorstore = FAISS.from_documents(documents, embeddings)
vectorstore.save_local("faiss_index") # 持久化到本地
return vectorstore
关键点: 每条知识的 page_content 包含问题和答案全文,metadata 存储图片等附加信息。FAISS 会将 page_content 转为向量,检索时基于向量相似度匹配。
6.3 RAG 检索 + 生成
这是整个系统的核心流程:
def rag_query(user_question, top_k=3):
# 1. 加载向量索引
vectorstore = FAISS.load_local("faiss_index", get_embeddings(),
allow_dangerous_deserialization=True)
# 2. 检索最相关的 top_k 条知识
retriever = vectorstore.as_retriever(search_kwargs={"k": top_k})
docs = retriever.invoke(user_question)
# 3. 收集图片
images = [doc.metadata["image_url"] for doc in docs if doc.metadata.get("image_url")]
# 4. 拼接上下文,送入 LLM
context = "\n\n".join([doc.page_content for doc in docs])
prompt = f"""你是知识问答助手。根据以下知识库内容回答用户问题。
知识库内容:
{context}
用户问题:{user_question}
请基于知识库内容回答:"""
response = get_llm().invoke(prompt)
return {"reply": response.content, "images": images}
七、Flask 后端接口
@app.route("/api/chat", methods=["POST"])
def chat():
data = request.json
use_rag = data.get("use_rag", True)
if use_rag:
result = rag_query(data["message"])
return jsonify({
"reply": result["reply"],
"images": result["images"],
"session_id": session_id,
})
else:
# 普通对话模式(不检索知识库)
...
# ==================== 知识库管理 API ====================
@app.route("/api/knowledge", methods=["GET"])
def get_knowledge():
"""获取知识库列表"""
try:
knowledge_list = list_knowledge()
return jsonify({"code": 200, "data": knowledge_list})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)})
@app.route("/api/knowledge", methods=["POST"])
def create_knowledge():
"""新增知识"""
data = request.json
question = data.get("question", "").strip()
answer = data.get("answer", "").strip()
category = data.get("category", "通用").strip()
if not question or not answer:
return jsonify({"code": 400, "msg": "问题和答案不能为空"})
try:
add_knowledge(question, answer, category)
return jsonify({"code": 200, "msg": "添加成功"})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)})
@app.route("/api/knowledge/<int:knowledge_id>", methods=["DELETE"])
def remove_knowledge(knowledge_id):
"""删除知识"""
try:
delete_knowledge(knowledge_id)
return jsonify({"code": 200, "msg": "删除成功"})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)})
@app.route("/api/rebuild-index", methods=["POST"])
def rebuild_index():
"""手动重建向量索引"""
try:
build_vector_store()
return jsonify({"code": 200, "msg": "索引重建成功"})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)})
if __name__ == "__main__":
# 启动时自动构建向量索引
print("正在构建向量索引...")
build_vector_store()
print("聊天机器人服务启动: http://127.0.0.1:5000")
app.run(debug=True, port=5000)
同时提供知识管理接口:
| 接口 | 方法 | 功能 |
|---|---|---|
/api/knowledge |
GET | 获取知识列表 |
/api/knowledge |
POST | 新增知识(自动更新向量索引) |
/api/knowledge/<id> |
DELETE | 删除知识(自动重建索引) |
/api/rebuild-index |
POST | 手动重建向量索引 |
八、前端实现
前端采用纯 HTML + JavaScript,主要功能:
-
聊天界面:支持 Markdown 渲染、加载动画、Enter 发送
-
知识检索开关:顶部 checkbox 控制 RAG / 普通对话模式切换
-
知识管理侧边栏:新增/查看/删除知识条目
-
图片展示:AI 回答下方自动展示检索到的知识配图
// 发送消息并处理含图片的响应
async function sendMessage() {
const res = await axios.post('/api/chat', {
message, session_id: sessionId, use_rag: ragToggle.checked
});
addMessage('ai', res.data.reply, res.data.images || []);
}function addMessage(role, content, images = []) {
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>RAG 知识检索助手</title> <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <style> * { margin: 0; padding: 0; box-sizing: border-box; }
// 渲染文本 + 图片
bubble.innerHTML = marked.parse(content);
if (images.length > 0) {
const imgBox = document.createElement('div');
imgBox.className = 'bubble-images';
images.forEach(url => {
const img = document.createElement('img');
img.src = url;
imgBox.appendChild(img);
});
bubble.appendChild(imgBox);
}
}</head> <body>body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f0f2f5; height: 100vh; display: flex; flex-direction: column; } /* 顶部栏 */ .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 2px 8px rgba(0,0,0,0.15); } .header h1 { font-size: 20px; font-weight: 600; } .header-actions { display: flex; gap: 8px; align-items: center; } .header-actions label { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; background: rgba(255,255,255,0.15); padding: 6px 12px; border-radius: 6px; } .header-actions label input { accent-color: #764ba2; } .header button { background: rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.4); color: #fff; padding: 6px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: background 0.2s; } .header button:hover { background: rgba(255,255,255,0.35); } /* 主体布局 */ .main-layout { flex: 1; display: flex; overflow: hidden; } /* 聊天区域 */ .chat-area { flex: 1; display: flex; flex-direction: column; } .chat-container { flex: 1; overflow-y: auto; padding: 20px; max-width: 800px; width: 100%; margin: 0 auto; } .message { display: flex; margin-bottom: 16px; animation: fadeIn 0.3s ease; } @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } .message.user { justify-content: flex-end; } .message.ai { justify-content: flex-start; } .avatar { width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; } .message.user .avatar { background: #667eea; color: #fff; margin-left: 10px; order: 2; } .message.ai .avatar { background: #e8e8e8; color: #555; margin-right: 10px; } .bubble { max-width: 70%; padding: 12px 16px; border-radius: 12px; line-height: 1.6; font-size: 15px; word-break: break-word; } .message.user .bubble { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; border-bottom-right-radius: 4px; } .message.ai .bubble { background: #fff; color: #333; border-bottom-left-radius: 4px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); } .message.ai .bubble p { margin: 0.4em 0; } .message.ai .bubble pre { background: #1e1e1e; color: #d4d4d4; padding: 12px; border-radius: 6px; overflow-x: auto; margin: 8px 0; font-size: 13px; } .message.ai .bubble code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; } .message.ai .bubble pre code { background: none; padding: 0; } /* 加载动画 */ .typing-indicator { display: flex; gap: 4px; padding: 4px 0; } .typing-indicator span { width: 8px; height: 8px; background: #999; border-radius: 50%; animation: bounce 1.4s infinite; } .typing-indicator span:nth-child(2) { animation-delay: 0.2s; } .typing-indicator span:nth-child(3) { animation-delay: 0.4s; } @keyframes bounce { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-6px); } } /* 输入区域 */ .input-area { background: #fff; padding: 16px 20px; border-top: 1px solid #e0e0e0; box-shadow: 0 -2px 8px rgba(0,0,0,0.05); } .input-wrapper { max-width: 800px; margin: 0 auto; display: flex; gap: 12px; } .input-wrapper textarea { flex: 1; padding: 12px 16px; border: 1px solid #ddd; border-radius: 10px; font-size: 15px; resize: none; outline: none; font-family: inherit; transition: border-color 0.2s; min-height: 46px; max-height: 120px; } .input-wrapper textarea:focus { border-color: #667eea; } .input-wrapper button { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; border: none; padding: 12px 24px; border-radius: 10px; font-size: 15px; cursor: pointer; transition: opacity 0.2s; white-space: nowrap; } .input-wrapper button:hover { opacity: 0.9; } .input-wrapper button:disabled { opacity: 0.5; cursor: not-allowed; } /* ========== 知识库侧边栏 ========== */ .sidebar-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 100; } .sidebar-overlay.open { display: block; } .sidebar { position: fixed; top: 0; right: -480px; width: 460px; height: 100vh; background: #fff; box-shadow: -4px 0 20px rgba(0,0,0,0.1); z-index: 101; display: flex; flex-direction: column; transition: right 0.3s ease; } .sidebar.open { right: 0; } .sidebar-header { padding: 16px 20px; border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; justify-content: space-between; } .sidebar-header h2 { font-size: 18px; } .sidebar-header button { background: none; border: none; font-size: 22px; cursor: pointer; color: #999; padding: 4px 8px; } .sidebar-header button:hover { color: #333; } .sidebar-body { flex: 1; overflow-y: auto; padding: 16px 20px; } /* 添加知识表单 */ .add-form { background: #f8f9fa; border-radius: 10px; padding: 16px; margin-bottom: 20px; } .add-form h3 { font-size: 15px; margin-bottom: 12px; color: #555; } .add-form input, .add-form textarea, .add-form select { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; font-family: inherit; margin-bottom: 10px; outline: none; } .add-form input:focus, .add-form textarea:focus { border-color: #667eea; } .add-form textarea { resize: vertical; min-height: 60px; } .add-form .form-actions { display: flex; gap: 8px; } .add-form .btn-add { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; border: none; padding: 8px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; } .add-form .btn-add:hover { opacity: 0.9; } /* 知识列表 */ .knowledge-item { background: #fff; border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-bottom: 10px; } .knowledge-item .ki-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; } .knowledge-item .ki-category { font-size: 12px; background: #667eea; color: #fff; padding: 2px 8px; border-radius: 10px; } .knowledge-item .ki-delete { background: none; border: none; color: #e74c3c; cursor: pointer; font-size: 13px; } .knowledge-item .ki-delete:hover { text-decoration: underline; } .knowledge-item .ki-question { font-size: 14px; font-weight: 600; color: #333; margin-bottom: 4px; } .knowledge-item .ki-answer { font-size: 13px; color: #666; line-height: 1.5; max-height: 60px; overflow: hidden; } .empty-tip { text-align: center; color: #999; padding: 40px 0; font-size: 14px; } /* 回答中的图片 */ .bubble-images { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } .bubble-images img { max-width: 280px; max-height: 200px; border-radius: 8px; object-fit: cover; cursor: pointer; transition: transform 0.2s; } .bubble-images img:hover { transform: scale(1.03); } </style>RAG 知识检索助手
<label> 知识检索 </label> <button onclick="toggleSidebar()">知识库管理</button> <button onclick="newSession()">新对话</button>AI你好!我是RAG知识检索助手。你可以向我提问,我会从知识库中检索相关信息并回答。点击右上角「知识库管理」可以管理知识数据。<div class="input-area"> <div class="input-wrapper"> <textarea id="userInput" placeholder="输入消息,按 Enter 发送..." rows="1" oninput="autoResize(this)"></textarea> <button id="sendBtn" onclick="sendMessage()">发送</button> </div> </div> </div>知识库管理
<button onclick="toggleSidebar()">×</button><script> let sessionId = null; const container = document.getElementById('chatContainer'); const userInput = document.getElementById('userInput'); const sendBtn = document.getElementById('sendBtn'); const ragToggle = document.getElementById('ragToggle');添加新知识
<textarea id="addAnswer" placeholder="知识内容 / 答案" rows="3"></textarea><button class="btn-add" onclick="submitKnowledge()">添加</button><!-- 知识列表 --> <div id="knowledgeList"></div> </div></script> </body> </html>// Enter 发送 userInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); function autoResize(el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 120) + 'px'; } function scrollToBottom() { container.scrollTop = container.scrollHeight; } function addMessage(role, content, images = []) { const div = document.createElement('div'); div.className = `message ${role}`; const avatar = document.createElement('div'); avatar.className = 'avatar'; avatar.textContent = role === 'user' ? '我' : 'AI'; const bubble = document.createElement('div'); bubble.className = 'bubble'; bubble.innerHTML = role === 'ai' ? marked.parse(content) : content; // 如果有图片,追加到气泡下方 if (images && images.length > 0) { const imgBox = document.createElement('div'); imgBox.className = 'bubble-images'; images.forEach(url => { const img = document.createElement('img'); img.src = url; img.alt = '知识配图'; img.onerror = function() { this.style.display = 'none'; }; imgBox.appendChild(img); }); bubble.appendChild(imgBox); } div.appendChild(avatar); div.appendChild(bubble); container.appendChild(div); scrollToBottom(); return bubble; } function addTypingIndicator() { const div = document.createElement('div'); div.className = 'message ai'; div.id = 'typing'; div.innerHTML = ` <div class="avatar">AI</div> <div class="bubble"><div class="typing-indicator"><span></span><span></span><span></span></div></div> `; container.appendChild(div); scrollToBottom(); } function removeTypingIndicator() { const el = document.getElementById('typing'); if (el) el.remove(); } async function sendMessage() { const message = userInput.value.trim(); if (!message) return; userInput.value = ''; userInput.style.height = 'auto'; sendBtn.disabled = true; const useRag = ragToggle.checked; addMessage('user', message); addTypingIndicator(); try { const res = await axios.post('/api/chat', { message, session_id: sessionId, use_rag: useRag }); removeTypingIndicator(); sessionId = res.data.session_id; addMessage('ai', res.data.reply, res.data.images || []); } catch (err) { removeTypingIndicator(); const errMsg = err.response?.data?.error || '请求失败,请重试'; addMessage('ai', `出错了: ${errMsg}`); } finally { sendBtn.disabled = false; userInput.focus(); } } async function newSession() { try { const res = await axios.post('/api/new-session'); sessionId = res.data.session_id; container.innerHTML = ''; const mode = ragToggle.checked ? '知识检索' : '普通对话'; addMessage('ai', `新对话已开始(当前模式:${mode})。有什么可以帮你的吗?`); } catch (err) { alert('创建新对话失败'); } } // ========== 知识库管理 ========== function toggleSidebar() { document.getElementById('sidebar').classList.toggle('open'); document.getElementById('sidebarOverlay').classList.toggle('open'); loadKnowledgeList(); } async function loadKnowledgeList() { try { const res = await axios.get('/api/knowledge'); const list = res.data.data || []; const container = document.getElementById('knowledgeList'); if (list.length === 0) { container.innerHTML = '<div class="empty-tip">暂无知识数据</div>'; return; } container.innerHTML = list.map(item => ` <div class="knowledge-item"> <div class="ki-header"> <span class="ki-category">${item.category}</span> <button class="ki-delete" onclick="deleteKnowledge(${item.id})">删除</button> </div> <div class="ki-question">${item.question}</div> <div class="ki-answer">${item.answer}</div> </div> `).join(''); } catch (err) { console.error('加载知识列表失败', err); } } async function submitKnowledge() { const question = document.getElementById('addQuestion').value.trim(); const answer = document.getElementById('addAnswer').value.trim(); const category = document.getElementById('addCategory').value.trim(); if (!question || !answer) { alert('问题和答案不能为空'); return; } try { await axios.post('/api/knowledge', { question, answer, category }); document.getElementById('addQuestion').value = ''; document.getElementById('addAnswer').value = ''; alert('添加成功,向量索引已更新'); loadKnowledgeList(); } catch (err) { alert('添加失败: ' + (err.response?.data?.msg || err.message)); } } async function deleteKnowledge(id) { if (!confirm('确定删除该条知识吗?')) return; try { await axios.delete(`/api/knowledge/${id}`); alert('删除成功'); loadKnowledgeList(); } catch (err) { alert('删除失败'); } } // 页面加载时创建会话 newSession();九、完整数据流转示例
以"小鹏P7的排量是多少"为例:
1. 用户输入 → "小鹏P7的排量是多少" 2. Embedding → 将问题转为 1536 维向量 3. FAISS 检索 → 找到最相似的文档:小鹏P7的基本参数和性能 4. 上下文拼接 → 将知识内容注入 Prompt 5. LLM 生成 → "小鹏P7为纯电动车型,无发动机排量。后驱版电机最大功率203kW..." 6. 图片返回 → 附带小鹏P7的配图 7. 前端渲染 → 文本 + 图片一起展示数据库

实现效果