跳到内容

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

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

本指南旨在逐步引导您完成创建基于 Python 的基本 API 服务以及该服务如何与 TypeScript+React 前端交互的步骤。

此处的所有代码示例均可在 llama_index_starter_pack 的 flask_react 文件夹中找到。

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

  • python3.11
  • llama_index
  • flask
  • typescript
  • react

Flask 后端#

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

使用 Flask 设置服务器非常简单。您只需导入包,创建 app 对象,然后创建您的端点。首先,让我们为服务器创建一个基本骨架:

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 中的 保罗·格雷厄姆的随笔,创建一个 documents 文件夹,并将随笔文本文件下载并放入其中。

基本 Flask - 处理用户索引查询#

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

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=作者从小做了什么(按下回车键后,浏览器会将空格转换为 "%20" 字符)。

一切看起来都很好!我们现在拥有了一个功能齐全的 API。使用您自己的文档,您可以轻松地为任何应用程序提供一个接口,以调用 Flask API 并获取查询答案。

高级 Flask - 处理用户文档上传#

一切看起来都相当酷,但我们如何才能更进一步呢?如果我们想允许用户通过上传自己的文档来构建自己的索引怎么办?别担心,Flask 可以处理所有这些问题 :muscle:。

为了让用户上传文档,我们必须采取一些额外的预防措施。索引将不再是只读的,而是变得可变。如果有很多用户向同一个索引添加内容,我们需要考虑如何处理并发性。我们的 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 对象,在服务器中注册了我们的单个函数,并在端口 5602 上启动了带有密码 password 的服务器。

然后,我们可以如下调整我们的 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() 函数。

如果我们允许用户上传自己的文档,我们应该先从 documents 文件夹中移除保罗·格雷厄姆的随笔。然后,让我们添加一个用于文件上传的端点!首先,让我们定义我们的 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 参数,要么使用文件名作为 doc_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 前端#

通常,React 和 Typescript 是当今编写 Web 应用程序最流行的库和语言之一。本指南假定您熟悉这些工具的工作原理,否则本指南的篇幅将增加三倍 :smile:。

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

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

  • /query -- 对现有索引进行查询
  • /uploadFile -- 将文件上传到 Flask 服务器,以便插入索引
  • /getDocuments -- 列出当前文档的标题和部分文本

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

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 服务器发起了查询(这里假设它运行在 localhost 上)。请注意,我们需要包含 mode: 'cors' 选项,因为我们正在发出外部请求。

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

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#

可能最复杂的 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 中分享最终成果!祝您好运!:muscle