【导航台账】老蒋的技术博客全系列文章汇总(持续更新)

一台32GB内存的笔记本,三个公开政策文件,从索引构建到跨文件均衡检索,完整记录了一个传统IT人攻克RAG工程化难题的全过程。

作者:Javy21(javy21@csdn)
博客主页javy21-CSDN博客
专栏:《老攻城狮的AI编程实践之路》
个人标签:传统企业AI转型实践者

为什么一个20+年IT老兵决定系统性学习AI人工智能

【前置】老攻城狮的AI开发环境搭建全记录:从零到跑通本地大模型(一日速通版)

一、为什么要做这个案例?

        在之前的专栏文章中,我已经完成了本地AI聊天机器人和以文搜图系统的搭建。这次的目标更进一步——构建一个真正的RAG(检索增强生成)知识库问答系统

RAG是目前企业级AI应用中最核心的技术模式,它解决了大模型的两个致命问题:

  • 幻觉问题:模型凭空捏造不存在的信息

  • 知识时效性:模型训练数据截止日期之后的事件一概不知

本次案例的素材:我选择了三个公开发布的“十五五”规划相关文件:

  1. 《中华人民共和国国民经济和社会发展第十五个五年规划纲要》(约140页,总纲)

  2. 《新型能源体系建设“十五五”规划》(专项规划)

  3. 《国家发展改革委 国家能源局关于促进电网高质量发展的指导意见》(部门规范性文件)

        这三个文件的组合非常典型——总纲+细则+指导意见,完整模拟了企业知识库中“顶层文档+执行细则”的常见结构。

        为什么选这个素材:公开文件无版权风险,内容结构规范,读者有共鸣,且能真实暴露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文章附件

相关文章

  1. 老攻城狮的AI开发环境搭建全记录:从零到跑通本地大模型(一日速通版)-CSDN博客

  2. 多模态AI实践:从零搭建本地“以文搜图”系统(华为MateBook 16S + WSL2 + CLIP + Chroma)-CSDN博客

  3. 《RAG知识库实战:从零搭建“十五五”规划智能问答系统》(本文)

作者:Javy21(javy21@csdn)
博客主页javy21-CSDN博客
首发日期:2026年6月27日

本文是《老攻城狮的AI编程实践之路》专栏的第06篇。后续文章将按计划逐步发布,欢迎持续关注。

本文采用 CC BY-NC 4.0 许可协议。欢迎转载,请注明出处。如有问题或建议,欢迎在评论区交流。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐