【每周一读】Automating Hyperparameter Tuning with LlamaIndex

原文🔗:https://levelup.gitconnected.com/automating-hyperparameter-tuning-with-llamaindex-72fdd68e3b90

原文作者:Wenqi Glantz

这篇文章刚好是我目前非常需要的,基于 LlamaIndex 的 ParamTuner对 RAG 的超参数进行自动化调整,还涉及用DatasetGenerator根据文档内容自动生成问答对,这对于不存在 Ground Truth 的 LLM 答案的评估来说非常友好。不过目前实习公司的项目是基于 Langchain 的,不知道 Langchain 有没有类似ParamTunerDatasetGenerator的类。

LlamaIndex 的推文里放出了这张图:

RAG 的大致流程就是如此,而淡蓝色方框里面是我们关心的超参数。正如推文所述,RAG 面临的一个巨大问题就是有太多超参数需要调整,而且它远远超出了prompting的范围:分块、检索策略、元数据......而本文提供了一种新方案,可以自动/高效地执行此操作。

超参数

文章中重点调整的是以下两个超参数:

  • chunk_size
  • similarity_top_k

chunk_size 决定检索到的文本块的大小。较小的 chunk_size 可能会导致更频繁的检索,从而可能以更高的计算开销为代价提高检索精度。相反,较大的 chunk_size 可能会减少检索次数,但可能导致匹配不完整或不太精确。Similarity_top_k 确定在检索阶段有多少排名靠前的块被考虑在生成阶段进行进一步处理。较高的 similarity_top_k 增加了检索到的候选者的多样性,可能为模型提供更丰富的信息来生成响应。然而,它也增加了计算负担。

调整这两个参数可以在计算效率和检索信息质量之间权衡。

评估器选择

我们用 LlamaIndex 的评估模块来帮助调整参数。 在我们这个用例中,鉴于 chunk_sizesimilarity_top_k 主要影响检索阶段,因此选择 SemanticSimilarityEaluator 来评估检索。 SemanticSimilarityEaluator 计算生成答案和参考答案的嵌入之间的相似度得分。

原文作者还尝试了另一个 LlamaIndex 评估器 CorrectnessEvaluator ,它主要处理 RAG 的生成阶段,得分最高的参数组合与从 SemanticSimilarityEaluator 得到的参数组合不同。可见,选择正确的评估器对参数调整来说至关重要,错误的评估器也会导致错误的参数选择。

作者遵循了 LlamaIndex 指南中推荐的方法:

  1. 加载文档。
  2. 生成评估问题/答案对。
  3. 为三组 chunk_size 和三组 similarity_top_k 构建索引、查询引擎并收集参数,得到 9 个参数组合。
  4. 定义 EDD(评估驱动开发)来衡量每个参数组合的语义相似度的分数。
  5. 运行 ParamTuner 来调整参数。 ParamTuner 通过尝试不同的参数组合并用每个组合指定的目标函数来调整超参数,根据目标函数得出的分数评估结果,并返回最佳组合。

超参数调优

1 - 加载文档

首先加载测试文档,一个8 页的 PDF 文档。

documents = SimpleDirectoryReader("data").load_data()
print(f"loaded documents with {len(documents)} documents")

# Use the new flattened interface for node parsing
node_parser = SentenceSplitter(chunk_size=256)
nodes = node_parser(documents)
print(f"loaded {len(nodes)} nodes")

2 - 生成评估问题/答案对

  • 作者使用 GPT-4 Turbo ( gpt-4–1106-preview ) 生成评估数据集。
  • 如果评估数据集 JSON 文件已存在,加载它。如果没有,调用 DatasetGenerator 生成问答数据集并将数据集保存到 JSON 文件。
from llama_index.evaluation import (
    DatasetGenerator,
    QueryResponseDataset,
)

eval_service_context = ServiceContext.from_defaults(llm=OpenAI(model="gpt-4-1106-preview"))

# load eval question/answer dataset from JSON file if exists
if os.path.exists("data/eval_qr_dataset.json"):
    eval_dataset = QueryResponseDataset.from_json("data/eval_qr_dataset.json")
else:
    # construct dataset_generator
    dataset_generator = DatasetGenerator(
        nodes[:8],
        service_context=eval_service_context,
        show_progress=True,
        num_questions_per_chunk=2,
    )

    # generate queries and responses
    eval_dataset = dataset_generator.generate_dataset_from_nodes()

    # save the dataset into a file
    eval_dataset.save_json("data/eval_qr_dataset.json")

上述代码选取了前 8 个节点,为每个节点生成 2 个问题,因此现在得到了 16 对问题/答案。让我们加载 JSON 文件并打印其内容。

import json

# Load dataset from JSON file
with open("data/eval_qr_dataset.json", "r") as file:
    eval_dataset_content = json.load(file)

# Print the content in JSON format
json_str = json.dumps(eval_dataset_content, indent=2)  # indent for pretty printing
print(json_str)

然后,我们将问题和答案分成两个不同的字典,一个用于查询,另一个用于响应,每个字典都以查询 id 作为键:

eval_qs = eval_dataset.questions
ref_response_strs = [r for (_, r) in eval_dataset.qr_pairs]

3 - 构建索引、查询引擎并收集参数

我们定义一个辅助函数 _build_index 来为文档构建索引, chunk_size 作为参数之一传入。

def _build_index(chunk_size, docs):
    index_out_path = f"./storage_{chunk_size}"
    if not os.path.exists(index_out_path):
        Path(index_out_path).mkdir(parents=True, exist_ok=True)
        
        # Using the new flattened interface for node parsing
        node_parser = SentenceSplitter(chunk_size=chunk_size)
        nodes = node_parser(docs)

        # build index
        index = VectorStoreIndex(nodes)

        # save index to disk
        index.storage_context.persist(index_out_path)
    else:
        # rebuild storage context
        storage_context = StorageContext.from_defaults(
            persist_dir=index_out_path
        )
        # load index
        index = load_index_from_storage(
            storage_context,
        )
    return index

这边用到了一种把 index 存储在硬盘上的方法,这样就不用每次再重新做索引这个步骤了,大大缩短了时间。同样,不知道 Langchain 有没有类似的技术。

现在我们将要调节的参数收集在字典param_dict中,还需要一个fixed_param_dict字典来保存文档、评估问题和参考答案。param_dict 包含需要调整的参数,而 fixed_param_dict 中的参数在调整过程中保持固定。

# contains the parameters that need to be tuned
param_dict = {"chunk_size": [256, 512, 1024], "top_k": [1, 2, 5]}

# contains parameters remaining fixed across all runs of the tuning process
fixed_param_dict = {
    "docs": documents,
    "eval_qs": eval_qs,
    "ref_response_strs": ref_response_strs,
}

4 - 定义 EDD 来衡量每个参数组合的分数

我们定义一个辅助函数 _get_eval_batch_runner_semantic_similarity 来为语义相似度评估器创建一个 BatchEvalRunner

def _get_eval_batch_runner_semantic_similarity():
    eval_service_context = ServiceContext.from_defaults(
        llm=OpenAI(model="gpt-4-1106-preview")
    )
    evaluator_s = SemanticSimilarityEvaluator(
        service_context=eval_service_context
    )
    eval_batch_runner = BatchEvalRunner(
        {"semantic_similarity": evaluator_s}, workers=2, show_progress=True
    )

    return eval_batch_runner

定义目标函数 objective_function_semantic_similarity 来构建索引和查询引擎,根据评估问题获取响应,并运行评估器。

def objective_function_semantic_similarity(params_dict):
    chunk_size = params_dict["chunk_size"]
    docs = params_dict["docs"]
    top_k = params_dict["top_k"]
    eval_qs = params_dict["eval_qs"]
    ref_response_strs = params_dict["ref_response_strs"]

    # build index
    index = _build_index(chunk_size, docs)

    # query engine
    query_engine = index.as_query_engine(similarity_top_k=top_k)

    # get predicted responses
    pred_response_objs = get_responses(
        eval_qs, query_engine, show_progress=True
    )

    # run evaluator
    eval_batch_runner = _get_eval_batch_runner_semantic_similarity()
    eval_results = eval_batch_runner.evaluate_responses(
        eval_qs, responses=pred_response_objs, reference=ref_response_strs
    )

    # get semantic similarity metric
    mean_score = np.array(
        [r.score for r in eval_results["semantic_similarity"]]
    ).mean()

    return RunResult(score=mean_score, params=params_dict)

5 - 运行 ParamTuner

最后,我们运行 ParamTuner 来查找语义相似性得分最高的参数组合。 ParamTuner 类是一个简单的超参数调整框架,它通过尝试不同的组合并对每个组合运行指定的函数来调整超参数。ParamTuner 的主要活动包括:

  • 通过调用 generate_param_combinations 函数生成参数组合,该函数根据给定的 param_dict 生成超参数值的所有可能组合。
  • 对于每个生成的参数组合,使用当前参数组合调用的指定函数以获取 RunResult ,其中包括分数、所用的参数和可选元数据。结果收集在 all_run_results 列表中。
  • 根据 RunResult 对象的分数按降序对它们进行排序。
from llama_index.param_tuner import ParamTuner

param_tuner = ParamTuner(
    param_fn=objective_function_semantic_similarity,
    param_dict=param_dict,
    fixed_param_dict=fixed_param_dict,
    show_progress=True,
)

results = param_tuner.tune()

best_result = results.best_run_result
best_top_k = results.best_run_result.params["top_k"]
best_chunk_size = results.best_run_result.params["chunk_size"]

print("")
print(f"Semantic Similarity Score: {best_result.score}")
print(f"Top-k: {best_top_k}")
print(f"Chunk size: {best_chunk_size}")

将结果可视化:

根据图表,我们得出语义相似度的最佳得分来自 chunk_size 1024 和 similarity_top_k 5 的组合。 chunk_size 的组合 512 和 similarity_top_k 1 返回最差分数。

关于成本

ParamTuner 并不是免费的:

  • 作者使用 GPT-4 Turbo 作为数据集生成和参数评估的模型。其成本为 0.01 美元/1000 个输入代币和 0.03 美元/1000 个输出代币。
  • 参数组合的数量根据参数数量及其期望调整的值呈指数增长。在本文例子中,只调了两个参数 chunk_sizesimilarity_top_k ,每个参数都有三个值,从而产生 9 个参数组合。

对于本文的示例 RAG,调整语义相似性的成本约为 0.40 美元。

总结

本文探讨了 LlamaIndex 提供的超参数调整功能,深入研究了实现 ParamTuner 的详细步骤,以自动调整 RAG 中的参数。通过类似的方法,我们还可以在 RAG 的检索或生成阶段调整其他参数。

本文源码在作者的 Colab 笔记本可以找到。

有机会自己用开源大模型试一试,以及研究一下怎么用 Langchain 实现。

欢迎大家在评论区留言讨论!

posted @ 2024-02-29 19:04  Aikoin  阅读(78)  评论(0编辑  收藏  举报