Skip to content

从文本中提取术语和定义的指南#

Llama Index 有许多用例(语义搜索、摘要等),这些用例都有很好的文档记录。但这并不意味着我们不能将 Llama Index 应用于非常特定的用例!

在本教程中,我们将介绍使用 Llama Index 从文本中提取术语和定义的设计过程,同时允许用户稍后查询这些术语。使用 Streamlit,我们可以提供一种简单的方法来构建前端,运行和测试所有这些,并快速迭代我们的设计。

本教程假定您已安装了 Python3.9+ 和以下软件包:

  • llama-index
  • streamlit

在基本层面上,我们的目标是从文档中提取术语和定义,然后提供一种让用户查询这些术语和定义的知识库的方法。本教程将介绍 Llama Index 和 Streamlit 的功能,并希望为常见问题提供一些有趣的解决方案。

本教程的最终版本可以在这里找到,还可以在Huggingface Spaces上找到一个实时托管的演示。

上传文本#

第一步是让用户有一种上传文档的方式。让我们使用 Streamlit 编写一些代码来提供这个界面!使用以下代码并使用 streamlit run app.py 来启动应用。

import streamlit as st

st.title("🦙 Llama Index 术语提取器 🦙")

document_text = st.text_area("或输入原始文本")
if st.button("提取术语和定义") and document_text:
    with st.spinner("提取中..."):
        extracted_terms = document_text  # 这只是一个占位符!
    st.write(extracted_terms)

非常简单对吧!但是您会注意到,该应用目前还没有任何有用的功能。要使用 llama_index,我们还需要设置我们的 OpenAI LLM。LLM 有许多可能的设置,因此我们可以让用户自行决定最佳设置。我们还应该让用户设置用于提取术语的提示(这也将帮助我们调试最佳设置)。

LLM 设置#

下一步是向我们的应用程序添加一些选项卡,将其分成提供不同功能的不同窗格。让我们为 LLM 设置和上传文本创建一个选项卡:

import os
import streamlit as st

DEFAULT_TERM_STR = (
    "列出在上下文中定义的术语和定义的清单,每行一个对。"
    "如果一个术语缺少定义,使用您的最佳判断。"
    "按以下格式写每一行:\n术语: <term> 定义: <definition>"
)

st.title("🦙 Llama Index 术语提取器 🦙")

setup_tab, upload_tab = st.tabs(["设置", "上传/提取术语"])

with setup_tab:
    st.subheader("LLM 设置")
    api_key = st.text_input("在此输入您的 OpenAI API 密钥", type="password")
    llm_name = st.selectbox(
        "选择哪个 LLM?", ["text-davinci-003", "gpt-3.5-turbo", "gpt-4"]
    )
    model_temperature = st.slider(
        "LLM 温度", min_value=0.0, max_value=1.0, step=0.1
    )
    term_extract_str = st.text_area(
        "用于提取术语和定义的查询。",
        value=DEFAULT_TERM_STR,
    )

with upload_tab:
    st.subheader("提取和查询定义")
    document_text = st.text_area("或输入原始文本")
    if st.button("提取术语和定义") and document_text:
        with st.spinner("提取中..."):
            extracted_terms = document_text  # 这只是一个占位符!
        st.write(extracted_terms)

现在我们的应用程序有两个选项卡,这对组织非常有帮助。您还会注意到我添加了一个默认提示来提取术语 -- 以后您可以在尝试提取一些术语后更改这个提示,这只是我在试验一番后得出的提示。

说到提取术语,现在是时候添加一些函数来做到这一点了!

提取和存储术语#

现在我们能够定义 LLM 设置并上传文本,我们可以尝试使用 Llama Index 来为我们从文本中提取术语!

我们可以添加以下函数来初始化我们的 LLM,以及使用它从输入文本中提取术语。

from llama_index.core import Document, SummaryIndex, load_index_from_storage
from llama_index.llms.openai import OpenAI
from llama_index.core import Settings


def get_llm(llm_name, model_temperature, api_key, max_tokens=256):
    os.environ["OPENAI_API_KEY"] = api_key
    return OpenAI(
        temperature=model_temperature, model=llm_name, max_tokens=max_tokens
    )


def extract_terms(
    documents, term_extract_str, llm_name, model_temperature, api_key
):
    llm = get_llm(llm_name, model_temperature, api_key, max_tokens=1024)

    temp_index = SummaryIndex.from_documents(
        documents,
    )
    query_engine = temp_index.as_query_engine(
        response_mode="tree_summarize", llm=llm
    )
    terms_definitions = str(query_engine.query(term_extract_str))
    terms_definitions = [
        x
        for x in terms_definitions.split("\n")
        if x and "Term:" in x and "Definition:" in x
    ]
    # parse the text into a dict
    terms_to_definition = {
        x.split("Definition:")[0]
        .split("Term:")[-1]
        .strip(): x.split("Definition:")[-1]
        .strip()
        for x in terms_definitions
    }
    return terms_to_definition
现在,使用新的功能,我们终于可以提取我们的术语了!

...
with upload_tab:
    st.subheader("提取和查询定义")
    document_text = st.text_area("或输入原始文本")
    if st.button("提取术语和定义") and document_text:
        with st.spinner("提取中..."):
            extracted_terms = extract_terms(
                [Document(text=document_text)],
                term_extract_str,
                llm_name,
                model_temperature,
                api_key,
            )
        st.write(extracted_terms)

现在有很多事情要做,让我们花点时间来了解正在发生的事情。

get_llm() 根据设置选项协助实例化 LLM。根据模型名称,我们需要使用适当的类(OpenAI vs. ChatOpenAI)。

extract_terms() 是所有好东西发生的地方。首先,我们使用 get_llm() 调用 max_tokens=1024,因为在提取我们的术语和定义时,我们不希望限制模型太多(如果未设置,默认为 256)。然后,我们定义我们的 Settings 对象,将 num_output 与我们的 max_tokens 值对齐,同时设置块大小不超过输出。当文档被 Llama Index 索引时,如果它们很大,它们会被分成块(也称为节点),chunk_size 设置了这些块的大小。

接下来,我们创建一个临时摘要索引并传入我们的 llm。摘要索引将阅读我们索引中的每一段文本,这对提取术语非常完美。最后,我们使用我们预定义的查询文本提取术语,使用 response_mode="tree_summarize"。这种响应模式将从底部向上生成摘要树,每个父节点总结其子节点。最后,返回树的顶部,其中将包含所有我们提取的术语和定义。

最后,我们进行一些次要的后处理。我们假设模型遵循了指令,并在每行放置了一个术语/定义对。如果一行缺少 Term:Definition: 标签,我们将跳过它。然后,我们将其转换为字典以便于存储!

保存提取的术语#

现在我们可以提取术语了,我们需要将它们放在某个地方,以便以后可以查询它们。一个 VectorStoreIndex 现在应该是一个完美的选择!但另外,我们的应用还应该跟踪插入到索引中的术语,以便以后可以检查它们。使用 st.session_state,我们可以将当前的术语列表存储在一个对每个用户唯一的会话字典中!

首先,让我们添加一个功能来初始化全局向量索引,以及另一个功能来插入提取的术语。

from llama_index.core import Settings

...
if "all_terms" not in st.session_state:
    st.session_state["all_terms"] = DEFAULT_TERMS
...


def insert_terms(terms_to_definition):
    for term, definition in terms_to_definition.items():
        doc = Document(text=f"Term: {term}\nDefinition: {definition}")
        st.session_state["llama_index"].insert(doc)


@st.cache_resource
def initialize_index(llm_name, model_temperature, api_key):
    """创建 VectorStoreIndex 对象。"""
    Settings.llm = get_llm(llm_name, model_temperature, api_key)

    index = VectorStoreIndex([])

    return index, llm


...

with upload_tab:
    st.subheader("提取和查询定义")
    if st.button("初始化索引并重置术语"):
        st.session_state["llama_index"] = initialize_index(
            llm_name, model_temperature, api_key
        )
        st.session_state["all_terms"] = {}

    if "llama_index" in st.session_state:
        st.markdown(
            "要么上传文档的图像/截图,要么手动输入文本。"
        )
        document_text = st.text_area("或输入原始文本")
        if st.button("提取术语和定义") and (
            uploaded_file or document_text
        ):
            st.session_state["terms"] = {}
            terms_docs = {}
            with st.spinner("提取中..."):
                terms_docs.update(
                    extract_terms(
                        [Document(text=document_text)],
                        term_extract_str,
                        llm_name,
                        model_temperature,
                        api_key,
                    )
                )
            st.session_state["terms"].update(terms_docs)

        if "terms" in st.session_state and st.session_state["terms"]:
            st.markdown("提取的术语")
            st.json(st.session_state["terms"])

            if st.button("插入术语?"):
                with st.spinner("正在插入术语"):
                    insert_terms(st.session_state["terms"])
                st.session_state["all_terms"].update(st.session_state["terms"])
                st.session_state["terms"] = {}
                st.experimental_rerun()
现在你真的开始充分利用 streamlit 的强大功能了!让我们从上传标签下的代码开始。我们添加了一个按钮来初始化向量索引,并将其存储在全局的 streamlit 状态字典中,同时重置当前提取的术语。然后,在从输入文本中提取术语后,我们再次将其存储在全局状态中,并让用户有机会在插入之前审查这些术语。如果按下插入按钮,我们将调用插入术语函数,更新我们全局的已插入术语跟踪,并从会话状态中删除最近提取的术语。

查询提取的术语/定义#

有了提取和保存的术语和定义,我们如何使用它们?用户又如何记住之前保存的内容呢?我们可以简单地向应用程序添加一些标签来处理这些功能。

...
setup_tab, terms_tab, upload_tab, query_tab = st.tabs(
    ["设置", "所有术语", "上传/提取术语", "查询术语"]
)
...
with terms_tab:
    with terms_tab:
        st.subheader("当前提取的术语和定义")
        st.json(st.session_state["all_terms"])
...
with query_tab:
    st.subheader("查询术语/定义!")
    st.markdown(
        (
            "LLM 将尝试回答您的查询,并使用您插入的术语/定义来增强其答案。"
            "如果索引中没有某个术语,它将使用内部知识进行回答。"
        )
    )
    if st.button("初始化索引并重置术语", key="init_index_2"):
        st.session_state["llama_index"] = initialize_index(
            llm_name, model_temperature, api_key
        )
        st.session_state["all_terms"] = {}

    if "llama_index" in st.session_state:
        query_text = st.text_input("询问术语或定义:")
        if query_text:
            query_text = (
                query_text
                + "\n如果找不到答案,请尽量用您的知识回答查询。"
            )
            with st.spinner("生成答案中..."):
                response = st.session_state["llama_index"].query(
                    query_text, similarity_top_k=5, response_mode="compact"
                )
            st.markdown(str(response))

虽然这大部分是基础知识,但有一些重要的事项需要注意:

  • 我们的初始化按钮与其他按钮的文本相同。Streamlit 会对此进行投诉,因此我们提供了一个唯一的键。
  • 查询中添加了一些额外的文本!这是为了弥补索引没有答案时的情况。
  • 在我们的索引查询中,我们指定了两个选项:
  • similarity_top_k=5 表示索引将获取与查询最接近的前 5 个匹配的术语/定义。
  • response_mode="compact" 表示将尽可能多的文本从这 5 个匹配的术语/定义中用于每次 LLM 调用。如果没有这个选项,索引将至少对 LLM 进行 5 次调用,这可能会减慢用户的速度。

干扰测试#

好吧,实际上我希望在我们进行测试时你已经在测试了。但现在,让我们进行一次完整的测试。

  1. 刷新应用程序
  2. 输入您的 LLM 设置
  3. 转到查询标签
  4. 询问以下问题:什么是 bunnyhug?
  5. 应用程序应该会给出一些无意义的回答。如果你不知道,bunnyhug 是加拿大草原地区人们用来形容帽衫的另一个词!
  6. 让我们将这个定义添加到应用程序中。打开上传标签,并输入以下文本:Bunnyhug 是用来描述帽衫的常用术语。这个术语是加拿大草原地区的人使用的。
  7. 点击提取按钮。几秒钟后,应用程序应该会显示正确提取的术语/定义。点击插入术语按钮保存它!
  8. 如果我们打开术语标签,我们刚刚提取的术语和定义应该会显示出来
  9. 返回查询标签,尝试询问 bunnyhug 是什么。现在,答案应该是正确的!

改进 #1 - 创建一个起始索引#

随着我们的基本应用程序运行,构建一个有用的索引可能会感觉很费力。如果我们给用户一些起始点来展示应用程序的查询功能会怎么样?我们可以做到!首先,让我们对应用程序进行一些小改动,以便在每次上传后将索引保存到磁盘:

def insert_terms(terms_to_definition):
    for term, definition in terms_to_definition.items():
        doc = Document(text=f"术语: {term}\n定义: {definition}")
        st.session_state["llama_index"].insert(doc)
    # 临时 - 保存到磁盘
    st.session_state["llama_index"].storage_context.persist()

现在,我们需要一些文档来提取!该项目的存储库使用了维基百科关于纽约市的页面,您可以在这里找到文本。 如果您将文本粘贴到上传选项卡并运行它(可能需要一些时间),我们可以插入提取的术语。确保在插入索引之前,也将提取的术语文本复制到记事本或类似的工具中!我们在接下来会用到它们。

插入后,删除我们用来将索引保存到磁盘的代码行。现在已经保存了起始索引,我们可以修改我们的 initialize_index 函数如下:

@st.cache_resource
def initialize_index(llm_name, model_temperature, api_key):
    """加载索引对象。"""
    Settings.llm = get_llm(llm_name, model_temperature, api_key)

    index = load_index_from_storage(storage_context)

    return index

您是否记得将提取的大量术语保存在记事本中?现在,当我们的应用程序初始化时,我们希望将索引中的默认术语传递给我们的全局术语状态:

...
if "all_terms" not in st.session_state:
    st.session_state["all_terms"] = DEFAULT_TERMS
...

在以前重置 all_terms 值的任何地方重复上述操作。

改进 #2 - (精炼)更好的提示#

如果您现在玩一下应用程序,您可能会注意到它停止遵循我们的提示了!请记住,我们在 query_str 变量中添加了一条指令,即如果找不到术语/定义,就尽量回答。但是现在,如果您尝试询问随机术语(比如 bunnyhug!),它可能会或可能不会遵循这些指示。

这是由于 Llama Index 中“精炼”答案的概念。由于我们在前 5 个匹配结果中进行查询,有时候所有结果都无法适应单个提示!OpenAI 模型通常具有最大输入大小为 4097 个标记。因此,Llama Index 通过将匹配结果分成适合提示的块来解决这个问题。在 Llama Index 从第一次 API 调用中获得初始答案后,它会将下一个块发送到 API,同时发送前一个答案,并要求模型精炼该答案。

因此,精炼过程似乎影响了我们的结果!与其在 query_str 中附加额外的指令,不如将其删除,这样 Llama Index 将允许我们提供自定义提示!让我们现在创建这些提示,使用默认提示特定于聊天的提示作为指南。使用新文件 constants.py,让我们创建一些新的查询模板:

from llama_index.core import (
    PromptTemplate,
    SelectorPromptTemplate,
    ChatPromptTemplate,
)
from llama_index.core.prompts.utils import is_chat_model
from llama_index.core.llms import ChatMessage, MessageRole

# 文本问答模板
DEFAULT_TEXT_QA_PROMPT_TMPL = (
    "下面是上下文信息。 \n"
    "---------------------\n"
    "{context_str}"
    "\n---------------------\n"
    "根据上下文信息回答以下问题(如果您不知道答案,请尽量回答):{query_str}\n"
)
TEXT_QA_TEMPLATE = PromptTemplate(DEFAULT_TEXT_QA_PROMPT_TMPL)

# 精炼模板
DEFAULT_REFINE_PROMPT_TMPL = (
    "原始问题如下:{query_str}\n"
    "我们提供了一个现有答案:{existing_answer}\n"
    "我们有机会通过下面的一些上下文来精炼现有答案(只有在需要时)。"
    "------------\n"
    "{context_msg}\n"
    "------------\n"
    "根据新的上下文,尽量使用您的知识改进现有答案。"
    "如果您无法改进现有答案,只需重复它。"
)
DEFAULT_REFINE_PROMPT = PromptTemplate(DEFAULT_REFINE_PROMPT_TMPL)

CHAT_REFINE_PROMPT_TMPL_MSGS = [
    ChatMessage(content="{query_str}", role=MessageRole.USER),
    ChatMessage(content="{existing_answer}", role=MessageRole.ASSISTANT),
    ChatMessage(
        content="我们有机会通过下面的一些上下文来精炼上述答案(只有在需要时)。"
        "------------\n"
        "{context_msg}\n"
        "------------\n"
        "根据新的上下文,尽量使用您的知识改进现有答案。"
        "如果您无法改进现有答案,只需重复它。",
        role=MessageRole.USER,
    ),
]

CHAT_REFINE_PROMPT = ChatPromptTemplate(CHAT_REFINE_PROMPT_TMPL_MSGS)

# 精炼提示选择器
REFINE_TEMPLATE = SelectorPromptTemplate(
    default_template=DEFAULT_REFINE_PROMPT,
    conditionals=[(is_chat_model, CHAT_REFINE_PROMPT)],
)
这似乎是一大段代码,但其实并不难!如果你查看了默认提示,你可能会注意到有默认提示和特定于聊天模型的提示。延续这一趋势,我们也为自定义提示做同样的处理。然后,使用提示选择器,我们可以将两种提示组合成一个对象。如果使用的 LLM 是一个聊天模型(如 ChatGPT、GPT-4),那么就使用聊天提示。否则,使用普通的提示模板。

还要注意的一点是,我们只定义了一个问答模板。在聊天模型中,这将被转换为一个“人类”消息。

现在,我们可以将这些提示导入我们的应用程序,并在查询过程中使用它们。

from constants import REFINE_TEMPLATE, TEXT_QA_TEMPLATE

...
if "llama_index" in st.session_state:
    query_text = st.text_input("询问术语或定义:")
    if query_text:
        query_text = query_text  # 注意我们删除了旧的指令
        with st.spinner("生成答案中..."):
            response = st.session_state["llama_index"].query(
                query_text,
                similarity_top_k=5,
                response_mode="compact",
                text_qa_template=TEXT_QA_TEMPLATE,
                refine_template=REFINE_TEMPLATE,
            )
        st.markdown(str(response))
...

如果你对查询进行了更多的实验,希望你能注意到现在响应更好地遵循了我们的指示!

改进 #3 - 图像支持#

Llama Index 也支持图像!使用 Llama Index,我们可以上传文档(论文、信件等)的图像,Llama Index 会处理提取文本。我们可以利用这一点,让用户上传他们文档的图像,并从中提取术语和定义。

如果你遇到有关 PIL 的导入错误,请先使用 pip install Pillow 进行安装。

from PIL import Image
from llama_index.readers.file import (
    DEFAULT_FILE_EXTRACTOR,
    ImageParser,
)


@st.cache_resource
def get_file_extractor():
    image_parser = ImageParser(keep_image=True, parse_text=True)
    file_extractor = DEFAULT_FILE_EXTRACTOR
    file_extractor.update(
        {
            ".jpg": image_parser,
            ".png": image_parser,
            ".jpeg": image_parser,
        }
    )

    return file_extractor


file_extractor = get_file_extractor()
...
with upload_tab:
    st.subheader("提取并查询定义")
    if st.button("初始化索引并重置术语", key="init_index_1"):
        st.session_state["llama_index"] = initialize_index(
            llm_name, model_temperature, api_key
        )
        st.session_state["all_terms"] = DEFAULT_TERMS

    if "llama_index" in st.session_state:
        st.markdown(
            "要么上传文档的图像/截图,要么手动输入文本。"
        )
        uploaded_file = st.file_uploader(
            "上传文档的图像/截图:",
            type=["png", "jpg", "jpeg"],
        )
        document_text = st.text_area("或输入原始文本")
        if st.button("提取术语和定义") and (
            uploaded_file or document_text
        ):
            st.session_state["terms"] = {}
            terms_docs = {}
            with st.spinner("提取中(图像可能较慢)..."):
                if document_text:
                    terms_docs.update(
                        extract_terms(
                            [Document(text=document_text)],
                            term_extract_str,
                            llm_name,
                            model_temperature,
                            api_key,
                        )
                    )
                if uploaded_file:
                    Image.open(uploaded_file).convert("RGB").save("temp.png")
                    img_reader = SimpleDirectoryReader(
                        input_files=["temp.png"], file_extractor=file_extractor
                    )
                    img_docs = img_reader.load_data()
                    os.remove("temp.png")
                    terms_docs.update(
                        extract_terms(
                            img_docs,
                            term_extract_str,
                            llm_name,
                            model_temperature,
                            api_key,
                        )
                    )
            st.session_state["terms"].update(terms_docs)

        if "terms" in st.session_state and st.session_state["terms"]:
            st.markdown("提取的术语")
            st.json(st.session_state["terms"])

            if st.button("插入术语?"):
                with st.spinner("正在插入术语"):
                    insert_terms(st.session_state["terms"])
                st.session_state["all_terms"].update(st.session_state["terms"])
                st.session_state["terms"] = {}
                st.experimental_rerun()
在这里,我们添加了使用 Streamlit 上传文件的选项。然后打开图像并将其保存到磁盘(这看起来有点巧妙,但这样做可以保持简单)。然后我们将图像路径传递给阅读器,提取文档/文本,并删除我们的临时图像文件。

现在我们有了文档,我们可以像以前一样调用 extract_terms()

结论/简而言之#

在本教程中,我们涵盖了大量信息,同时解决了一些常见问题和难题:

  • 对于不同的用例使用不同的索引(列表索引 vs. 向量索引)
  • 使用 Streamlit 的 session_state 概念存储全局状态值
  • 使用 Llama Index 自定义内部提示
  • 使用 Llama Index 从图像中读取文本

本教程的最终版本可以在 这里 找到,并且可以在 Huggingface Spaces 上找到一个实时托管的演示。