跳转到内容

使用LLamaIndex构建全栈Web应用指南

LlamaIndex 是一个 Python 库,这意味着将其与全栈 Web 应用程序集成会与您习惯的方式略有不同。

本指南旨在逐步介绍创建一个用Python编写的基础API服务所需的步骤,以及该服务如何与TypeScript+React前端进行交互。

所有代码示例均可从 flask_react 文件夹中的 llama_index_starter_pack 获取。

本指南使用的主要技术如下:

  • python3.11
  • llama_index
  • Flask
  • TypeScript
  • 响应式

在本指南中,我们的后端将使用 Flask API 服务器与前端代码进行通信。如果您愿意,也可以轻松将其转换为 FastAPI 服务器,或您选择的任何其他 Python 服务器库。

使用Flask搭建服务器非常简单。您只需导入包、创建应用对象,然后创建端点。让我们先为服务器创建一个基础框架:

from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return "Hello World!"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5601)

flask_demo.py

如果你运行此文件(python flask_demo.py),它将在端口5601上启动一个服务器。如果你访问http://localhost:5601/,你将在浏览器中看到渲染的“Hello World!”文本。很棒!

下一步是决定我们希望服务器包含哪些功能,并开始使用LlamaIndex。

为保持简洁,我们能提供的最基本操作是查询现有索引。使用来自LlamaIndex的保罗·格雷厄姆随笔,创建一个文档文件夹并将随笔文本文件下载并放置其中。

现在,让我们编写一些代码来初始化我们的索引:

import os
from llama_index.core import (
SimpleDirectoryReader,
VectorStoreIndex,
StorageContext,
load_index_from_storage,
)
# NOTE: for local testing only, do NOT deploy with your key hardcoded
os.environ["OPENAI_API_KEY"] = "your key here"
index = None
def initialize_index():
global index
storage_context = StorageContext.from_defaults()
index_dir = "./.index"
if os.path.exists(index_dir):
index = load_index_from_storage(storage_context)
else:
documents = SimpleDirectoryReader("./documents").load_data()
index = VectorStoreIndex.from_documents(
documents, storage_context=storage_context
)
storage_context.persist(index_dir)

此函数将初始化我们的索引。如果我们在 main 函数中启动flask服务器前调用它,我们的索引就能准备好处理用户查询!

我们的查询端点将接受 GET 请求,并将查询文本作为参数。以下是完整端点函数的样子:

from flask import request
@app.route("/query", methods=["GET"])
def query_index():
global index
query_text = request.args.get("text", None)
if query_text is None:
return (
"No text found, please include a ?text=blah parameter in the URL",
400,
)
query_engine = index.as_query_engine()
response = query_engine.query(query_text)
return str(response), 200

现在,我们已经向服务器引入了几个新概念:

  • 一个新的 /query 端点,由函数装饰器定义
  • 从flask导入一个新的内容,request,用于从请求中获取参数
  • 如果缺少 text 参数,则返回错误信息和相应的HTML响应代码
  • 否则,我们查询索引,并将响应作为字符串返回

一个你可以在浏览器中测试的完整查询示例如下:http://localhost:5601/query?text=what did the author do growing up(当你按下回车键后,浏览器会将空格转换为“%20”字符)。

情况看起来相当不错!我们现在有了一个可用的API。使用您自己的文档,您可以轻松为任何应用程序提供一个接口来调用flask API并获取查询的答案。

事情看起来相当酷,但我们如何能更进一步呢?如果我们想让用户通过上传自己的文档来构建他们的索引呢?别担心,Flask可以处理这一切💪。

为了让用户上传文档,我们必须采取一些额外的预防措施。索引将变为可变的,而不是查询现有索引。如果有很多用户向同一索引添加内容,我们需要考虑如何处理并发问题。我们的Flask服务器是线程化的,这意味着多个用户可以同时向服务器发送请求,这些请求将被同时处理。

一种选择可能是为每个用户或群组创建索引,并从S3存储和获取数据。但在本示例中,我们将假设存在一个本地存储的索引供用户交互。

为了处理并发上传并确保按顺序插入索引,我们可以使用 BaseManager Python 包,通过独立服务器和锁机制提供对索引的顺序访问。这听起来很吓人,但其实没那么糟糕!我们只需将所有索引操作(初始化、查询、插入)移至 BaseManager “索引服务器”,该服务器将通过我们的 Flask 服务器进行调用。

以下是我们迁移代码后,index_server.py 的基本示例:

import os
from multiprocessing import Lock
from multiprocessing.managers import BaseManager
from llama_index.core import SimpleDirectoryReader, VectorStoreIndex, Document
# NOTE: for local testing only, do NOT deploy with your key hardcoded
os.environ["OPENAI_API_KEY"] = "your key here"
index = None
lock = Lock()
def initialize_index():
global index
with lock:
# same as before ...
pass
def query_index(query_text):
global index
query_engine = index.as_query_engine()
response = query_engine.query(query_text)
return str(response)
if __name__ == "__main__":
# init the global index
print("initializing index...")
initialize_index()
# setup server
# NOTE: you might want to handle the password in a less hardcoded way
manager = BaseManager(("", 5602), b"password")
manager.register("query_index", query_index)
server = manager.get_server()
print("starting server...")
server.serve_forever()

index_server.py

因此,我们已经迁移了函数,引入了Lock对象来确保对全局索引的顺序访问,在服务器中注册了我们的单一函数,并使用密码password在端口5602上启动了服务器。

然后,我们可以按如下方式调整我们的Flask代码:

from multiprocessing.managers import BaseManager
from flask import Flask, request
# initialize manager connection
# NOTE: you might want to handle the password in a less hardcoded way
manager = BaseManager(("", 5602), b"password")
manager.register("query_index")
manager.connect()
@app.route("/query", methods=["GET"])
def query_index():
global index
query_text = request.args.get("text", None)
if query_text is None:
return (
"No text found, please include a ?text=blah parameter in the URL",
400,
)
response = manager.query_index(query_text)._getvalue()
return str(response), 200
@app.route("/")
def home():
return "Hello World!"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5601)

flask_demo.py

两个主要变化是连接到我们现有的 BaseManager 服务器并注册函数,以及在 /query 端点中通过管理器调用函数。

需要特别注意的是,BaseManager 服务器的返回对象与我们预期的不完全一致。要将返回值解析为其原始对象,我们需要调用 _getvalue() 函数。

如果允许用户上传自己的文档,我们可能需要从文档文件夹中移除保罗·格雷厄姆的文章,所以让我们先完成这一步。然后,让我们添加一个用于上传文件的端点!首先,让我们定义我们的Flask端点函数:

...
manager.register("insert_into_index")
...
@app.route("/uploadFile", methods=["POST"])
def upload_file():
global manager
if "file" not in request.files:
return "Please send a POST request with a file", 400
filepath = None
try:
uploaded_file = request.files["file"]
filename = secure_filename(uploaded_file.filename)
filepath = os.path.join("documents", os.path.basename(filename))
uploaded_file.save(filepath)
if request.form.get("filename_as_doc_id", None) is not None:
manager.insert_into_index(filepath, doc_id=filename)
else:
manager.insert_into_index(filepath)
except Exception as e:
# cleanup temp file
if filepath is not None and os.path.exists(filepath):
os.remove(filepath)
return "Error: {}".format(str(e)), 500
# cleanup temp file
if filepath is not None and os.path.exists(filepath):
os.remove(filepath)
return "File inserted!", 200

还不错!你会注意到我们将文件写入磁盘。如果我们只接受像 txt 文件这样的基础格式,可以跳过这一步,但写入磁盘后我们就能利用 LlamaIndex 的 SimpleDirectoryReader 来处理一系列更复杂的文件格式。可选地,我们还使用第二个 POST 参数来选择使用文件名作为文档ID或让 LlamaIndex 为我们生成一个。等我们实现前端后这一点会更容易理解。

对于这些更复杂的请求,我还建议使用像 Postman 这样的工具。使用 Postman 测试我们端点的示例可在 本项目代码库 中找到。

最后,您会注意到我们向管理器添加了一个新函数。让我们在 index_server.py 中实现它:

def insert_into_index(doc_text, doc_id=None):
global index
document = SimpleDirectoryReader(input_files=[doc_text]).load_data()[0]
if doc_id is not None:
document.doc_id = doc_id
with lock:
index.insert(document)
index.storage_context.persist()
...
manager.register("insert_into_index", insert_into_index)
...

简单!如果我们同时启动 index_server.pyflask_demo.py 这两个 Python 文件,就能获得一个 Flask API 服务器,它可以处理将文档插入向量索引的多个请求并响应用户查询!

为支持前端的一些功能,我已调整了Flask API中某些响应的外观,并添加了用于跟踪索引中存储哪些文档的功能(LlamaIndex目前尚未以用户友好的方式支持此功能,但我们可以自行增强它!)。最后,我不得不使用Flask-cors Python包为服务器添加CORS支持。

查看完整的 flask_demo.pyindex_server.py 脚本在代码仓库中,包括最终的小幅改动、requirements.txt 文件以及一个示例 Dockerfile 以协助部署。

通常来说,React和Typescript是当今编写网页应用最流行的库和语言之一。本指南假设您已熟悉这些工具的使用方法,否则本指南的篇幅将增加三倍 :smile:。

代码仓库中,前端代码组织在react_frontend文件夹内。

前端最相关的部分将是 src/apis 文件夹。这是我们向 Flask 服务器发起调用的地方,支持以下查询:

  • /query — 对现有索引进行查询
  • /uploadFile — 将文件上传到Flask服务器以插入索引
  • /getDocuments — 列出当前文档标题及其部分文本内容

通过这三个查询,我们可以构建一个强大的前端,允许用户上传并跟踪他们的文件、查询索引,以及查看查询响应和用于形成响应的文本节点信息。

该文件包含用于获取索引中当前文档列表的函数,正如您所猜测的那样。代码如下:

export type Document = {
id: string;
text: string;
};
const fetchDocuments = async (): Promise<Document[]> => {
const response = await fetch("http://localhost:5601/getDocuments", {
mode: "cors",
});
if (!response.ok) {
return [];
}
const documentList = (await response.json()) as Document[];
return documentList;
};

如您所见,我们向Flask服务器发起了一个查询(这里假设它在本地主机上运行)。请注意,我们需要包含mode: 'cors'选项,因为我们正在发起一个外部请求。

然后,我们检查响应是否正常,如果是,则获取响应 JSON 并返回。这里的响应 JSON 是在同一文件中定义的 Document 对象列表。

该文件将用户查询发送至Flask服务器,并获取返回的响应结果,同时显示索引中哪些节点提供了该响应的详细信息。

export type ResponseSources = {
text: string;
doc_id: string;
start: number;
end: number;
similarity: number;
};
export type QueryResponse = {
text: string;
sources: ResponseSources[];
};
const queryIndex = async (query: string): Promise<QueryResponse> => {
const queryURL = new URL("http://localhost:5601/query?text=1");
queryURL.searchParams.append("text", query);
const response = await fetch(queryURL, { mode: "cors" });
if (!response.ok) {
return { text: "Error in query", sources: [] };
}
const queryResponse = (await response.json()) as QueryResponse;
return queryResponse;
};
export default queryIndex;

这类似于 fetchDocuments.tsx 文件,主要区别在于我们将查询文本作为参数包含在 URL 中。然后,我们检查响应是否正常,并以适当的 TypeScript 类型返回它。

最复杂的API调用可能是上传文档。此处的函数接受一个文件对象,并使用FormData构建POST请求。

实际响应文本在应用程序中并未使用,但可用于在文件上传失败时向用户提供反馈。

const insertDocument = async (file: File) => {
const formData = new FormData();
formData.append("file", file);
formData.append("filename_as_doc_id", "true");
const response = await fetch("http://localhost:5601/uploadFile", {
mode: "cors",
method: "POST",
body: formData,
});
const responseText = response.text();
return responseText;
};
export default insertDocument;

前端部分的内容就差不多介绍完了!剩下的React前端代码是一些相当基础的React组件,以及我为了让界面至少看起来稍微美观一些所做的最大努力 :smile:。

我建议阅读代码库的其余部分,并提交任何改进的PR!

本指南涵盖了海量信息。我们从用 Python 编写的基础 "Hello World" Flask 服务器开始,逐步构建了功能完整的 LlamaIndex 驱动后端,并讲解了如何将其连接到前端应用程序。

如您所见,我们可以轻松增强和封装LlamaIndex提供的服务(例如小型外部文档追踪器),以帮助在前端提供良好的用户体验。

你可以在此基础上添加许多功能(多索引/用户支持、将对象保存到S3、添加Pinecone向量服务器等)。当你阅读本文后构建应用程序时,请务必在Discord中分享最终成果!祝你好运!💪