学习笔记-关于中华心法问答系统的环境配置和源代码理解

1. 简介

由于上周电脑故障的问题,没有进行环境配置。所以本周的主要任务就是进行环境配置和阅读理解基本代码。

2. 环境配置

首先需要需要下载python。这里注意最好下载python3.11版本。因为Python 3.12 不兼容旧版 jiebasetuptools;NumPy 2.2.3 尚未支持 Python 3.13。

安装python的时候记得勾选自动配置环境变量,如果忘记勾选,需要自己添加环境变量。验证python可以使用后进行下面的虚拟环境配置。

(1)创建和激活虚拟环境

cpp 复制代码
# 创建虚拟环境
python -m venv venv

# 激活虚拟环境
venv\Scripts\activate

(2)下载需要的功能包

cpp 复制代码
# 安装 Flask
pip install flask

# 安装 jieba 
pip install jieba

# 安装 numpy
pip install numpy

# 安装PyTorch (torch) 库
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu

# 安装 transformers
pip install transformers

(3)运行代码

cpp 复制代码
python xinfa_QA.py

(4)登录网页:http://127.0.0.1:5000

即可出现以下页面

关掉终端后,仅需激活虚拟环境,再运行代码就可以了。

3. 代码理解

3.1 代码流程图

3.2 主要模块

3.2.1 全局变量和配置类

(1)全局变量:

复制代码
# app 是 Flask 应用实例。
app = Flask(__name__)

# system 是全局变量,用于存储问答系统实例。
system = None

(2)配置类:

**①TagConfig 类:**管理问答系统中的标签层级结构(一级标签和二级标签)。

a.工作方式:从CSV文件中加载标签配置,存储一级标签和二级标签的层级关系,提供标签的验证功能。

b.主要功能:

  • 初始化方法

    接收一个CSV文件路径作为参数

    def init(self, csv_path):
    # 初始化两个数据结构:
    # LEVEL1: 存储所有一级标签的集合
    self.LEVEL1 = set()
    # LEVEL2: 使用defaultdict(set)存储每个一级标签对应的二级标签集合
    self.LEVEL2 = defaultdict(set)

  • CSV文件处理

    • 使用csv.DictReader读取CSV文件

    • 遍历每一行数据:

      • 如果存在非空的"一级标签",将其添加到LEVEL1集合中

      • 如果该行还有非空的"二级标签",会将其按"/"分割,去除空白后添加到对应一级标签的二级标签集合中

      with open(csv_path, 'r', encoding='utf-8') as f:
      reader = csv.DictReader(f)
      for row in reader:
      if '一级标签' in row and row['一级标签']:
      l1 = row['一级标签'].strip()
      self.LEVEL1.add(l1)
      if '二级标签' in row and row['二级标签']:
      l2_list = [t.strip() for t in row['二级标签'].split('/') if t.strip()]
      for l2 in l2_list:
      self.LEVEL2[l1].add(l2)

  • 数据整理

    • 最后将LEVEL1集合转换为排序后的列表

    • LEVEL2中的每个集合也转换为排序后的列表

      self.LEVEL1 = sorted(self.LEVEL1)
      self.LEVEL2 = {k: sorted(v) for k, v in self.LEVEL2.items()

**② Config 类:**存储系统配置参数。

a.功能:用于集中管理项目中使用的各种配置参数和常量。

复制代码
class Config:
    # 指定BERT模型的存储路径
    # 默认值为当前目录下的"BERT"文件夹
    BERT_PATH = "./BERT"

    # 指定问答数据文件的名称
    # 文件格式应为CSV,包含问题和答案数据
    QA_FILE = "心法问答.csv"

    # 指定停用词文件的名称
    # 停用词文件包含需要过滤的无意义词汇
    STOPWORDS_FILE = "stopwords.txt"

    # 设置检索或返回结果的最大数量
    # 值为1000表示最多返回1000个结果
    TOP_K = 1000

    # 设置相似度的最低阈值
    # 值为0.6表示只保留相似度大于等于60%的结果
    MIN_SIMILARITY = 0.6

    # 自动检测并设置计算设备
    # 优先使用CUDA(GPU)加速,不可用时回退到CPU
    DEVICE = "cuda" if torch.cuda.is_available() else "cpu"

    # 设置BERT模型各层的权重分配
    # 列表中的4个值分别对应BERT的4个隐藏层
    # 权重总和应为1.0
    LAYER_WEIGHTS = [0.15, 0.25, 0.35, 0.25]

3.2.2 Flask路由

每个函数基本流程:接收请求→验证数据→处理业务→返回响应

(1)首页路由(/)

a.功能:处理根路径请求,返回首页

复制代码
@app.route('/')
def home():
    # 使用 render_template 渲染并返回 index.html 模板
    # 这是应用的入口页面
    return render_template('index.html')

(2)提交页面路由(/submit)

a.功能:返回问题提交页面

复制代码
@app.route('/submit')
def submit_page():
    # 渲染 submit.html 模板
    # 用于展示问题提交表单
    return render_template('submit.html')

(3)搜索接口(/search)

a.功能:处理问题搜索请求

复制代码
# 只接受 POST 请求
@app.route('/search', methods=['POST'])
def search():
    # 从请求 JSON 中获取 question(搜索问题)和 tags(筛选标签)
    data = request.json
    query = data.get('question', '')
    selected_tags = data.get('tags', {})

    # 调用 system.search_with_tags 进行带标签的搜索
    results = system.search_with_tags(query, selected_tags)

    # 返回包含问题、答案、相似度和标签的 JSON 数组
    return jsonify([{
        'question': r['question'],
        'answer': r['answer'],
        'similarity': r['similarity'],
        'tags': r['tags']

#使用列表推导式格式化返回结果
    } for r in results])

(4)问题提交接口(/submit_question)

a.功能:处理新问题的提交

b.细节

  • 数据获取与清理:从请求中获取问题、答案、标签等数据并去除空白

    try:
    # 获取并清理数据
    data = request.json
    question = data.get('question', '').strip()
    answer = data.get('answer', '').strip()
    level1 = data.get('level1', '').strip()
    level2 = data.get('level2', [])

  • 验证逻辑

    • 检查必填字段是否为空

    • 验证一级标签是否有效

    • 验证二级标签是否属于对应的一级标签

      验证必填字段

      if not question or not answer or not level1:
      return jsonify({'status': 400, 'msg': '问题、答案和一级标签不能为空'})

      验证一级标签有效性

      if level1 not in system.tag_config.LEVEL1:
      return jsonify({'status': 400, 'msg': '无效的一级标签'})

      验证二级标签有效性

      for tag in level2:
      if tag not in system.tag_config.LEVEL2.get(level1, []):
      return jsonify({'status': 400, 'msg': '无效的二级标签组合'})

  • 重复检查:清理问题文本后检查是否已存在

    清理问题文本并检查是否已存在

    clean_q = clean_text(question)
    existing = [q['cleaned_question'] for q in system.qa_pairs]
    if clean_q in existing:
    return jsonify({'status': 400, 'msg': '该问题已存在'})

  • 数据持久化

    • 将新问题追加到 CSV 文件中

    • 更新内存中的问答对列表

    • 计算并更新问题的向量表示

      以追加模式打开QA文件

      with open(system.config.QA_FILE, 'a', newline='', encoding='utf-8') as f:
      # 创建CSV写入器
      writer = csv.writer(f)
      # 写入新行:问题、答案、一级标签、用斜杠连接的二级标签
      writer.writerow([question, answer, level1, '/'.join(level2)])

      复制代码
          # 创建内存中的新条目字典
          new_entry = {
              "original_question": question,  # 原始问题
              "cleaned_question": clean_q,    # 清洗后问题
              "answer": answer,               # 答案
              "tags": {
                  'level1': level1,          # 一级标签
                  'level2': level2           # 二级标签列表
              }
          }
          # 将新条目添加到内存列表
          system.qa_pairs.append(new_entry)
          # 获取新问题的嵌入向量并调整形状
          new_vec = system._get_embedding(clean_q).reshape(1, -1)
          # 将新向量垂直堆叠到现有向量矩阵
          system.question_vectors = np.vstack([system.question_vectors, new_vec])
      
          # 返回成功响应
          return jsonify({'status': 200, 'msg': '提交成功'})
  • 错误处理:捕获异常并返回 500 错误

    捕获所有异常

    except Exception as e:
    # 返回500服务器错误及异常信息
    return jsonify({'status': 500, 'msg': f'服务器错误: {str(e)}'})

3.2.3 文本清理函数

a.功能:用于清理输入的文本,去除非中文字符和停用词,并保留指定词性的词语。

b.细节:

  • 移除特殊字符(保留中文和标点)

  • 使用jieba进行分词和词性标注

  • 过滤停用词(保留某些否定词和程度词)

  • 只保留特定词性(名词(n)、动词(v)、形容词(a)、人名(nr)、地名(ns))

  • 返回用空格连接的关键词串

    def clean_text(text: str) -> str:
    # 使用正则表达式移除所有非中文字符、非汉字字符(保留中文标点??!!)
    text = re.sub(r"[^\w\u4e00-\u9fa5??!!]", "", text)

    复制代码
      # 去除文本首尾空白字符
      text = text.strip()
    
      # 使用jieba分词器进行词性标注分词(pseg.cut)
      # 返回生成器,产生(词, 词性)元组
      words = pseg.cut(text)
    
      # 初始化停用词集合
      stopwords = set()
    
      # 打开停用词文件(使用Config中定义的路径)
      with open(Config.STOPWORDS_FILE, "r", encoding='utf-8') as f:
          for line in f:
              # 去除每行首尾空白
              word = line.strip()
              # 保留有意义的否定词和程度词,添加到停用词集合
              if word not in ["不", "没", "非常", "极其"]:
                  stopwords.add(word)
    
      # 定义需要保留的词性集合:
      # n-名词, v-动词, a-形容词, nr-人名, ns-地名
      keep_pos = {'n', 'v', 'a', 'nr', 'ns'}
    
      # 使用列表推导式过滤词语:
      # 1. 词性标记的首字母在保留集合中
      # 2. 词语不在停用词集合中
      filtered_words = [
          word for word, flag in words
          if flag[0] in keep_pos and word not in stopwords
      ]
    
      # 用空格连接过滤后的词语,返回处理后的字符串
      return " ".join(filtered_words)

c. 例子

复制代码
原始输入: "Python是一种非常流行的编程语言!"
处理流程:
1. 移除"!" → "Python是一种非常流行的编程语言"
2. 分词+词性标注: 
   [('Python','n'), ('是','v'), ('一种','m'), ('非常','d'), 
    ('流行','v'), ('的','uj'), ('编程','v'), ('语言','n')]
3. 过滤后: ['Python', '流行', '编程', '语言']
4. 返回结果: "Python 流行 编程 语言"

3.2.4 问答系统类

a.功能:问答系统的核心实现。

b.主要方法

  • 类初始化__init__

    • 接收配置对象

    • 初始化标签管理系统

    • 按顺序执行:模型加载 → 数据加载 → 向量计算 → 向量验证

      def init(self, config: Config):
      self.config = config # 存储配置对象
      self.tag_config = TagConfig(config.QA_FILE) # 初始化标签配置
      self.qa_pairs = [] # 存储问答对
      self._load_model() # 加载BERT模型
      self._load_data() # 加载问答数据
      self._prepare_vectors() # 预计算问题向量
      self._validate_vectors() # 验证向量质量

  • 模型加载 _load_model

    • 加载预训练的BERT模型和分词器

    • 设置模型为评估模式

    • 指定设备(CPU/GPU)

      def _load_model(self):
      # 加载BERT分词器和模型
      self.tokenizer = BertTokenizer.from_pretrained(self.config.BERT_PATH)
      self.model = BertModel.from_pretrained(
      self.config.BERT_PATH,
      output_hidden_states=True # 获取所有隐藏层输出
      ).to(self.config.DEVICE) # 自动选择GPU/CPU
      self.model.eval() # 设置为评估模式

  • 数据加载_load_data

    • 从CSV文件加载问答对数据

    • 对每个问题:

      • 清理文本

      • 验证标签

      • 存储原始问题、清理后的问题、答案和标签

    • 数据处理流程:原始数据 → 标签解析 → 严格验证 → 文本清洗 → 内存存储

      def _load_data(self):
      # 仅支持CSV格式
      with open(self.config.QA_FILE, 'r', encoding='utf-8') as f:
      reader = csv.DictReader(f)
      for row in reader:
      # 标签处理与验证
      tags = {
      'level1': row.get('一级标签', '').strip(),
      'level2': [t.strip() for t in row.get('二级标签', '').split('/') if t.strip()]
      }
      # 严格验证标签有效性
      if not tags['level1'] or tags['level1'] not in self.tag_config.LEVEL1:
      raise ValueError(f"无效的一级标签")

      复制代码
              # 存储处理后的数据
              self.qa_pairs.append({
                  "original_question": row['问题'],
                  "cleaned_question": clean_text(row['问题']),  # 文本清洗
                  "answer": row['答案'],
                  "tags": tags
              })
  • 向量准备_prepare_vectors

    • 为所有问题预计算BERT嵌入向量

    • 使用与_get_embedding相同的融合策略

    • 存储归一化后的向量矩阵

      def _prepare_vectors(self):
      # 批量处理所有问题
      inputs = self.tokenizer(
      [q["cleaned_question"] for q in self.qa_pairs],
      padding=True,
      truncation=True,
      max_length=512,
      return_tensors="pt"
      ).to(self.config.DEVICE)

      复制代码
      # 获取BERT各层输出
      with torch.no_grad():
          outputs = self.model(**inputs)
      
      # 融合最后4层隐藏状态(加权平均)
      hidden_states = outputs.hidden_states[-4:]
      weights = torch.tensor(self.config.LAYER_WEIGHTS).to(self.config.DEVICE)
      weights /= weights.sum()
      
      # 生成标准化向量
      self.question_vectors = np.array([
          torch.sum(
              torch.stack([hs[i][0] for hs in hidden_states]) * weights,
              dim=0
          ).cpu().numpy()
          for i in range(len(self.qa_pairs))
      ])
      # L2归一化
      self.question_vectors /= np.linalg.norm(self.question_vectors, axis=1, keepdims=True)
  • 搜索功能search

    • 搜索流程:

      • 查询文本清洗和向量化

      • 计算余弦相似度 + 非线性校准

      • 结合关键词匹配权重

      • 多维度结果过滤:

        • 相似度阈值

        • 内容去重

        • 高相似结果去重

      def search(self, query: str) -> List[Dict]:
      # 1. 查询预处理
      cleaned_query = clean_text(query)
      query_emb = self._get_embedding(cleaned_query)

      复制代码
      # 2. 计算相似度(带校准)
      raw_sim = np.dot(self.question_vectors, query_emb)
      calibrated = 1 / (1 + np.exp(-25*(raw_sim-0.88)))  # Sigmoid校准
      similarities = 0.2*raw_sim + 0.8*calibrated
      
      # 3. 关键词增强
      query_keywords = set(jieba.lcut(cleaned_query))
      keyword_weights = np.array([
          len(set(jieba.lcut(q["cleaned_question"])) & query_keywords)
          / max(len(query_keywords), 1)
          for q in self.qa_pairs
      ])
      similarities = 0.7*similarities + 0.3*keyword_weights
      
      # 4. 结果过滤与排序
      results = []
      seen_hashes = set()
      for idx in np.argsort(-similarities):
          # 多种过滤条件...
          results.append({
              "question": self.qa_pairs[idx]["original_question"],
              "answer": self.qa_pairs[idx]["answer"],
              "similarity": float(similarities[idx]),
              "tags": self.qa_pairs[idx]["tags"]
          })
      return sorted(results, key=lambda x: -x['similarity'])[:self.config.TOP_K]
  • 带标签过滤的搜索方法search_with_tags

    • 标签过滤采用"或空即通过"逻辑:如果未指定某级标签,则视为通过

    • 当前实现中标签匹配不改变相似度(+0),但保留了权重调整接口

    • 二级标签采用部分匹配策略(any)

      def search_with_tags(self, query: str, selected_tags: Dict) -> List[Dict]:
      # 先获取基础搜索结果(不带标签过滤)
      base_results = self.search(query)
      filtered = []

      复制代码
      # 遍历所有结果进行标签过滤
      for item in base_results:
          # 检查一级标签匹配(如果selected_tags中有level1要求)
          l1_match = not selected_tags.get('level1') or \
                    item['tags']['level1'] in selected_tags['level1']
          
          # 检查二级标签匹配(如果selected_tags中有level2要求)
          l2_match = not selected_tags.get('level2') or \
                    any(tag in item['tags']['level2'] for tag in selected_tags['level2'])
          
          # 同时满足两级标签条件
          if l1_match and l2_match:
              # 理论上可以增加匹配标签的权重(当前实现为+0,保持原相似度)
              item['similarity'] = min(item['similarity'] + 
                                     0 * len(set(item['tags']['level2']) & 
                                            set(selected_tags.get('level2', []))), 
                                     1.0)
              filtered.append(item)
      
      # 按相似度降序返回,限制结果数量
      return sorted(filtered, key=lambda x: -x['similarity'])[:self.config.TOP_K]
  • 文本向量化方法_get_embedding

    • 使用BERT的[CLS]token作为文本表示

    • 多层融合策略:最后4层加权求和(权重来自配置)

    • 强制设备转移确保GPU/CPU一致性

    • 最终向量进行L2归一化,方便余弦相似度计算

      def _get_embedding(self, text: str) -> np.ndarray:
      # 使用BERT tokenizer处理文本
      inputs = self.tokenizer(
      text,
      return_tensors="pt", # 返回PyTorch张量
      padding=True, # 自动填充
      truncation=True, # 自动截断
      max_length=512 # 最大长度限制
      ).to(self.config.DEVICE) # 发送到指定设备

      复制代码
      # 不计算梯度(推理模式)
      with torch.no_grad():
          outputs = self.model(**inputs)
      
      # 获取最后4层隐藏状态
      hidden_states = outputs.hidden_states[-4:]
      
      # 加载层级权重并归一化
      weights = torch.tensor(self.config.LAYER_WEIGHTS).to(self.config.DEVICE)
      weights /= weights.sum()
      
      # 提取每层的[CLS]向量(首向量)并堆叠
      cls_vectors = torch.stack([layer[:, 0, :] for layer in hidden_states])
      
      # 加权融合各层向量
      fused_vector = torch.sum(cls_vectors * weights.view(-1, 1, 1), dim=0)
      
      # 转为numpy数组并去除多余维度
      fused_vector = fused_vector.cpu().numpy().squeeze()
      
      # L2归一化后返回
      return fused_vector / np.linalg.norm(fused_vector)
  • 向量质量验证方法_validate_vectors

    • 验证逻辑

      • 重复检测:确保相同文本生成的向量几乎相同(相似度≈1)

      • 空间分析:计算所有向量两两之间的平均相似度,评估:

        • 值过高 → 向量区分度不足

        • 值过低 → 可能编码异常

      • 典型健康值:0.2-0.5

      def _validate_vectors(self):
      # 检查所有问题对
      for i in range(len(self.qa_pairs)):
      for j in range(i+1, len(self.qa_pairs)):
      # 发现文本完全重复的问题
      if self.qa_pairs[i]["cleaned_question"] == self.qa_pairs[j]["cleaned_question"]:
      # 计算向量相似度
      sim = np.dot(self.question_vectors[i], self.question_vectors[j])

      复制代码
                  # 理论上相同文本的向量相似度应为1
                  if abs(sim - 1.0) > 1e-6:
                      print(f"警告:重复问题向量差异过大 [{i}] vs [{j}]: {sim:.4f}")
      
      # 计算整个向量空间的平均相似度
      avg_sim = np.mean(np.dot(self.question_vectors, self.question_vectors.T))
      print(f"向量空间平均相似度: {avg_sim:.2f}")

3.2.5 主函数

  • 初始化jieba分词

  • 创建QASystem实例

  • 启动Flask应用

    def main():
    global system
    jieba.initialize()
    system = QASystem(Config())
    app.run(host='0.0.0.0', port=5000, debug=False)

    if name == "main":
    main()

4. 总结

本周主要是进行环境配置,以及代码的阅读理解。通过本周的学习任务,我理解了一些爱问答系统的实现细节。核心即是采用BERT+标签体系的双通道架构,在文本处理方面采用保留关键否定词/程度词、基于词性的内容过滤、文本重复检测的哈希值对比机制等技术来实现。在向量化方面,掌握了BERT[CLS]向量的提取方法和多层表达融合技巧等。

相关推荐
Chef_Chen3 分钟前
从0开始学习R语言--Day57--SCAD模型
开发语言·学习·r语言
停走的风5 分钟前
Yolo底层原理学习(V1~V3)(第一篇)
人工智能·深度学习·神经网络·学习·yolo
超浪的晨32 分钟前
Java 单元测试详解:从入门到实战,彻底掌握 JUnit 5 + Mockito + Spring Boot 测试技巧
java·开发语言·后端·学习·单元测试·个人开发
飞翔的佩奇2 小时前
OpenTelemetry学习笔记(十二):在APM系统中,属性的命名空间处理遵循规则
笔记·学习·springboot·sdk·apm·opentelemetry
序属秋秋秋3 小时前
《C++初阶之STL》【vector容器:详解 + 实现】
开发语言·c++·笔记·学习·stl
Brookty3 小时前
【Java学习】匿名内部类的向外访问机制
java·开发语言·后端·学习
快乐肚皮3 小时前
Zookeeper学习专栏(十):核心流程剖析之服务启动、请求处理与选举协议
linux·学习·zookeeper·源码
ATaylorSu4 小时前
Kafka入门指南:从零开始掌握分布式消息队列
笔记·分布式·学习·kafka
CarmenHu5 小时前
RNN学习笔记
笔记·rnn·学习
钊兵6 小时前
mysql时间处理函数和操作笔记
数据库·笔记·mysql