AI应用基础篇02-RAG知识库实战:从零搭建“十五五”规划智能问答系统
一台32GB内存的笔记本,三个公开政策文件,从索引构建到跨文件均衡检索,完整记录了一个传统IT人攻克RAG工程化难题的全过程。
作者:Javy21(javy21@csdn)
博客主页:javy21-CSDN博客
专栏:《老攻城狮的AI编程实践之路》
个人标签:传统企业AI转型实践者
【前置】老攻城狮的AI开发环境搭建全记录:从零到跑通本地大模型(一日速通版)
一、为什么要做这个案例?
在之前的专栏文章中,我已经完成了本地AI聊天机器人和以文搜图系统的搭建。这次的目标更进一步——构建一个真正的RAG(检索增强生成)知识库问答系统。
RAG是目前企业级AI应用中最核心的技术模式,它解决了大模型的两个致命问题:
-
幻觉问题:模型凭空捏造不存在的信息
-
知识时效性:模型训练数据截止日期之后的事件一概不知
本次案例的素材:我选择了三个公开发布的“十五五”规划相关文件:
-
《中华人民共和国国民经济和社会发展第十五个五年规划纲要》(约140页,总纲)
-
《新型能源体系建设“十五五”规划》(专项规划)
-
《国家发展改革委 国家能源局关于促进电网高质量发展的指导意见》(部门规范性文件)
这三个文件的组合非常典型——总纲+细则+指导意见,完整模拟了企业知识库中“顶层文档+执行细则”的常见结构。
为什么选这个素材:公开文件无版权风险,内容结构规范,读者有共鸣,且能真实暴露RAG系统在“总纲淹没细则”场景下的技术挑战。
二、技术方案概览
2.1 技术选型
| 组件 | 选型 | 理由 |
|---|---|---|
| 文档解析 | LangChain PyPDFLoader | 统一接口,支持页码提取 |
| 文本切分 | RecursiveCharacterTextSplitter | 按段落/句子智能切分,保持语义完整 |
| 向量化模型 | all-MiniLM-L6-v2 | 轻量(384维),CPU友好,中文支持尚可 |
| 向量数据库 | Chroma | 轻量,Python集成简单,已用于之前案例 |
| 大模型 | Qwen2.5:7B | 已安装运行稳定,推理能力足够,支持中文 |
| Web框架 | Flask | 轻量,无需学习新框架 |
| 编排框架 | LCEL(LangChain Expression Language) | 官方推荐,灵活可控 |
2.2 系统架构图

三、完整代码实现
3.1 项目结构
~/ai_project/knowledge_base/
├── docs/ # 文档存放目录
│ ├── 中华人民共和国国民经济和社会发展第十五个五年规划纲要.pdf
│ ├── 新型能源体系建设“十五五”规划.pdf
│ └── 国家发展改革委+国家能源局关于促进电网高质量发展的指导意见+发改能源〔2025〕1710号.pdf
├── chroma_db/ # Chroma向量库持久化目录(自动生成)
├── index_docs.py # 索引脚本
├── rag_chat.py # RAG问答服务主程序
├── templates/
│ └── rag.html # 前端页面
└── requirements.txt # 依赖清单
3.2 索引脚本 index_docs.py
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
RAG知识库索引脚本
功能:将指定目录下的PDF文档切分、向量化并存入Chroma向量数据库
作者:Javy21 (https://blog.csdn.net/javy21)
环境:WSL2 Ubuntu 22.04 / Python 3.10 / Chroma 0.5.x
索引流程:
1. 加载PDF文档(同时提取页码信息)
2. 按语义边界切分文本(chunk_size=1024, overlap=150)
3. 用all-MiniLM-L6-v2模型生成向量(384维)
4. 存入Chroma(使用余弦相似度)
注意事项:
- 首次运行会自动下载向量化模型(约80MB)
- 如需重新索引,建议先删除chroma_db目录
"""
import os
# 设置Hugging Face镜像源(国内加速下载模型)
os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'
import glob
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import Chroma
# ---------- 配置参数 ----------
DOCS_FOLDER = "./docs" # 文档存放目录
CHROMA_PATH = "./chroma_db" # Chroma持久化路径
COLLECTION_NAME = "knowledge_base" # 集合名称
CHUNK_SIZE = 1024 # 每个文本块的大小(字符数)
CHUNK_OVERLAP = 150 # 文本块之间的重叠字符数
# ---------- 初始化 ----------
print("=" * 60)
print("RAG知识库索引工具 v1.0")
print(f"文档目录: {DOCS_FOLDER}")
print(f"输出路径: {CHROMA_PATH}")
print("=" * 60)
print("\n⏳ 加载向量化模型...")
embeddings = HuggingFaceEmbeddings(
model_name="all-MiniLM-L6-v2", # 轻量级中文友好模型
model_kwargs={'device': 'cpu'}, # CPU推理(无需GPU)
encode_kwargs={'normalize_embeddings': True} # 归一化向量,便于余弦相似度计算
)
print("✅ 模型加载完成")
print("\n⏳ 准备Chroma数据库...")
client = chromadb.PersistentClient(path=CHROMA_PATH)
# 删除旧集合(确保完全重建,避免配置不一致)
try:
client.delete_collection(COLLECTION_NAME)
print(" 🗑️ 删除旧集合")
except:
print(" ℹ️ 无旧集合需要删除")
# 创建新集合,指定使用余弦相似度
collection = client.create_collection(
name=COLLECTION_NAME,
metadata={"hnsw:space": "cosine"} # HNSW索引使用余弦距离
)
print("✅ 新建集合")
# ---------- 加载文档 ----------
print(f"\n📄 扫描文档目录: {os.path.abspath(DOCS_FOLDER)}")
# 只处理PDF文件(可根据需要扩展)
all_files = glob.glob(os.path.join(DOCS_FOLDER, "*.pdf"))
print(f"📄 找到 {len(all_files)} 个PDF文件:")
for f in all_files:
print(f" - {os.path.basename(f)}")
if not all_files:
print("❌ 未找到任何PDF文件!请检查docs目录")
exit(1)
# 逐个加载文档,提取页码信息
documents = []
for file_path in all_files:
file_name = os.path.basename(file_path)
print(f"\n⏳ 加载: {file_name}")
try:
loader = PyPDFLoader(file_path)
docs = loader.load()
# 为每个片段添加元数据
for doc in docs:
# PyPDFLoader的page从0开始,转为从1开始显示
page_num = doc.metadata.get('page', 0)
doc.metadata['page'] = page_num + 1 if page_num is not None else 0
doc.metadata['source'] = file_name
documents.append(doc)
print(f" ✅ 成功加载 {len(docs)} 页")
except Exception as e:
print(f" ❌ 加载失败: {e}")
print(f"\n✅ 共加载 {len(documents)} 个文档片段")
# ---------- 文本切分 ----------
print("\n✂️ 切分文档...")
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE,
chunk_overlap=CHUNK_OVERLAP,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
length_function=len,
)
chunks = text_splitter.split_documents(documents)
print(f"✅ 切分为 {len(chunks)} 个片段")
# ---------- 向量化并入库 ----------
print("\n💾 生成向量并存入Chroma...")
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory=CHROMA_PATH,
collection_name=COLLECTION_NAME,
)
print(f"\n✅ 索引完成!")
print(f" - 总片段数: {len(chunks)}")
print(f" - 数据库路径: {os.path.abspath(CHROMA_PATH)}")
# 打印文件统计信息
print("\n📊 文件统计:")
result = vectorstore.get(include=['metadatas'])
source_counts = {}
for meta in result['metadatas']:
src = meta.get('source', 'unknown')
source_counts[src] = source_counts.get(src, 0) + 1
for src, count in source_counts.items():
print(f" - {src}: {count} 个片段")
3.3 搜索服务 rag_chat.py
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
RAG知识库问答服务
功能:基于"十五五"规划相关文档的智能问答系统
作者:Javy21 (https://blog.csdn.net/javy21)
环境:WSL2 Ubuntu 22.04 / Python 3.10 / Chroma 0.5.x
核心特性:
1. 跨文件均衡检索:确保每个文档都有片段被选中,避免总纲淹没细则
2. 查询扩展:同义词扩展提升召回率
3. 引用溯源:显示答案来源(文件名+页码)
4. LCEL链路:基于LangChain Expression Language构建
5. 内存优化:限制片段长度和生成长度,适配32GB内存环境
"""
import os
# 设置Hugging Face镜像源(国内加速)
os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'
from flask import Flask, render_template, request, jsonify
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_community.llms import Ollama
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from collections import defaultdict
# ---------- 配置参数 ----------
CHROMA_PATH = "./chroma_db" # Chroma数据库路径
COLLECTION_NAME = "knowledge_base" # 集合名称
MODEL_NAME = "qwen2.5:7b" # 大模型名称
OLLAMA_BASE_URL = "http://192.168.0.106:11434" # Ollama服务地址(根据实际环境修改)
K_PER_FILE = 3 # 每个文件检索的片段数
CANDIDATE_POOL = 30 # 每个文件检索时的候选池大小
# ---------- Flask应用初始化 ----------
app = Flask(__name__)
print("=" * 60)
print("RAG知识库问答服务 v3.8")
print("作者: Javy21 (https://blog.csdn.net/javy21)")
print("=" * 60)
# ---------- 加载向量化模型 ----------
print("\n⏳ 加载向量化模型...")
embeddings = HuggingFaceEmbeddings(
model_name="all-MiniLM-L6-v2",
model_kwargs={'device': 'cpu'},
encode_kwargs={'normalize_embeddings': True}
)
print("✅ 向量化模型加载完成")
# ---------- 连接Chroma ----------
print("\n⏳ 连接Chroma数据库...")
vectorstore = Chroma(
persist_directory=CHROMA_PATH,
embedding_function=embeddings,
collection_name=COLLECTION_NAME,
)
# 基础检索器(用于每个文件的独立检索)
base_retriever = vectorstore.as_retriever(
search_kwargs={"k": CANDIDATE_POOL}
)
print(f"✅ 连接成功,共 {vectorstore._collection.count()} 个片段")
# ---------- 加载大模型 ----------
print("\n⏳ 加载大模型...")
llm = Ollama(
model=MODEL_NAME,
base_url=OLLAMA_BASE_URL,
temperature=0.2, # 低温度,提高回答确定性
num_predict=1024 # 限制生成长度,控制内存占用
)
print("✅ 大模型加载完成")
# ---------- 工具函数 ----------
def get_all_sources():
"""
获取Chroma中所有不同的文件来源
用于后续的跨文件均衡检索
"""
result = vectorstore.get(include=['metadatas'])
sources = set()
for meta in result['metadatas']:
src = meta.get('source', '')
if src:
sources.add(src)
return list(sources)
# 启动时获取文件列表
ALL_SOURCES = get_all_sources()
print(f"\n📁 索引中包含 {len(ALL_SOURCES)} 个文件:")
for s in ALL_SOURCES:
print(f" - {s}")
# ---------- 查询扩展 ----------
# 关键词同义词映射表(可根据实际场景扩展)
SYNONYM_MAP = {
"新能源": ["新能源", "可再生能源", "清洁能源", "风电", "光伏", "绿色能源"],
"电力": ["电力", "电网", "输配电", "电力市场", "特高压"],
"十五五": ["十五五", "十五五规划", "第十五个五年规划"],
"能源": ["能源", "能源体系", "能源安全", "能源转型"],
"电网": ["电网", "输电", "配电", "主网架"],
"指导意见": ["指导意见", "意见", "通知"],
}
def expand_query(query: str) -> str:
"""
查询扩展:将用户输入扩展为包含同义词的查询字符串
目的:提升向量检索的召回率
示例:"新能源" → "新能源 可再生能源 清洁能源 风电 光伏 绿色能源"
"""
expanded = [query]
for key, vals in SYNONYM_MAP.items():
if key in query or any(v in query for v in vals):
expanded.extend(vals)
# 去重并保持顺序
unique = list(dict.fromkeys(expanded))
result = " ".join(unique)
if result != query:
print(f"🔍 查询扩展: {query} → {result}")
return result
# ---------- 跨文件均衡检索 ----------
def retrieve_across_files(query: str, all_sources: list, k_per_file: int = K_PER_FILE) -> list:
"""
跨文件均衡检索:确保每个文件都有片段被选中
为什么需要这个函数?
当文档库中同时存在"总纲"和"细则"时,总纲篇幅大、覆盖面广,
会淹没细则文件的检索结果。本函数通过"每个文件独立检索"的方式,
确保每个来源都有片段被选中。
实现逻辑:
1. 对每个文件,先用文件名作为查询词强制检索
2. 如果文件名检索不足,用原查询补充
3. 每个文件取前k_per_file个片段
4. 合并所有片段返回
返回:文档片段列表(每个文件最多k_per_file个)
"""
all_results = []
print(f"\n📖 对 {len(all_sources)} 个文件分别检索...")
for source in all_sources:
# 用文件名(不含扩展名)作为强制查询词
file_query = os.path.splitext(source)[0]
if len(file_query) < 5:
file_query = f"{file_query} 文件"
# 第一步:用文件名检索,确保能命中该文件
file_docs = base_retriever.invoke(file_query)
source_docs = [doc for doc in file_docs if doc.metadata.get('source') == source]
# 第二步:如果文件名检索不足,用原查询补充
if len(source_docs) < k_per_file:
print(f" 🔄 {source}: 文件名检索到 {len(source_docs)} 个片段,用原查询补充...")
query_docs = base_retriever.invoke(query)
extra_docs = [doc for doc in query_docs if doc.metadata.get('source') == source]
# 按页码去重
existing_pages = {doc.metadata.get('page', 0) for doc in source_docs}
for doc in extra_docs:
page = doc.metadata.get('page', 0)
if page not in existing_pages:
source_docs.append(doc)
existing_pages.add(page)
if len(source_docs) >= k_per_file:
break
# 第三步:取前k_per_file个片段
take = min(k_per_file, len(source_docs))
selected = source_docs[:take]
all_results.extend(selected)
# 第四步:如果仍然不足,用文件名强制补采
if len(selected) < k_per_file:
print(f" ⚠️ {source}: 只有 {len(selected)} 个片段,不足 {k_per_file} 个")
fallback_docs = base_retriever.invoke(source)[:k_per_file]
for doc in fallback_docs:
if doc.metadata.get('source') == source:
existing_pages = {d.metadata.get('page', 0) for d in selected}
if doc.metadata.get('page', 0) not in existing_pages:
selected.append(doc)
all_results.append(doc)
if len(selected) >= k_per_file:
break
print(f" - {source}: 取 {min(k_per_file, len(selected))} 个片段")
# 打印来源分布(调试用)
print(f"\n📄 最终返回 {len(all_results)} 个片段")
src_count = defaultdict(int)
for doc in all_results:
src = doc.metadata.get('source', 'unknown')
src_count[src] += 1
print("📊 来源分布:")
for src, cnt in src_count.items():
print(f" - {src}: {cnt} 个片段")
return all_results
# ---------- Prompt模板 ----------
PROMPT_TEMPLATE = ChatPromptTemplate.from_messages([
("system", """你是一位专业政策研究助手,基于参考文档回答问题。
规则:
1. 优先引用直接相关内容,并标注出处
2. 无直接相关内容时,综合推断并告知用户
3. 完全无关时如实告知,建议换个关键词
4. 回答条理清晰
参考文档:
{context}"""),
("human", "{question}")
])
def format_docs(docs: list) -> str:
"""
格式化文档片段为上下文文本
限制每个片段800字符,防止上下文过长
"""
return "\n\n---\n".join([doc.page_content[:800] for doc in docs])
def format_sources(docs: list) -> list:
"""
格式化引用来源:按文件名聚合页码
输入:文档片段列表
输出:[{"source": "文件名", "display": "《文件名》第 X, Y, Z 页"}, ...]
注意:如果同一个文件有多个页码,会合并显示
"""
source_map = defaultdict(set)
for doc in docs:
name = doc.metadata.get('source', '未知来源')
page = doc.metadata.get('page', 0)
if page and page > 0:
source_map[name].add(page)
else:
source_map[name].add(0)
result = []
for name, pages in source_map.items():
valid_pages = [p for p in pages if p > 0]
if valid_pages:
sorted_pages = sorted(valid_pages)
if len(sorted_pages) == 1:
page_str = f"第 {sorted_pages[0]} 页"
else:
page_str = f"第 {', '.join(map(str, sorted_pages))} 页"
result.append({'source': name, 'display': f"《{name}》{page_str}"})
else:
result.append({'source': name, 'display': f"《{name}》"})
print(f"\n📎 格式化后的引用来源: {len(result)} 个文件")
for r in result:
print(f" - {r['display']}")
return result
# ---------- 路由 ----------
@app.route('/')
def index():
"""渲染主页面"""
return render_template('rag.html')
@app.route('/api/ask', methods=['POST'])
def ask():
"""
问答API
请求体:{"question": "用户问题"}
返回:{"question": "...", "answer": "...", "sources": [...]}
处理流程:
1. 查询扩展
2. 跨文件均衡检索
3. 构建上下文
4. 生成答案
5. 格式化引用来源
"""
data = request.get_json()
question = data.get('question', '').strip()
if not question:
return jsonify({'error': '请输入问题'}), 400
try:
# 1. 查询扩展
expanded = expand_query(question)
# 2. 跨文件均衡检索
docs = retrieve_across_files(expanded, ALL_SOURCES)
# 3. 构建上下文
context = format_docs(docs)
# 4. 生成答案
chain = PROMPT_TEMPLATE | llm | StrOutputParser()
answer = chain.invoke({"context": context, "question": expanded})
# 限制答案长度
if len(answer) > 1500:
answer = answer[:1500] + "..."
# 5. 格式化引用来源
sources = format_sources(docs)
return jsonify({
'question': question,
'answer': answer,
'sources': sources,
})
except Exception as e:
print(f"❌ 错误: {e}")
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500
if __name__ == '__main__':
# debug=False 减少内存占用
app.run(host='0.0.0.0', port=5002, debug=False)
3.4 前端页面 templates/rag.html
html
<!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>
<style>
/* ---------- 全局重置 ---------- */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
background: #f0f2f5;
min-height: 100vh;
padding: 30px;
}
/* ---------- 容器 ---------- */
.container { max-width: 800px; margin: 0 auto; }
/* ---------- 头部 ---------- */
.header {
text-align: center;
padding: 20px 0;
}
.header h1 {
font-size: 26px;
color: #1a2332;
}
.header h1 span { color: #2d5a88; }
.header .subtitle {
color: #6b7a8f;
font-size: 14px;
margin-top: 6px;
}
.header .author {
color: #999;
font-size: 12px;
margin-top: 4px;
}
/* ---------- 聊天区域 ---------- */
.chat-box {
background: white;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
padding: 24px;
min-height: 400px;
max-height: 500px;
overflow-y: auto;
margin-bottom: 16px;
}
/* ---------- 消息样式 ---------- */
.message {
margin-bottom: 16px;
padding: 12px 16px;
border-radius: 12px;
max-width: 90%;
}
.message.user {
background: #2d5a88;
color: white;
margin-left: auto;
border-bottom-right-radius: 4px;
}
.message.assistant {
background: #e9ecef;
color: #1a2332;
border-bottom-left-radius: 4px;
}
.message .label {
font-size: 12px;
color: #888;
margin-bottom: 4px;
}
.message.user .label { color: rgba(255,255,255,0.7); }
/* ---------- 引用来源 ---------- */
.sources {
margin-top: 10px;
padding: 10px 14px;
background: #f8f9fa;
border-radius: 8px;
font-size: 13px;
border-left: 3px solid #2d5a88;
}
.sources strong { color: #2d5a88; }
.sources .item { margin: 4px 0; color: #555; }
/* ---------- 输入区域 ---------- */
.input-area {
display: flex;
gap: 12px;
}
.input-area input {
flex: 1;
padding: 12px 18px;
border: 1px solid #ddd;
border-radius: 30px;
font-size: 15px;
outline: none;
}
.input-area input:focus { border-color: #2d5a88; }
.input-area button {
padding: 12px 30px;
background: #2d5a88;
color: white;
border: none;
border-radius: 30px;
font-size: 15px;
cursor: pointer;
transition: background 0.2s;
}
.input-area button:hover { background: #1f4569; }
.input-area button:disabled {
background: #aab3c5;
cursor: not-allowed;
}
/* ---------- 加载状态 ---------- */
.loading { color: #888; font-style: italic; }
.error { color: #d9534f; background: #f8d7da; padding: 8px 16px; border-radius: 8px; }
/* ---------- 底部 ---------- */
.footer {
text-align: center;
color: #aaa;
font-size: 12px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #eee;
}
.footer a {
color: #2d5a88;
text-decoration: none;
}
.footer a:hover { text-decoration: underline; }
/* ---------- 响应式 ---------- */
@media (max-width: 600px) {
body { padding: 16px; }
.chat-box { padding: 16px; min-height: 300px; max-height: 400px; }
.input-area { flex-direction: column; gap: 8px; }
.input-area input { padding: 10px 14px; }
.input-area button { padding: 10px; }
}
</style>
</head>
<body>
<div class="container">
<!-- 头部 -->
<div class="header">
<h1>📚 <span>RAG知识库问答</span></h1>
<div class="subtitle">基于"十五五"规划相关文档的智能问答系统</div>
<div class="author">作者:Javy21 | 博客:blog.csdn.net/javy21</div>
</div>
<!-- 聊天区域 -->
<div class="chat-box" id="chatBox">
<div class="message assistant">
<div class="label">AI</div>
你好!我已加载"十五五"规划相关文档(共3个文件),有什么政策问题可以问我。
</div>
</div>
<!-- 输入区域 -->
<div class="input-area">
<input type="text" id="questionInput" placeholder="输入你的问题..." />
<button id="sendBtn">发送</button>
</div>
<!-- 底部 -->
<div class="footer">
<a href="https://blog.csdn.net/javy21" target="_blank">老攻城狮的AI编程实践之路</a> ·
基于 Qwen2.5:7B + Chroma + LangChain
</div>
</div>
<script>
// ============================================================
// 前端交互逻辑
// 作者:Javy21 (https://blog.csdn.net/javy21)
// 功能:发送用户消息 → 调用后端API → 渲染回答和引用来源
// ============================================================
const chatBox = document.getElementById('chatBox');
const input = document.getElementById('questionInput');
const sendBtn = document.getElementById('sendBtn');
/**
* 添加一条消息到聊天区域
* @param {string} role - 'user' 或 'assistant'
* @param {string} content - 消息内容
* @param {Array} sources - 引用来源列表(仅assistant消息有)
*/
function addMessage(role, content, sources) {
const div = document.createElement('div');
div.className = `message ${role}`;
const label = document.createElement('div');
label.className = 'label';
label.textContent = role === 'user' ? '你' : 'AI';
div.appendChild(label);
const contentDiv = document.createElement('div');
contentDiv.innerHTML = content.replace(/\n/g, '<br>');
div.appendChild(contentDiv);
// 显示引用来源
if (sources && sources.length > 0) {
const srcDiv = document.createElement('div');
srcDiv.className = 'sources';
srcDiv.innerHTML = '<strong>📎 引用来源:</strong>';
sources.forEach(s => {
const item = document.createElement('div');
item.className = 'item';
item.textContent = s.display || s.source;
srcDiv.appendChild(item);
});
div.appendChild(srcDiv);
}
chatBox.appendChild(div);
chatBox.scrollTop = chatBox.scrollHeight;
}
/**
* 设置加载状态(禁用按钮、显示加载提示)
*/
function setLoading(loading) {
sendBtn.disabled = loading;
input.disabled = loading;
sendBtn.textContent = loading ? '思考中...' : '发送';
if (loading) {
// 显示加载提示
const loadingDiv = document.createElement('div');
loadingDiv.className = 'message assistant';
loadingDiv.id = 'loadingMsg';
loadingDiv.innerHTML = `<div class="label">AI</div><div class="loading">⏳ 正在检索和思考...</div>`;
chatBox.appendChild(loadingDiv);
chatBox.scrollTop = chatBox.scrollHeight;
} else {
// 移除加载提示
const loadingMsg = document.getElementById('loadingMsg');
if (loadingMsg) loadingMsg.remove();
}
}
/**
* 发送问题到后端API
*/
async function ask() {
const question = input.value.trim();
if (!question) return;
// 显示用户消息
addMessage('user', question);
input.value = '';
setLoading(true);
try {
const resp = await fetch('/api/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question })
});
const data = await resp.json();
if (resp.ok) {
addMessage('assistant', data.answer, data.sources);
} else {
addMessage('assistant', `❌ 错误:${data.error || '未知错误'}`);
}
} catch (err) {
addMessage('assistant', `❌ 网络错误:${err.message}`);
} finally {
setLoading(false);
input.focus();
}
}
// ---------- 事件绑定 ----------
sendBtn.addEventListener('click', ask);
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') ask();
});
// 页面加载后自动聚焦
input.focus();
</script>
</body>
</html>
四、运行与验证
4.1 启动步骤
bash
# 1. 进入项目目录
cd ~/ai_project/knowledge_base
# 2. 激活虚拟环境
source ../venv/bin/activate
# 3. 安装依赖(首次运行)
pip install langchain langchain-community langchain-text-splitters \
chromadb sentence-transformers pypdf flask
# 4. 索引文档(首次运行)
python index_docs.py
# 5. 启动问答服务
python rag_chat.py
# 6. 访问 http://localhost:5002
4.2 验证测试
| 测试问题 | 预期结果 |
|---|---|
| “国家能源局关于促进电网高质量发展的指导意见的总体要求是什么?” | 返回7条总体要求 + 三个文件的引用 |
| “新能源方面有什么规划?” | 返回新能源相关内容 + 多个文件引用 |
| “十五五规划有多少章?” | 返回章节数量 + 引用来源 |
五、踩坑全记录
5.1 问题1:总纲文件淹没细则文件
| 阶段 | 问题表现 | 原因分析 |
|---|---|---|
| 初始方案 | 任何问题都只返回第一个文件的片段 | 总纲文件140页,片段数量远多于细则文件 |
| MMR尝试 | 多样性检索无效 | MMR在高相似度场景下效果有限 |
| 补采方案 | 补采片段丢失 | 分组-取片段逻辑存在缺陷 |
| 最终方案 | 三个文件均衡覆盖 | 每个文件独立检索,强制各取3个片段 |
5.2 问题2:补采片段在返回时丢失
现象:日志显示补采到3个片段,但最终返回时只有第一个文件的片段。
原因:grouped 字典中补采的文件条目虽然存在,但在遍历 grouped.items() 取片段时,补采的片段没有被正确保留。
解决方案:完全重构检索逻辑,改为“每个文件独立检索”,不再依赖分组-取片段流程。
5.3 问题3:内存占用过高
现象:Windows任务管理器显示内存占用超过20GB。
原因:Qwen2.5:7B模型加载占用约8-12GB,Chroma向量库占用2-4GB,Python进程占用1-2GB。
解决方案:
-
限制WSL内存上限(
.wslconfig中设置memory=16GB) -
控制片段长度(
num_predict=1024) -
关闭Debug模式(
debug=False)
六、核心经验总结
6.1 向量检索的局限性
关键认知:向量检索不是万能的。当文档篇幅差异巨大时,单纯靠相似度排序会导致“长尾文件被淹没”。必须从业务层面设计检索策略。
6.2 跨文件均衡检索的必要性
核心原则:在企业知识库场景中,用户期望从所有相关文档中获得答案,而不仅仅是“最相似”的文档。确保每个文件都有片段被选中,是提升回答质量的关键。
6.3 调试日志的价值
实践教训:每一步打印详细日志,是定位问题的最高效方式。本次案例中,日志帮助快速定位了“补采片段丢失”和“文件覆盖不足”等问题。
6.4 工程化思维
核心能力:AI应用开发的核心能力,不是调参,而是能快速把想法变成可运行的原型,并能系统性地解决工程化问题。
七、文章与代码资源
| 资源 | 链接 |
|---|---|
| 作者博客 | javy21-CSDN博客 |
| 专栏系列 | 《老攻城狮的AI编程实践之路》 |
| 代码仓库 | 见CSDN文章附件 |
相关文章:
-
多模态AI实践:从零搭建本地“以文搜图”系统(华为MateBook 16S + WSL2 + CLIP + Chroma)-CSDN博客
-
《RAG知识库实战:从零搭建“十五五”规划智能问答系统》(本文)
作者:Javy21(javy21@csdn)
博客主页:javy21-CSDN博客
首发日期:2026年6月27日本文是《老攻城狮的AI编程实践之路》专栏的第06篇。后续文章将按计划逐步发布,欢迎持续关注。
本文采用 CC BY-NC 4.0 许可协议。欢迎转载,请注明出处。如有问题或建议,欢迎在评论区交流。
更多推荐


所有评论(0)