鲁棒问答系统:结合 Chroma 和 OpenAI

本 Notebook 将引导您逐步完成关于数据集合的问答,使用开源向量数据库 Chroma 以及 OpenAI 的 文本嵌入聊天补全 API。

此外,本 Notebook 还演示了一些使问答系统更鲁棒的权衡。正如我们将看到的,简单的查询并不总能产生最佳结果

使用 LLM 进行问答

像 OpenAI 的 ChatGPT 这样的大型语言模型(LLM)可以用来回答关于模型未训练或无法访问的数据的问题。例如:

  • 个人数据,如电子邮件和笔记
  • 高度专业化的数据,如档案或法律文件
  • 新创建的数据,如近期新闻报道

为了克服这个限制,我们可以使用一个易于用自然语言查询的数据存储,就像 LLM 本身一样。像 Chroma 这样的嵌入存储将文档与其自身一起表示为 嵌入

通过嵌入文本查询,Chroma 可以找到相关文档,然后我们可以将这些文档传递给 LLM 来回答我们的问题。我们将展示此方法的详细示例和变体。

设置和准备工作

首先,我们确保安装了所需的 Python 依赖项。

%pip install -qU openai chromadb pandas
注意:您可能需要重新启动内核才能使用更新的包。

我们在整个 Notebook 中使用 OpenAI 的 API。您可以在 https://beta.openai.com/account/api-keys 获取 API 密钥。

您可以通过在终端中执行命令 export OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 将 API 密钥添加为环境变量。请注意,如果环境变量尚未设置,您需要重新加载 Notebook。或者,您也可以在 Notebook 中设置它,如下所示。

import os
from openai import OpenAI

# 取消注释以下行以在 Notebook 中设置环境变量
# os.environ["OPENAI_API_KEY"] = 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'

api_key = os.getenv("OPENAI_API_KEY")

if api_key:
    client = OpenAI(api_key=api_key)
    print("OpenAI 客户端已准备就绪")
else:
    print("未找到 OPENAI_API_KEY 环境变量")
OpenAI 客户端已准备就绪
# 设置所有 API 调用的模型
OPENAI_MODEL = "gpt-4o"

数据集

在本 Notebook 中,我们使用 SciFact 数据集。这是一个经过专家注释的科学声明的精选数据集,并附带论文标题和摘要的文本语料库。根据语料库中的文档,每个声明可能得到支持、被否定,或者没有足够的证据来判断。

拥有语料库作为事实依据,使我们能够研究以下 LLM 问答方法的性能。

# 加载声明数据集
import pandas as pd

data_path = '../../data'

claim_df = pd.read_json(f'{data_path}/scifact_claims.jsonl', lines=True)
claim_df.head()
id claim evidence cited_doc_ids
0 1 0-dimensional biomaterials show inductive prop... {} [31715818]
1 3 1,000 genomes project enables mapping of genet... {'14717500': [{'sentences': [2, 5], 'label': '... [14717500]
2 5 1/2000 in UK have abnormal PrP positivity. {'13734012': [{'sentences': [4], 'label': 'SUP... [13734012]
3 13 5% of perinatal mortality is due to low birth ... {} [1606628]
4 36 A deficiency of vitamin B12 increases blood le... {} [5152028, 11705328]

直接询问模型

ChatGPT 在大量科学信息上进行了训练。作为基准,我们想了解模型在没有任何额外上下文的情况下已经知道了什么。这将使我们能够校准整体性能。

我们构建一个带有示例事实的适当提示,然后用数据集中的每个声明查询模型。我们要求模型将声明评估为“True”(真)、“False”(假)或“NEE”(没有足够证据)。

def build_prompt(claim):
    return [
        {"role": "system", "content": "我将要求您评估一项科学声明。仅输出文本“True”表示声明为真,“False”表示声明为假,或“NEE”表示没有足够证据。"},
        {"role": "user", "content": f"""
示例:

声明:
0-dimensional biomaterials show inductive properties.

评估:
False

声明:
1/2000 in UK have abnormal PrP positivity.

评估:
True

声明:
Aspirin inhibits the production of PGE2.

评估:
False

示例结束。评估以下声明:

声明:
{claim}

评估:
"""}
    ]


def assess_claims(claims):
    responses = []
    # 查询 OpenAI API
    for claim in claims:
        response = client.chat.completions.create(
            model=OPENAI_MODEL,
            messages=build_prompt(claim),
            max_tokens=3,
        )
        # 剥离响应中的任何标点符号或空格
        responses.append(response.choices[0].message.content.strip('., '))

    return responses

我们从数据集中采样 50 个声明。

# 让我们看 50 个声明
samples = claim_df.sample(50)

claims = samples['claim'].tolist()

我们根据数据集评估真实情况。根据数据集描述,每个声明要么被证据支持,要么被证据否定,否则就没有足够的证据来判断。

def get_groundtruth(evidence):
    groundtruth = []
    for e in evidence:
        # 证据为空
        if len(e) == 0:
            groundtruth.append('NEE')
        else:
            # 在此数据集中,给定声明的所有证据都是一致的,要么是 SUPPORT(支持)要么是 CONTRADICT(否定)
            if list(e.values())[0][0]['label'] == 'SUPPORT':
                groundtruth.append('True')
            else:
                groundtruth.append('False')
    return groundtruth
evidence = samples['evidence'].tolist()
groundtruth = get_groundtruth(evidence)

我们还输出混淆矩阵,将模型的评估与真实情况进行比较,并以易于阅读的表格显示。

def confusion_matrix(inferred, groundtruth):
    assert len(inferred) == len(groundtruth)
    confusion = {
        'True': {'True': 0, 'False': 0, 'NEE': 0},
        'False': {'True': 0, 'False': 0, 'NEE': 0},
        'NEE': {'True': 0, 'False': 0, 'NEE': 0},
    }
    for i, g in zip(inferred, groundtruth):
        confusion[i][g] += 1

    # 美化打印混淆矩阵
    print('\tGroundtruth')
    print('\tTrue\tFalse\tNEE')
    for i in confusion:
        print(i, end='\t')
        for g in confusion[i]:
            print(confusion[i][g], end='\t')
        print()

    return confusion

我们要求模型直接评估声明,不加任何额外上下文。

gpt_inferred = assess_claims(claims)
confusion_matrix(gpt_inferred, groundtruth)
    Groundtruth
    True    False   NEE
True    9   3   15  
False   0   3   2   
NEE 8   6   4





{'True': {'True': 9, 'False': 3, 'NEE': 15},
 'False': {'True': 0, 'False': 3, 'NEE': 2},
 'NEE': {'True': 8, 'False': 6, 'NEE': 4}}

结果

从这些结果中,我们发现 LLM 强烈倾向于将声明评估为真,即使它们是假的,并且也倾向于将假声明评估为没有足够证据。请注意,“没有足够证据”是指模型在没有额外上下文的情况下,对声明的评估。

添加上下文

现在我们添加语料库中的论文标题和摘要可用的额外上下文。本节展示了如何将文本语料库加载到 Chroma 中,使用 OpenAI 文本嵌入。

首先,我们加载文本语料库。

# 将语料库加载到 DataFrame 中
corpus_df = pd.read_json(f'{data_path}/scifact_corpus.jsonl', lines=True)
corpus_df.head()
doc_id title abstract structured
0 4983 Microstructural development of human newborn c... [Alterations of the architecture of cerebral w... False
1 5836 Induction of myelodysplasia by myeloid-derived... [Myelodysplastic syndromes (MDS) are age-depen... False
2 7912 BC1 RNA, the transcript from a master gene for... [ID elements are short interspersed elements (... False
3 18670 The DNA Methylome of Human Peripheral Blood Mo... [DNA methylation plays an important role in bi... False
4 19238 The human myelin basic protein gene is include... [Two human Golli (for gene expressed in the ol... False

将语料库加载到 Chroma

下一步是将语料库加载到 Chroma 中。给定一个嵌入函数,Chroma 将自动处理每个文档的嵌入,并将其与文本和元数据一起存储,从而简化查询。

我们实例化一个(临时的)Chroma 客户端,并为 SciFact 标题和摘要语料库创建一个集合。 Chroma 也可以在持久化配置中实例化;请参阅 Chroma 文档 以了解更多信息。

import chromadb
from chromadb.utils.embedding_functions import OpenAIEmbeddingFunction

# 我们初始化一个嵌入函数,并将其提供给集合。
embedding_function = OpenAIEmbeddingFunction(api_key=os.getenv("OPENAI_API_KEY"))

chroma_client = chromadb.Client() # 默认是临时的
scifact_corpus_collection = chroma_client.create_collection(name='scifact_corpus', embedding_function=embedding_function)

接下来我们加载语料库到 Chroma。由于此数据加载是内存密集型的,我们建议使用分批加载方案,每批 50-1000 个。对于此示例,加载整个语料库大约需要一分钟多一点。它正在使用我们之前指定的 embedding_function 在后台自动嵌入。

batch_size = 100

for i in range(0, len(corpus_df), batch_size):
    batch_df = corpus_df[i:i+batch_size]
    scifact_corpus_collection.add(
        ids=batch_df['doc_id'].apply(lambda x: str(x)).tolist(), # Chroma 需要字符串 ID。
        documents=(batch_df['title'] + '. ' + batch_df['abstract'].apply(lambda x: ' '.join(x))).to_list(), # 我们连接标题和摘要。
        metadatas=[{"structured": structured} for structured in batch_df['structured'].to_list()] # 我们也存储元数据,尽管在此示例中我们不使用它。
    )

检索上下文

接下来,我们从语料库中检索可能与我们样本中的每个声明相关的文档。我们希望将这些作为上下文提供给 LLM 以评估声明。我们根据嵌入距离检索每个声明的 3 个最相关文档。

claim_query_result = scifact_corpus_collection.query(query_texts=claims, include=['documents', 'distances'], n_results=3)

我们创建一个新的提示,这次考虑我们从语料库中检索到的额外上下文。

def build_prompt_with_context(claim, context):
    return [{'role': 'system', 'content': "我将要求您根据提供的证据评估一个特定的科学声明。仅输出文本“True”表示声明为真,“False”表示声明为假,或“NEE”表示没有足够证据。"},
            {'role': 'user', 'content': f""""
证据如下:

{' '.join(context)}

请根据证据评估以下声明。仅输出文本“True”表示声明为真,“False”表示声明为假,或“NEE”表示没有足够证据。不要输出任何其他文本。

声明:
{claim}

评估:
"""}]


def assess_claims_with_context(claims, contexts):
    responses = []
    # 查询 OpenAI API
    for claim, context in zip(claims, contexts):
        # 如果没有提供证据,则返回 NEE
        if len(context) == 0:
            responses.append('NEE')
            continue
        response = client.chat.completions.create(
            model=OPENAI_MODEL,
            messages=build_prompt_with_context(claim=claim, context=context),
            max_tokens=3,
        )
        # 剥离响应中的任何标点符号或空格
        responses.append(response.choices[0].message.content.strip('., '))

    return responses

然后要求模型使用检索到的上下文来评估声明。

gpt_with_context_evaluation = assess_claims_with_context(claims, claim_query_result['documents'])
confusion_matrix(gpt_with_context_evaluation, groundtruth)
    Groundtruth
    True    False   NEE
True    13  1   4   
False   1   10  2   
NEE 3   1   15





{'True': {'True': 13, 'False': 1, 'NEE': 4},
 'False': {'True': 1, 'False': 10, 'NEE': 2},
 'NEE': {'True': 3, 'False': 1, 'NEE': 15}}

结果

我们看到模型整体表现更好,并且现在能够更准确地识别虚假声明。此外,现在大多数 NEE 情况也能被正确识别。

查看检索到的文档,我们发现它们有时与声明不相关——这会导致模型因额外信息而感到困惑,并且它可能会认为存在足够的证据,即使信息不相关。这是因为我们总是请求 3 个“最相关”的文档,但这些文档可能根本不相关。这是因为我们总是请求 3 个“最相关”的文档,但这些文档可能根本不相关。

基于相关性过滤上下文

除了文档本身,Chroma 还返回距离分数。我们可以尝试对距离进行阈值处理,以便更少的不相关文档进入我们提供给模型的上下文。

如果经过阈值过滤后没有剩余的上下文文档,我们将绕过模型,直接返回没有足够证据。

def filter_query_result(query_result, distance_threshold=0.25):
# 对于每个查询结果,仅保留距离低于阈值的文档
    for ids, docs, distances in zip(query_result['ids'], query_result['documents'], query_result['distances']):
        for i in range(len(ids)-1, -1, -1):
            if distances[i] > distance_threshold:
                ids.pop(i)
                docs.pop(i)
                distances.pop(i)
    return query_result
filtered_claim_query_result = filter_query_result(claim_query_result)

现在我们使用这个更干净的上下文来评估声明。

gpt_with_filtered_context_evaluation = assess_claims_with_context(claims, filtered_claim_query_result['documents'])
confusion_matrix(gpt_with_filtered_context_evaluation, groundtruth)
    Groundtruth
    True    False   NEE
True    9   0   1   
False   0   7   0   
NEE 8   5   20





{'True': {'True': 9, 'False': 0, 'NEE': 1},
 'False': {'True': 0, 'False': 7, 'NEE': 0},
 'NEE': {'True': 8, 'False': 5, 'NEE': 20}}

结果

模型现在评估为“True”或“False”的声明要少得多,当没有足够证据时。然而,它现在也更加谨慎,倾向于将大多数项目标记为证据不足,从而避免确定性。大多数声明现在被评估为证据不足,因为其中很大一部分被距离阈值过滤掉了。可以调整距离阈值以找到最佳操作点,但这可能很困难,并且取决于数据集和嵌入模型。

假设文档嵌入:有建设性地使用幻觉

我们希望能够检索相关文档,同时避免检索可能使模型混淆的不太相关的文档。一种方法是改进检索查询。

到目前为止,我们一直使用声明(单句陈述)来查询数据集,而语料库包含描述科学论文的摘要。直观地说,虽然它们可能相关,但在它们的结构和含义上存在显著差异。这些差异由嵌入模型编码,因此会影响查询与最相关结果之间的距离。

我们可以通过利用 LLM 生成相关文本的能力来克服这一点。虽然事实可能是幻觉,但模型生成的文档的内容和结构比查询更相似。这可能导致更好的查询,从而产生更好的结果。

这种方法称为 假设文档嵌入 (HyDE),在检索任务中表现相当好。它应该有助于我们将更多相关信息带入上下文,而不会污染它。

简而言之:

  • 嵌入整个摘要比嵌入单个句子能获得更好的匹配
  • 但声明通常是单句的
  • 因此 HyDE 表明,使用 GPT3 将声明扩展为幻觉摘要,然后基于这些摘要进行搜索(声明 -> 摘要 -> 结果)比直接搜索(声明 -> 结果)效果更好。

首先,我们使用上下文内示例来提示模型为我们要评估的每个声明生成与语料库中内容相似的文档。

def build_hallucination_prompt(claim):
    return [{'role': 'system', 'content': """我将要求您为支持或反驳给定声明的科学论文撰写摘要。它应该用科学语言书写,包含标题。仅输出一个摘要,然后停止。

    示例:

    声明:
    A high microerythrocyte count raises vulnerability to severe anemia in homozygous alpha (+)-thalassemia trait subjects.

    摘要:
    背景:α(+)-地中海贫血是一种遗传性血红蛋白病,由正常成人血红蛋白(Hb)一部分的 α-珠蛋白链合成减少引起。α(+)-地中海贫血纯合子具有小细胞性贫血和红细胞计数增加。α(+)-地中海贫血纯合子对严重疟疾(包括严重疟疾性贫血(SMA)(Hb 浓度 < 50 g/l))具有相当大的保护作用,但不会影响寄生虫计数。我们测试了这样一个假设:与严重疟疾相比,α(+)-地中海贫血纯合子相关的血液学指标是否能提供血液学益处。
    方法和结果:对居住在巴布亚新几内亚北部海岸、参与一项关于 α(+)-地中海贫血对严重疟疾保护作用的病例对照研究的儿童的数据进行了重新分析,以评估与急性疟疾相关的基因型特异性红细胞计数和 Hb 水平的降低。我们观察到,与社区儿童相比,所有患有急性恶性疟的儿童的红细胞计数中位数减少了约 1.5 x 10(12)/l(p < 0.001)。我们开发了一个简单的数学模型,用于描述 Hb 浓度和红细胞计数之间的线性关系。该模型预测,与正常基因型的儿童相比,α(+)-地中海贫血纯合子儿童在红细胞计数减少 >1.1 x 10(12)/l 时会损失更多的 Hb,这是由于 α(+)-地中海贫血纯合子的平均细胞 Hb 降低所致。此外,与正常基因型的儿童相比,α(+)-地中海贫血纯合子儿童需要 10% 的红细胞计数减少(p = 0.02),Hb 浓度才会降至 SMA 的临界值 50 g/l。我们估计,与正常基因型的儿童相比,α(+)-地中海贫血纯合子儿童患 SMA 的风险降低(相对风险 0.52;95% 置信区间 [CI] 0.24-1.12,p = 0.09)。
    结论:α(+)-地中海贫血纯合子儿童增加的红细胞计数和小细胞性贫血可能对其免受 SMA 的保护作用有显著贡献。每红细胞血红蛋白浓度较低以及红细胞数量较多,可能是对抗恶性疟原虫引起的红细胞计数显著减少的生物学优势策略。这种血液学特征可能通过其他疟原虫物种以及其他原因引起的贫血降低了患 SMA 的风险。诱导增加红细胞计数和小细胞性贫血的其他宿主多态性可能具有类似的优势。

    示例结束。

    """}, {'role': 'user', 'content': f""""
    为以下声明执行任务。

    声明:
    {claim}

    摘要:
    """}]


def hallucinate_evidence(claims):
    responses = []
    # 查询 OpenAI API
    for claim in claims:
        response = client.chat.completions.create(
            model=OPENAI_MODEL,
            messages=build_hallucination_prompt(claim),
        )
        responses.append(response.choices[0].message.content)
    return responses

我们为每个声明生成一个文档。

注意:这可能需要一段时间,100 个声明大约需要 7 分钟。您可以减少要评估的声明数量以更快地获得结果。

hallucinated_evidence = hallucinate_evidence(claims)

我们使用生成的文档作为查询语料库,并使用相同的距离阈值过滤结果。

hallucinated_query_result = scifact_corpus_collection.query(query_texts=hallucinated_evidence, include=['documents', 'distances'], n_results=3)
filtered_hallucinated_query_result = filter_query_result(hallucinated_query_result)

然后,我们要求模型使用新的上下文来评估声明。

gpt_with_hallucinated_context_evaluation = assess_claims_with_context(claims, filtered_hallucinated_query_result['documents'])
confusion_matrix(gpt_with_hallucinated_context_evaluation, groundtruth)
    Groundtruth
    True    False   NEE
True    13  0   3   
False   1   10  1   
NEE 3   2   17





{'True': {'True': 13, 'False': 0, 'NEE': 3},
 'False': {'True': 1, 'False': 10, 'NEE': 1},
 'NEE': {'True': 3, 'False': 2, 'NEE': 17}}

结果

将 HyDE 与简单的距离阈值相结合可以带来显著的改进。模型不再偏向于评估声明为真,也不偏向于证据不足。它还能更频繁地正确评估何时没有足够的证据。

结论

为 LLM 配备基于文档语料库的上下文,是将 LLM 的通用推理和自然语言交互应用于您自己的数据的一项强大技术。但是,了解朴素的查询和检索可能无法产生最佳结果非常重要!最终,理解数据将有助于充分利用基于检索的问答方法。