书接上回,在完成了性能方面的调整之后,接下来的就是对准确率的提升优化了。这次没有犹豫直接选择 RAGChecker 。原因很简单:简单,好使...

废话不多说了,直接上代码。如下图:

...

question_id = 0

class init_data:

    
    def ollama_to_create_standard_data(self):
        """
        使用ollama模型将ES中的随机数据处理成问题-答案对,写入到Excel文件中
        """
        # 若路径下文件存在则删除文件
        file_path = Path(self.excel_data_path)
        if file_path.exists():
            file_path.unlink()
        
        contents = []
        body={
            "size": self.pick_size,
            "query": {
                "match_all": {}
            },
            "sort": [
                {
                    "_script": {
                        "type": "number",
                        "script": {
                            "lang": "painless",
                            "source": "Math.random()"
                        },
                        "order": "asc"
                    }
                }
            ]
        }
        
        # 遍历所有知识库索引
        for index in self.index_arr:
            ...
            
            rand_array =  self.es.find_by_body(index_name,body,None)
            contents.extend(row["_source"][text_field_name] for row in rand_array) 
        
        if len(contents) > 0:
            total_arr = []
            # 创建一个新的工作簿,保存问答对数据
            wb = Workbook()
            ws = wb.active
            ws.append(["序号", "问题", "答案", "rag答案"])

            # 遍历每一行内容
            for content in contents:
                # 为每一个问题都加上特定的提示词
                you_question = [
                    {
                        "role": "user",
                        "content": f"{content + self.ask}"
                    }
                ]

                # 调用 ollama api 接口,使用 chat 模式
                response_text,response_code = self.ol.remote_transfor_msg(you_question)
                if response_code == 200:
                    data = json.loads(response_text)
                    response = data.get('content')
                    cleaned_string = response.replace('*', '').replace('-', '').replace('#', '').replace('1.', '').replace('2.', '').replace('3.', '').replace('4.', '').replace('5.', '')
                    qa_list = self._split_questions_and_answers(cleaned_string)
                    total_arr.extend(qa_list)
                    
            if len(total_arr) > 0:
                for row in total_arr:
                    ws.append(row)
                wb.save(self.excel_data_path)

    
    def _split_questions_and_answers(self,result_string):
        """
        此函数从聊天机器人获取结果字符串并将其拆分为问题和答案。
        它假设结果字符串的格式为一系列问题和答案,每个问题以"问题:" 或“问:”每个答案都以“答案:”开头或“答:”。
        这个函数返回一个元组列表,每个元组包含问题id、问题本身和答案。
        问题id为从1开始递增分配。该函数还记录向日志记录器提问和回答。
        """
        global question_id
        questions_and_answers = []
        current_question = None
        current_answer = []
        for line in result_string.splitlines():
            if line.startswith(('问题:', '问:')) or line.endswith('?'):
                if current_question:
                    question_id += 1
                    questions_and_answers.append(
                        (question_id, current_question, ''.join(current_answer)))
                current_question = line.strip()
                logger.info(f"问题:{current_question.replace('问题:', '')}")
                current_answer.clear()
            elif line.startswith(('答案:', '答:')) or line.endswith('。'):
                current_answer.append(line.strip())
                logger.info(f"标准:{current_answer}")
        if current_question:
            question_id += 1
            questions_and_answers.append(
                (question_id, current_question, ''.join(current_answer)))
        return questions_and_answers

    def ask_for_answer(self):
        """
        读取Excel文件,遍历每一行,使用问答服务获取答案,并将答案写入到第四列

        1. 读取Excel文件
        2. 遍历每一行,使用问答服务获取答案
        3. 将答案写入到第四列
        4. 保存修改后的Excel文件
        """
        # 读取Excel文件
        wb = openpyxl.load_workbook(self.excel_data_path)
        self.ws = wb.active
        # 从第二行开始读取数据(第一行是标题)
        start_row = 2
        # 收集所有需要处理的行数据(行索引和第二列的值)
        rows_to_process = [(row_index, second_column_value)
                        for row_index, (_, second_column_value) in enumerate(self.ws.iter_rows(min_row=start_row, max_col=2, values_only=True), start=start_row)]
        # 采用多线程进行数据处理
        threading_arr = []
        split_arr = cu.split_array(rows_to_process, self.process_core)
        for split_item_arr in split_arr:
            thread = threading.Thread(target=self._data_process_by_threading, args=(split_item_arr,))
            thread.start()
            threading_arr.append(thread)
        for thread in threading_arr:
            thread.join()
        # 保存修改后的Excel文件
        wb.save(self.excel_data_path)

    def _data_process_by_threading(self,split_item_arr):
        """
        多线程处理数据
        """
        for i in tqdm(range(0, len(split_item_arr), 1), desc="llm thinking..."):
            row_data = split_item_arr[i]
            self._process_single_row(*row_data)

    def _process_single_row(self,row_index, second_column_value):
        """
        多线程处理单个行数据
        
        参数:
            row_index: 需要处理的行索引
            second_column_value: 第二列的值
        
        返回:
            None
        """
        try:
            # 根据 second_column_value 进行处理
            xy_answer = self._ask_server(second_column_value)
            # 将处理结果写入到第四列(使用正确的行索引)
            self.ws.cell(row=row_index, column=self.column_index, value=xy_answer)
        except Exception as exc:
            logger.info(f"Row {row_index} generated an exception: {exc}")
            
    def _ask_server(self, question):
        """
        使用问答服务获取答案

        参数:
            question: 问题

        返回:
            答案
        """
        response_clear_list = []
        headers = {'Content-Type': self.content_type}
        data = {
            "recommend": "0",
            "user_id": random.randint(1, 100),
            "us_id": "",
            "messages": [
                {
                    "role": "user",
                    "content": question.replace('问题:', '')
                }
            ]
        }

        response = requests.post(self._url, headers=headers, json=data)
        response.encoding = 'utf-8'
        # 正则表达式,匹配 {"text": "任意内容"} 并捕获双引号内的内容
        pattern = r'"text":\s*"(.*?)",\s*?"token_count"'
        # 使用 findall 方法查找所有匹配项
        matches = re.findall(pattern, response.text)
        # 输出匹配到的 text 内容
        response_clear_list.extend(match for match in matches)
        # 使用空字符串拼接非空字符串
        result = ''.join([item for item in response_clear_list if item.strip()])
        # 剔除换行符
        return result.replace('\\n', '').replace('*', '')

    def read_excel_and_change_to_json(self):
        """
        读取Excel文件,并将其转换为 ragchecker 需要的json格式

        读取Excel文件,不设置header,因为header=None
        删除第一行(索引为0的行,因为pandas使用0-based索引)
        将DataFrame的值转换为列表,返回一个列表
        将转换后的数据列表包装在包含'results'键的字典中

        返回:
            ragchecker 需要的 json 格式
        """
        # 读取Excel文件,不设置header,因为header=None
        df = pd.read_excel(self.excel_data_path, header=None)
        # 删除第一行(索引为0的行,因为pandas使用0-based索引)
        df = df.drop(0)
        # 将DataFrame的值转换为列表,返回一个列表
        data_arr = df.values.tolist()
        # ragchecker 需要的 json 格式
        converted_data = [
            {
                "query_id": query_id,
                "query": str(query).replace('问题:', ''),  # 去除"问题:"前缀
                "gt_answer": str(answer).replace('答案:', ''),  # 去除"答案:"前缀
                "response": str(xy_answer).replace('答案:', ''),  # 去除"答案:"前缀
                "retrieved_context": [
                    {
                        "doc_id": "",
                        "text": ""
                    }
                ]
            }
            for query_id, query, answer, xy_answer in data_arr
        ]
        # 将转换后的数据列表包装在包含'results'键的字典中
        data_with_results = {"results": converted_data}
        return data_with_results

class rag_checker:
   ...
    
    def checker_process(self,data_json):
        """
        评估 RagResults
        评估 RagResults,使用 RAGChecker 评估器

        参数:
            data_json (dict): RagResults 的json表示
        """
       
        parsed_url = urllib.parse.urlparse(self.ollama_url)
        ollama_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"

        rag_results = RAGResults.from_json(json.dumps(data_json,ensure_ascii=False))
        
        # 设置评估器
        evaluator = RAGChecker(
            extractor_name=self.evaluate_model,
            checker_name=self.evaluate_model,
            # 每次从数据源中提取的数据量
            batch_size_extractor=self.batch_size,
            # 每次评估的数据量
            batch_size_checker=self.batch_size,
            joint_check_num=self.check_num,
            #评估服务地址
            extractor_api_base=ollama_base_url,
            checker_api_base=ollama_base_url
        )
        # 对结果进行评估
        evaluator.evaluate(rag_results, all_metrics)
        logger.info(rag_results)


if __name__ == "__main__":
    
    id = init_data()
    # 由 llm 模型去创建数据集(没有数据集就使用这两个方法创建数据集文件)
    id.ollama_to_create_standard_data()
    id.ask_for_answer()
    
    # 将数据集excel转换为json(若有数据集的情况下可以直接使用)
    data_with_results = id.read_excel_and_change_to_json()

    # 调用评估器进行评估
    rc = rag_checker()
    rc.checker_process(data_with_results)

通过代码得知,本代码只做两件事。第一,从 elasticsearch 中随机抽取测试数据并固化成 excel 进行存储,并最终转换成 RAGChecker 需要的 json 数据供后续使用。第二,将数据传入评估模型进行评估。就这么简单...

OK,接着就是选择模型了。由于我们 RAG 应用使用的是 Qwen2.5 的 7B 模型,因此在 RAGChecker 中我也选择了 Qwen 系列的模型。

在“生成问答对”环节中我使用的是 Ollama 的“qwen2.5:7b-instruct-q5_K_M”模型,而在“更新 RAG 回答到 Excel”环节中 我使用的是 Huggingface 的“Qwen2.5-7B-Instruct”。最终生成出来问答对如下图:

由于 Ollama 的 Qwen2.5 7B 模型没有垂直领域知识库加持,若提供的数据信息量不足,则有可能生成“短小精悍”的返回内容。在这种“标准答案”和“RAG 答案”差距很大的情况下,执行评估也不会有很好的结果。我稍微用了 Ollama 的 qwen2.5:32b-instruct-q5_K_M 来试了一下。结果如下:

RAGResults(
4,000 RAG results,
Metrics:{
  "overall_metrics": {
    "precision": 16.7,
    "recall": 18.3,
    "f1": 11.8
  },
  "retriever_metrics": {
    "claim_recall": 9.2,
    "context_precision": 23.1
  },
  "generator_metrics": {
    "context_utilization": 12.3,
    "noise_sensitivity_in_relevant": 2.8,
    "noise_sensitivity_in_irrelevant": 5.5,
    "hallucination": 72.2,
    "self_knowledge": 12.0,
    "faithfulness": 13.1
  }
})

以下是对各项指标的解释:

overall_metrics:总体指标

  • precision(精确率):指的是在所有被模型认为相关的文档或片段中,实际确实相关的比例。这个值越高,表示模型的检索结果越准确。
  • recall(召回率):指的是所有实际上相关的文档或片段中,被模型正确检索到的比例。高的召回率意味着模型能够找到更多的相关信息。
  • f1(F1分数):指的是精确率和召回率的调和平均数,它提供了这两个指标之间平衡的一个综合度量。当需要同时考虑精确率和召回率时,F1分数是一个很好的参考标准。

retriever_metrics:检索器指标

  • claim_recall(声明召回率):表示模型能够找回与给定声明相关的上下文或证据的比例。高声明召回率说明模型能够有效地找到支持特定主张的信息。
  • context_precision(上下文精确率):指的是在所有检索到的上下文中,实际上对生成有用的比例。极高的上下文精确率表明几乎所有的检索结果都是高质量且相关的。

generator_metrics:生成器指标

  • context_utilization(上下文利用率):表示模型利用检索到的上下文信息生成回答的有效程度。较高的利用率意味着更多的检索内容被合理地使用到了输出中。
  • noise_sensitivity_in_relevant(相关噪声敏感性):描述的是模型对于相关但可能引入误导信息的上下文的敏感度。较低的数值通常是更好的,因为它表明模型可以较好地区分有用信息和潜在的噪音。
  • noise_sensitivity_in_irrelevant(无关噪声敏感性):指的是模型对于完全不相关上下文的敏感度。理想的值为0,表示模型不会被无关信息所影响。
  • hallucination(幻觉):表示模型产生没有根据的回答或者包含错误信息的比例。低幻觉率是理想的情况,因为它表明模型的回答是基于真实和可靠的数据。
  • self_knowledge(自知力):反映了模型在多大程度上依赖其内部知识而非检索到的信息来生成答案。低自知力通常更好,因为它强调了模型对外部信息的依赖,从而确保了信息的新鲜度和准确性。
  • faithfulness(忠实度):表示模型生成的答案与其检索到的支持信息之间的符合程度。高的忠实度意味着生成的内容更有可能是准确和可靠的。

果然不出所料,整个评估会出现巨大的偏差。面对这种情况我觉得应该从以下两点入手:

  1. 生成问答对用高参数模型,评估用低参数模型;
  2. 生成问答对的提示词需要优化,在提供的信息量不足的情况下,使用模型自身的数据;

最起码要先保证评估的标准答案是正确且准确的(唉,我要是有钱我直接调 GPT4、Claude 的 API,还搞这么麻烦么),如果评估结果还是不理想再想想其他原因。

模型互换

还是那台 A6000 的服务器,在考虑 Ollama 和 RAG 都能够完整获取资源的前提下,生成问答对时将使用 Qwen2.5 的 32B 模型(找了一轮,在优先保证对中文理解能力的前提下,最大限度满足资源使用需要的只有 Qwen 的 32B 了。再高一点的参数模型根本拉不动)。而在 RAGChecker 评估的时候反而使用了 Qwen2.5 的 7B 就足够了。

标准答案生成提示词优化

从代码中能够看出,原本提交给 Ollama 进行问答对生成的信息是这样的:

you_question = [
    {
        "role": "user",
        "content": f"{content + self.ask}"
    }
]

这其中,content 是从 Elasticsearch 中查询的信息,而后面 self.ask 就是补充说明,具体是什么呢?就是这样的

"根据这段内容生成2个问题及答案,要求问题和答案必须从这段内容中提取,在回答时问题请以问题开头,答案请以答案开头,并且问题中不要出现根据这段内容、根据提供的内容、根据给出的字样。"

Yo~~虽然这段提示里面说明了生成的要求和一些简单的格式,但坦白说以 Qwen 系列的自律性来说,如此简单的补充说明不足以约束它的生成结构。

那该怎么做呢?

you_question = [
    {
        "role": "system",
        "content": f"{self.prompt}"
    },
    {
        "role": "user",
        "content": f"""
                        提供参考信息:
                        {content}

                        任务说明:
                        根据上述参考信息生成1个问答对。如果参考信息不足,请使用模型知识库补充回答,确保答案完整准确。

                        输出格式要求:
                        问题:[直接提出问题,不要使用"根据内容"等引导语]
                        答案:[详细完整的答案,包含以下要素]
                        - 直接回答问题的核心内容
                        - 补充相关的细节信息
                        - 提供实用的建议或注意事项
                        - 使用流畅自然的语言
                        - 确保专业准确性的同时保持通俗易懂
                        - 答案长度建议在150-300字之间

                        注意事项:
                        1. 严格按照"问题:"和"答案:"的格式输出
                        2. 只输出一个问答对
                        3. 不要加入任何额外的标记或说明
                        4. 不使用markdown格式
                        """
    }
]

如上所示,既然现在采用的是 Ollama 的 chat 模式,那先增加一个 prompt 约束一下模型的角色和定位(各个行业、领域的可以根据自己的要求进行定义)。接着就是提示词的主体了,参考信息和要求分开进行说明,其中输出格式一定要将自己的要求明确列举,最后补上注意事项,再次强调格式问题。好了现在看看输出的结果怎么样了。

嗯...有点像模像样了,接下来就可以尝试进行模拟了,结果如下:

RAGResults(
  3,554 RAG results,
  Metrics:
  {
    "overall_metrics": {
      "precision": 40.1,
      "recall": 40.3,
      "f1": 33.9
    },
    "retriever_metrics": {
      "claim_recall": 38.1,
      "context_precision": 70.2
    },
    "generator_metrics": {
      "context_utilization": 40.1,
      "noise_sensitivity_in_relevant": 12.1,
      "noise_sensitivity_in_irrelevant": 3.6,
      "hallucination": 43.9,
      "self_knowledge": 21.0,
      "faithfulness": 34.9
    }
  }
)

虽然测试条数没有 4000 ,但也可以看出整个效果是有所提升,也侧面印证了这个思路是正确的。既然这样先从成本最低的优化开始吧(优化提示词),看看效果是否会有进一步飞跃。

RAG 提示词优化和评估模型交叉试验

那么我要如何进行提示词的优化呢?

这里我选择了阿里云百炼的“Prompt 工程”进行提示词自动优化,如下图:

提示词我就不展示了,我们直接看看结果吧:

RAGResults(
  3,651 RAG results,
  Metrics:
  {
    "overall_metrics": {
      "precision": 38.2,
      "recall": 44.2,
      "f1": 34.9
    },
    "retriever_metrics": {
      "claim_recall": 38.0,
      "context_precision": 69.8
    },
    "generator_metrics": {
      "context_utilization": 41.8,
      "noise_sensitivity_in_relevant": 12.4,
      "noise_sensitivity_in_irrelevant": 3.6,
      "hallucination": 45.5,
      "self_knowledge": 19.8,
      "faithfulness": 34.4
    }
  }
)

整体效果不太明显。对于这个结果暂时未能想到更好的优化方案,于是又重新看了一遍 RAGChecker 的自述文档(https://github.com/amazon-science/RAGChecker/blob/main/tutorial/ragchecker_tutorial_zh.md)。

嗯?官方实例里面是使用的 Llama3.1 70B 进行评估的。如下图:

嗯... Llama 系列和 Qwen 系列之间的确存在差距的。虽然没有资源运行 70B 模型,但可以用本地的 llama3:8b-instruct-q5_K_M 对相同的数据集进行了评估试试嘛,结果如下:

RAGResults(
  3,651 RAG results,
  Metrics:
  {
    "overall_metrics": {
      "precision": 46.5,
      "recall": 51.0,
      "f1": 43.5
    },
    "retriever_metrics": {
      "claim_recall": 59.4,
      "context_precision": 98.4
    },
    "generator_metrics": {
      "context_utilization": 72.8,
      "noise_sensitivity_in_relevant": 16.9,
      "noise_sensitivity_in_irrelevant": 0.0,
      "hallucination": 33.8,
      "self_knowledge": 6.6,
      "faithfulness": 57.2
    }
  }
)

在数据集相同的前提下,呈现出两种不同的结果。可以看到 overall_metrics 指标都普遍在 40 以上了,而且 context_precision(上下文精确率)达到了 98.4,hallucination(幻觉)则降低到 33。最最主要的是 noise_sensitivity_in_irrelevant(无关噪声敏感性)已降为 0。

既然用两个模型出来的结果不一样,那么接下来的优化就分别使用 Qwen 和 Llama 两个模型进行评估吧,若整体趋势都是向上的,那么就可以认为优化的方向是对的。

至于后面多轮测试的结果我就不放上来了(要解释就必须做大量数据脱敏,快要过年了有点懒就不做了 (˘³˘)),但我可以跟各位分享一下我的 RAGChecker 使用总结的:

  1. 生成标准答案时提示词一定要做好,最好排除掉所有的格式和特殊符号;
  2. 生成标准答案最佳实践是调用商用大模型 API(如:文心一言、GPT-4o、Claude Sonnet 等),千万不要过分信赖泛化开源模型给您生成的结果(什么 70B、405B...多少 B 都是扯淡)。经验证,两者在垂直领域的知识储备存在巨大差距,导致最终评估结果存在误导性;
  3. 本地 RAG 应用的提示词调整是最快提升评估分数的,应该多花点心思在提示词建设中;
  4. 有条件的情况下还是用跟官网一样的 Llama 3.1 的 70B 模型做数据评估会比较好,参数低模型在某些语境下是无法理解的(最终我也是购买了第三方算力接口来完成的,A6000 还是不够看呢);

至此,RAGChecker 测试正式结束。

(未完待续...)