使用LLamaIndex构建全栈Web应用指南
LlamaIndex 是一个 Python 库,这意味着将其与全栈 Web 应用程序集成会与您习惯的方式略有不同。
本指南旨在逐步介绍创建一个用Python编写的基础API服务所需的步骤,以及该服务如何与TypeScript+React前端进行交互。
所有代码示例均可从 flask_react 文件夹中的 llama_index_starter_pack 获取。
本指南使用的主要技术如下:
- python3.11
- llama_index
- Flask
- TypeScript
- 响应式
Flask 后端
Section titled “Flask Backend”在本指南中,我们的后端将使用 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的保罗·格雷厄姆随笔,创建一个文档文件夹并将随笔文本文件下载并放置其中。
基础Flask - 处理用户索引查询
Section titled “Basic Flask - Handling User Index Queries”现在,让我们编写一些代码来初始化我们的索引:
import osfrom llama_index.core import ( SimpleDirectoryReader, VectorStoreIndex, StorageContext, load_index_from_storage,)
# NOTE: for local testing only, do NOT deploy with your key hardcodedos.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 - 处理用户文档上传
Section titled “Advanced Flask - Handling User Document Uploads”事情看起来相当酷,但我们如何能更进一步呢?如果我们想让用户通过上传自己的文档来构建他们的索引呢?别担心,Flask可以处理这一切💪。
为了让用户上传文档,我们必须采取一些额外的预防措施。索引将变为可变的,而不是查询现有索引。如果有很多用户向同一索引添加内容,我们需要考虑如何处理并发问题。我们的Flask服务器是线程化的,这意味着多个用户可以同时向服务器发送请求,这些请求将被同时处理。
一种选择可能是为每个用户或群组创建索引,并从S3存储和获取数据。但在本示例中,我们将假设存在一个本地存储的索引供用户交互。
为了处理并发上传并确保按顺序插入索引,我们可以使用 BaseManager Python 包,通过独立服务器和锁机制提供对索引的顺序访问。这听起来很吓人,但其实没那么糟糕!我们只需将所有索引操作(初始化、查询、插入)移至 BaseManager “索引服务器”,该服务器将通过我们的 Flask 服务器进行调用。
以下是我们迁移代码后,index_server.py 的基本示例:
import osfrom multiprocessing import Lockfrom multiprocessing.managers import BaseManagerfrom llama_index.core import SimpleDirectoryReader, VectorStoreIndex, Document
# NOTE: for local testing only, do NOT deploy with your key hardcodedos.environ["OPENAI_API_KEY"] = "your key here"
index = Nonelock = 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 BaseManagerfrom flask import Flask, request
# initialize manager connection# NOTE: you might want to handle the password in a less hardcoded waymanager = 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.py 和 flask_demo.py 这两个 Python 文件,就能获得一个 Flask API 服务器,它可以处理将文档插入向量索引的多个请求并响应用户查询!
为支持前端的一些功能,我已调整了Flask API中某些响应的外观,并添加了用于跟踪索引中存储哪些文档的功能(LlamaIndex目前尚未以用户友好的方式支持此功能,但我们可以自行增强它!)。最后,我不得不使用Flask-cors Python包为服务器添加CORS支持。
查看完整的 flask_demo.py 和 index_server.py 脚本在代码仓库中,包括最终的小幅改动、requirements.txt 文件以及一个示例 Dockerfile 以协助部署。
React 前端
Section titled “React Frontend”通常来说,React和Typescript是当今编写网页应用最流行的库和语言之一。本指南假设您已熟悉这些工具的使用方法,否则本指南的篇幅将增加三倍 :smile:。
在代码仓库中,前端代码组织在react_frontend文件夹内。
前端最相关的部分将是 src/apis 文件夹。这是我们向 Flask 服务器发起调用的地方,支持以下查询:
/query— 对现有索引进行查询/uploadFile— 将文件上传到Flask服务器以插入索引/getDocuments— 列出当前文档标题及其部分文本内容
通过这三个查询,我们可以构建一个强大的前端,允许用户上传并跟踪他们的文件、查询索引,以及查看查询响应和用于形成响应的文本节点信息。
fetchDocuments.tsx
Section titled “fetchDocuments.tsx”该文件包含用于获取索引中当前文档列表的函数,正如您所猜测的那样。代码如下:
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 对象列表。
queryIndex.tsx
Section titled “queryIndex.tsx”该文件将用户查询发送至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 类型返回它。
insertDocument.tsx
Section titled “insertDocument.tsx”最复杂的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中分享最终成果!祝你好运!💪