提取术语和定义的指南#
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 Term Extractor 🦙")
document_text = st.text_area("Enter raw text")
if st.button("Extract Terms and Definitions") and document_text:
with st.spinner("Extracting..."):
extracted_terms = document_text # this is a placeholder!
st.write(extracted_terms)
超级简单吧!但您会注意到应用目前还不能做任何有用的事情。要使用 llama_index,我们还需要设置 OpenAI LLM。LLM 有许多可能的设置,我们可以让用户自行决定哪种最好。我们还应该让用户设置提取术语的提示(这也将帮助我们调试哪种效果最好)。
LLM 设置#
下一步是在我们的应用中引入一些标签页,以便将应用分成不同功能的面板。让我们为 LLM 设置和上传文本创建两个标签页。
import os
import streamlit as st
DEFAULT_TERM_STR = (
"Make a list of terms and definitions that are defined in the context, "
"with one pair on each line. "
"If a term is missing it's definition, use your best judgment. "
"Write each line as as follows:\nTerm: <term> Definition: <definition>"
)
st.title("🦙 Llama Index Term Extractor 🦙")
setup_tab, upload_tab = st.tabs(["Setup", "Upload/Extract Terms"])
with setup_tab:
st.subheader("LLM Setup")
api_key = st.text_input("Enter your OpenAI API key here", type="password")
llm_name = st.selectbox("Which LLM?", ["gpt-3.5-turbo", "gpt-4"])
model_temperature = st.slider(
"LLM Temperature", min_value=0.0, max_value=1.0, step=0.1
)
term_extract_str = st.text_area(
"The query to extract terms and definitions with.",
value=DEFAULT_TERM_STR,
)
with upload_tab:
st.subheader("Extract and Query Definitions")
document_text = st.text_area("Enter raw text")
if st.button("Extract Terms and Definitions") and document_text:
with st.spinner("Extracting..."):
extracted_terms = document_text # this is a placeholder!
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("Extract and Query Definitions")
document_text = st.text_area("Enter raw text")
if st.button("Extract Terms and Definitions") and document_text:
with st.spinner("Extracting..."):
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, VectorStoreIndex
...
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):
"""Create the VectorStoreIndex object."""
Settings.llm = get_llm(llm_name, model_temperature, api_key)
index = VectorStoreIndex([])
return index, llm
...
with upload_tab:
st.subheader("Extract and Query Definitions")
if st.button("Initialize Index and Reset Terms"):
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(
"Either upload an image/screenshot of a document, or enter the text manually."
)
document_text = st.text_area("Or enter raw text")
if st.button("Extract Terms and Definitions") and (
uploaded_file or document_text
):
st.session_state["terms"] = {}
terms_docs = {}
with st.spinner("Extracting..."):
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("Extracted terms")
st.json(st.session_state["terms"])
if st.button("Insert terms?"):
with st.spinner("Inserting terms"):
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(
["Setup", "All Terms", "Upload/Extract Terms", "Query Terms"]
)
...
with terms_tab:
with terms_tab:
st.subheader("Current Extracted Terms and Definitions")
st.json(st.session_state["all_terms"])
...
with query_tab:
st.subheader("Query for Terms/Definitions!")
st.markdown(
(
"The LLM will attempt to answer your query, and augment it's answers using the terms/definitions you've inserted. "
"If a term is not in the index, it will answer using it's internal knowledge."
)
)
if st.button("Initialize Index and Reset Terms", 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("Ask about a term or definition:")
if query_text:
query_text = (
query_text
+ "\nIf you can't find the answer, answer the query with the best of your knowledge."
)
with st.spinner("Generating answer..."):
response = (
st.session_state["llama_index"]
.as_query_engine(
similarity_top_k=5,
response_mode="compact",
text_qa_template=TEXT_QA_TEMPLATE,
refine_template=DEFAULT_REFINE_PROMPT,
)
.query(query_text)
)
st.markdown(str(response))
虽然这大多是基础操作,但有一些重要事项需要注意:
- 我们的初始化按钮与另一个按钮的文本相同。Streamlit 会对此产生警告,因此我们提供了一个唯一的键来区分。
- 查询中添加了一些额外的文本!这是为了尝试弥补索引中没有答案的情况。
- 在索引查询中,我们指定了两个选项:
similarity_top_k=5
表示索引将提取与查询最匹配的前 5 个术语/定义。response_mode="compact"
表示 5 个匹配术语/定义中的尽可能多的文本将被用于每次 LLM 调用。如果不设置此选项,索引将至少调用 LLM 5 次,这会减慢用户体验。
试运行测试#
好吧,实际上我希望您在过程中一直在测试。但现在,让我们进行一个完整的测试。
- 刷新应用
- 输入您的 LLM 设置
- 前往查询标签页
- 提问:
什么是 bunnyhug?
- 应用应该会给出一些胡说八道的回答。如果您不知道,bunnyhug 是加拿大草原地区人们用来指代连帽衫的另一个词!
- 让我们将此定义添加到应用中。打开上传标签页并输入以下文本:
A bunnyhug is a common term used to describe a hoodie. This term is used by people from the Canadian Prairies.
- 点击提取按钮。片刻后,应用应显示正确提取的术语/定义。点击插入术语按钮以保存!
- 如果我们打开术语标签页,应该会显示我们刚刚提取的术语和定义。
- 回到查询标签页,尝试询问什么是 bunnyhug。现在,答案应该正确了!
改进 #1 - 创建初始索引#
我们的基础应用已经可以工作了,但构建一个有用的索引似乎需要大量工作。如果我们给用户一些起点来展示应用的查询能力会怎么样?我们可以做到!首先,让我们对应用进行一些小的更改,以便每次上传后都将索引保存到磁盘。
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)
# TEMPORARY - save to disk
st.session_state["llama_index"].storage_context.persist()
现在,我们需要一些文档来提取!该项目的仓库使用了纽约市的维基百科页面文本,您可以在此处找到文本。
如果您将文本粘贴到上传标签页并运行(可能需要一些时间),我们可以插入提取的术语。在插入索引之前,务必将提取的术语文本复制到记事本或类似工具中!我们很快就会用到它们。
插入后,移除我们用于将索引保存到磁盘的代码行。现在我们已经保存了初始索引,可以修改 initialize_index
函数如下:
@st.cache_resource
def initialize_index(llm_name, model_temperature, api_key):
"""Load the Index object."""
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 个 token。因此,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
# Text QA templates
DEFAULT_TEXT_QA_PROMPT_TMPL = (
"Context information is below. \n"
"---------------------\n"
"{context_str}"
"\n---------------------\n"
"Given the context information answer the following question "
"(if you don't know the answer, use the best of your knowledge): {query_str}\n"
)
TEXT_QA_TEMPLATE = PromptTemplate(DEFAULT_TEXT_QA_PROMPT_TMPL)
# Refine templates
DEFAULT_REFINE_PROMPT_TMPL = (
"The original question is as follows: {query_str}\n"
"We have provided an existing answer: {existing_answer}\n"
"We have the opportunity to refine the existing answer "
"(only if needed) with some more context below.\n"
"------------\n"
"{context_msg}\n"
"------------\n"
"Given the new context and using the best of your knowledge, improve the existing answer. "
"If you can't improve the existing answer, just repeat it again."
)
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="We have the opportunity to refine the above answer "
"(only if needed) with some more context below.\n"
"------------\n"
"{context_msg}\n"
"------------\n"
"Given the new context and using the best of your knowledge, improve the existing answer. "
"If you can't improve the existing answer, just repeat it again.",
role=MessageRole.USER,
),
]
CHAT_REFINE_PROMPT = ChatPromptTemplate(CHAT_REFINE_PROMPT_TMPL_MSGS)
# refine prompt selector
REFINE_TEMPLATE = SelectorPromptTemplate(
default_template=DEFAULT_REFINE_PROMPT,
conditionals=[(is_chat_model, CHAT_REFINE_PROMPT)],
)
这看起来代码量不少,但并不太糟!如果您查看了默认提示,可能会注意到有默认提示和特定于聊天模型的提示。遵循这一趋势,我们对自定义提示也做了同样的处理。然后,使用提示选择器,我们可以将两个提示组合成一个对象。如果使用的 LLM 是聊天模型(ChatGPT、GPT-4),则使用聊天提示。否则,使用普通的提示模板。
另一点需要注意的是,我们只定义了一个 QA 模板。在聊天模型中,这将被转换为一条“人类”消息。
所以,现在我们可以将这些提示导入到我们的应用中并在查询时使用它们了。
from constants import REFINE_TEMPLATE, TEXT_QA_TEMPLATE
...
if "llama_index" in st.session_state:
query_text = st.text_input("Ask about a term or definition:")
if query_text:
query_text = query_text # Notice we removed the old instructions
with st.spinner("Generating answer..."):
response = (
st.session_state["llama_index"]
.as_query_engine(
similarity_top_k=5,
response_mode="compact",
text_qa_template=TEXT_QA_TEMPLATE,
refine_template=DEFAULT_REFINE_PROMPT,
)
.query(query_text)
)
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 ImageReader
@st.cache_resource
def get_file_extractor():
image_parser = ImageReader(keep_image=True, parse_text=True)
file_extractor = {
".jpg": image_parser,
".png": image_parser,
".jpeg": image_parser,
}
return file_extractor
file_extractor = get_file_extractor()
...
with upload_tab:
st.subheader("Extract and Query Definitions")
if st.button("Initialize Index and Reset Terms", 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(
"Either upload an image/screenshot of a document, or enter the text manually."
)
uploaded_file = st.file_uploader(
"Upload an image/screenshot of a document:",
type=["png", "jpg", "jpeg"],
)
document_text = st.text_area("Or enter raw text")
if st.button("Extract Terms and Definitions") and (
uploaded_file or document_text
):
st.session_state["terms"] = {}
terms_docs = {}
with st.spinner("Extracting (images may be slow)..."):
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("Extracted terms")
st.json(st.session_state["terms"])
if st.button("Insert terms?"):
with st.spinner("Inserting terms"):
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()
函数。
结论/TLDR#
在本教程中,我们涵盖了大量信息,同时解决了一些常见问题:
- 针对不同用例使用不同的索引 (List vs. Vector index)
- 使用 Streamlit 的
session_state
概念存储全局状态值 - 使用 Llama Index 自定义内部提示
- 使用 Llama Index 从图片中读取文本
本教程的最终版本可在此处找到,在线演示可在 Huggingface Spaces 获取。