NSDT工具推荐Three.js AI纹理开发包 - YOLO合成数据生成器 - GLTF/GLB在线编辑 - 3D模型格式在线转换 - 可编程3D场景编辑器 - REVIT导出3D模型插件 - 3D模型语义搜索引擎 - AI模型在线查看 - Three.js虚拟轴心开发包 - 3D模型在线减面 - STL模型在线切割

5 月 23 日,我收到 Nvidia 的一封电子邮件,邀请我参加 NVIDIA 和 LangChain 举办的生成式 AI 代理开发者大赛。

我的第一个想法是时间太短了,而且考虑到我们最近刚生了个孩子,我父母应该会来,我没有时间参加。但后来我又改变了主意,决定编写一些代码并提交。

我花了几天时间思考可以做什么,一个想法一直萦绕在我的脑海里 — — 一个可以让你与本地文件交互的开源生成式搜索引擎。Microsoft Copilot 已经提供了类似的东西,但我认为我可以制作一个开源版本,为了好玩,并分享我在快速编码系统过程中收集的一些经验。

1、系统设计

为了构建本地生成式搜索引擎或助手,我们需要几个组件:

  • 一个包含本地文件内容的索引,以及一个信息检索引擎,用于检索给定查询/问题的最相关文档。
  • 一种语言模型,使用从本地文档中选择的内容并生成总结性答案
  • 用户界面

下图显示了组件如何交互:

系统设计和架构。Qdrant 用于向量存储,Streamlit 用于用户界面。Llama 3 可通过 Nvidia NIM API(70B 版本)使用,也可通过 HuggingFace(8B 版本)下载。文档分块使用 Langchain 完成

首先,我们需要将本地文件编入索引,以便查询本地文件的内容。然后,当用户提出问题时,我们将使用创建的索引以及一些不对称段落或文档嵌入来检索可能包含答案的最相关文档。这些文档的内容和问题将传递给已部署的大型语言模型,该模型将使用给定文档的内容来生成答案。在指令提示中,我们将要求大型语言模型也返回对所用文档的引用。最终,所有内容都将在用户界面上可视化给用户。

现在,让我们更详细地了解每个组件。

2、语义索引

我们正在构建一个语义索引,它将根据文件内容和给定查询的相似性为我们提供最相关的文档。要创建这样的索引,我们将使用 Qdrant 作为向量存储。有趣的是,Qdrant 客户端库不需要完整安装 Qdrant 服务器,并且可以对适合工作内存 (RAM) 的文档进行相似性分析。因此,我们需要做的就是 pip install Qdrant 客户端。

我们可以按以下方式初始化 Qdrant(请注意,由于故事流程,hf 参数稍后会定义,但使用 Qdrant 客户端,您已经需要定义正在使用哪种矢量化方法和度量):

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams
client = QdrantClient(path="qdrant/")
collection_name = "MyCollection"
if client.collection_exists(collection_name):
    client.delete_collection(collection_name)

client.create_collection(collection_name,vectors_config=VectorParams(size=768, distance=Distance.DOT))
qdrant = Qdrant(client, collection_name, hf)

为了创建向量索引,我们必须将文档嵌入硬盘。对于嵌入,我们必须选择正确的嵌入方法和正确的向量比较度量。可以使用多种段落、句子或单词嵌入方法,结果各不相同。基于文档创建向量搜索的主要问题是搜索不对称问题。不对称搜索问题在信息检索中很常见,当查询较短而文档较长时就会发生。单词或句子嵌入通常经过微调,以根据大小相似的文档(句子或段落)提供相似度分数。一旦情况并非如此,正确的信息检索可能会失败。

但是,我们可以找到一种可以很好地解决不对称搜索问题的嵌入方法。例如,在 MSMARCO 数据集上微调的模型通常效果很好。MSMARCO 数据集基于 Bing 搜索查询和文档,已由 Microsoft 发布。因此,它非常适合我们正在处理的问题。

对于这个特定的实现,我选择了一个已经微调的模型,称为:

sentence-transformers/msmarco-bert-base-dot-v5

该模型基于 BERT,并使用点积作为相似度度量进行了微调。我们已经初始化了 qdrant 客户端,以使用点积作为相似度度量(请注意,该模型的维度为 768):

client.create_collection(collection_name,vectors_config=VectorParams(size=768, distance=Distance.DOT))

我们可以使用其他指标,例如余弦相似度,但是,鉴于此模型是使用点积进行微调的,我们将使用此指标获得最佳性能。除此之外,从几何角度思考:余弦相似度仅关注角度差异,而点积则同时考虑角度和幅度。通过将数据标准化为具有统一的幅度,这两个度量变得等同。在忽略幅度有益的情况下,余弦相似度很有用。但是,如果幅度很大,点积是更合适的相似度度量。

初始化 MSMarco 模型的代码是(如果您有可用的 GPU,请使用它。无论如何):

    model_name = "sentence-transformers/msmarco-bert-base-dot-v5"
    model_kwargs = {'device': 'cpu'}
    encode_kwargs = {'normalize_embeddings': True}
    hf = HuggingFaceEmbeddings(
        model_name=model_name,
        model_kwargs=model_kwargs,
        encode_kwargs=encode_kwargs
    )

下一个问题:我们需要处理的是,由于 Transformer 模型对内存的二次方要求,类似 BERT 的模型上下文大小有限。对于许多类似 BERT 的模型,此上下文大小设置为 512 个 token。有两种选择:(1)我们可以仅根据前 512 个 token 给出答案,而忽略文档的其余部分,或者(2)创建一个索引,其中一个文档将被拆分成多个块并以块的形式存储在索引中。在第一种情况下,我们会丢失很多重要信息,因此,我们选择了第二种方案。要对文档进行分块,我们可以使用 LangChain 中预先构建的分块器:

from langchain_text_splitters import TokenTextSplitter
text_splitter = TokenTextSplitter(chunk_size=500, chunk_overlap=50)
texts = text_splitter.split_text(file_content)
metadata = []
for i in range(0,len(texts)):
    metadata.append({"path":file})
qdrant.add_texts(texts,metadatas=metadata)

在提供的代码部分中,我们将文本分块为 500 个标记,窗口为 50 个重叠标记。这样,我们就可以保留一些关于块结束或开始位置的上下文。在其余的代码中,我们使用用户硬盘上的文档路径创建元数据,并将这些带有元数据的块添加到索引中。

但是,在将文件内容添加到索引之前,我们需要读取它。甚至在读取文件之前,我们就需要获取需要索引的所有文件。为简单起见,在这个项目中,用户可以定义他/她想要索引的文件夹。索引器以递归方式检索该文件夹及其子文件夹中的所有文件,并索引受支持的文件(我们将研究如何支持 PDF、Word、PPT 和 TXT)。

我们可以以递归方式检索给定文件夹及其子文件夹中的所有文件:

def get_files(dir):
    file_list = []
    for f in listdir(dir):
        if isfile(join(dir,f)):
            file_list.append(join(dir,f))
        elif isdir(join(dir,f)):
            file_list= file_list + get_files(join(dir,f))
    return file_list

一旦在列表中检索到所有文件,我们就可以读取包含文本的文件的内容。在此工具中,首先,我们将支持 MS Word 文档(扩展名为“.docx”)、PDF 文档、MS PowerPoint 演示文稿(扩展名为“.pptx”)和纯文本文件(扩展名为“.txt”)。

为了读取 MS Word 文档,我们可以使用 docx-python 库。将文档读入字符串变量的函数如下所示:

import docx
def getTextFromWord(filename):
    doc = docx.Document(filename)
    fullText = []
    for para in doc.paragraphs:
        fullText.append(para.text)
    return '\n'.join(fullText)

类似的事情也可以用 MS PowerPoint 文件完成。为此,我们需要下载并安装 pptx-python 库并编写如下函数:

from pptx import Presentation
def getTextFromPPTX(filename):
    prs = Presentation(filename)
    fullText = []
    for slide in prs.slides:
        for shape in slide.shapes:
            fullText.append(shape.text)
    return '\n'.join(fullText)

读取文本文件非常简单:

f = open(file,'r')
file_content = f.read()
f.close()

对于 PDF 文件,在这种情况下我们将使用 PyPDF2 库:

reader = PyPDF2.PdfReader(file)
for i in range(0,len(reader.pages)):
    file_content = file_content + " "+reader.pages[i].extract_text()

最后,整个索引函数看起来像这样:

file_content = ""
    for file in onlyfiles:
        file_content = ""
        if file.endswith(".pdf"):
            print("indexing "+file)
            reader = PyPDF2.PdfReader(file)
            for i in range(0,len(reader.pages)):
                file_content = file_content + " "+reader.pages[i].extract_text()
        elif file.endswith(".txt"):
            print("indexing " + file)
            f = open(file,'r')
            file_content = f.read()
            f.close()
        elif file.endswith(".docx"):
            print("indexing " + file)
            file_content = getTextFromWord(file)
        elif file.endswith(".pptx"):
            print("indexing " + file)
            file_content = getTextFromPPTX(file)
        else:
            continue
        text_splitter = TokenTextSplitter(chunk_size=500, chunk_overlap=50)
        texts = text_splitter.split_text(file_content)
        metadata = []
        for i in range(0,len(texts)):
            metadata.append({"path":file})
        qdrant.add_texts(texts,metadatas=metadata)
    print(onlyfiles)
    print("Finished indexing!")

正如我们所说,我们使用 LangChain 的 TokenTextSplitter 创建 500 个标记的块,其中 50 个标记重叠。现在,当我们创建索引时,我们可以创建一个 Web 服务来查询它并生成答案。

3、生成式搜索 API

我们将使用 FastAPI 创建一个 Web 服务来托管我们的生成搜索引擎。该 API 将使用我们在上一节中创建的索引数据访问 Qdrant 客户端,使用向量相似度度量执行搜索,使用顶部块通过 Llama 3 模型生成答案,最后将答案返回给用户。

为了初始化和导入生成搜索组件的库,我们可以使用以下代码:

from fastapi import FastAPI
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_qdrant import Qdrant
from qdrant_client import QdrantClient
from pydantic import BaseModel
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
import environment_var
import os
from openai import OpenAI

class Item(BaseModel):
    query: str
    def __init__(self, query: str) -> None:
        super().__init__(query=query)

如前所述,我们使用 FastAPI 创建 API 接口。我们将利用 qdrant_client 库来访问我们创建的索引数据,并利用 langchain_qdrant 库来获得额外支持。对于嵌入和本地加载 Llama 3 模型,我们将使用 PyTorch 和 Transformers 库。此外,我们将使用 OpenAI 库调用 NVIDIA NIM API,API 密钥存储在我们创建的 environment_var(适用于 Nvidia 和 HuggingFace)文件中。

我们在 Pydantic 中创建类 Item,该类派生自 BaseModel,以作为参数传递给请求函数。它将有一个名为 query 的字段。

现在,我们可以开始初始化我们的机器学习模型:

model_name = "sentence-transformers/msmarco-bert-base-dot-v5"
model_kwargs = {'device': 'cpu'}
encode_kwargs = {'normalize_embeddings': True}
hf = HuggingFaceEmbeddings(
    model_name=model_name,
    model_kwargs=model_kwargs,
    encode_kwargs=encode_kwargs
)

os.environ["HF_TOKEN"] = environment_var.hf_token
use_nvidia_api = False
use_quantized = True
if environment_var.nvidia_key !="":
    client_ai = OpenAI(
        base_url="https://integrate.api.nvidia.com/v1",
        api_key=environment_var.nvidia_key
    )
    use_nvidia_api = True
elif use_quantized:
    model_id = "Kameshr/LLAMA-3-Quantized"
    tokenizer = AutoTokenizer.from_pretrained(model_id)
    model = AutoModelForCausalLM.from_pretrained(
        model_id,
        torch_dtype=torch.float16,
        device_map="auto",
    )
else:
    model_id = "meta-llama/Meta-Llama-3-8B-Instruct"
    tokenizer = AutoTokenizer.from_pretrained(model_id)
    model = AutoModelForCausalLM.from_pretrained(
        model_id,
        torch_dtype=torch.float16,
        device_map="auto",
    )

在前几行中,我们加载基于 BERT 的模型的权重,该模型在 MSMARCO 数据上进行了微调,我们也使用这些数据来索引我们的文档。

然后,我们检查是否提供了 nvidia_key,如果提供了,我们使用 OpenAI 库来调用 NVIDIA NIM API。当我们使用 NVIDIA NIM API 时,我们可以使用大版本的 Llama 3 指令模型,该模型具有 70B 参数。如果没有提供 nvidia_key,我们将在本地加载 Llama 3。但是,至少对于大多数消费电子产品而言,在本地加载 70B 参数模型是不可能的。因此,我们将加载 Llama 3 8B 参数模型或经过额外量化的 Llama 3 8B 参数模型。通过量化,我们可以节省空间并能够在更少的 RAM 上执行模型。例如,Llama 3 8B 通常需要大约 14GB 的 GPU RAM,而量化后的 Llama 3 8B 可以在 6GB 的 GPU RAM 上运行。因此,我们根据参数加载完整或量化模型。

我们现在可以初始化 Qdrant 客户端:

client = QdrantClient(path="qdrant/")
collection_name = "MyCollection"
qdrant = Qdrant(client, collection_name, hf)

另外,FastAPI 并创建第一个模拟 GET 函数:

app = FastAPI()


@app.get("/")
async def root():
    return {"message": "Hello World"}

此函数将返回格式为 {“message”:”Hello World”} 的 JSON

但是,为了使此 API 正常运行,我们将创建两个函数,一个仅执行语义搜索,而另一个将执行搜索,然后将前 10 个块作为上下文并生成答案,引用它使用的文档。

@app.post("/search")
def search(Item:Item):
    query = Item.query
    search_result = qdrant.similarity_search(
        query=query, k=10
    )
    i = 0
    list_res = []
    for res in search_result:
        list_res.append({"id":i,"path":res.metadata.get("path"),"content":res.page_content})
    return list_res

@app.post("/ask_localai")
async def ask_localai(Item:Item):
    query = Item.query
    search_result = qdrant.similarity_search(
        query=query, k=10
    )
    i = 0
    list_res = []
    context = ""
    mappings = {}
    i = 0
    for res in search_result:
        context = context + str(i)+"\n"+res.page_content+"\n\n"
        mappings[i] = res.metadata.get("path")
        list_res.append({"id":i,"path":res.metadata.get("path"),"content":res.page_content})
        i = i +1

    rolemsg = {"role": "system",
               "content": "Answer user's question using documents given in the context. In the context are documents that should contain an answer. Please always reference document id (in squere brackets, for example [0],[1]) of the document that was used to make a claim. Use as many citations and documents as it is necessary to answer question."}
    messages = [
        rolemsg,
        {"role": "user", "content": "Documents:\n"+context+"\n\nQuestion: "+query},
    ]
    if use_nvidia_api:
        completion = client_ai.chat.completions.create(
            model="meta/llama3-70b-instruct",
            messages=messages,
            temperature=0.5,
            top_p=1,
            max_tokens=1024,
            stream=False
        )
        response = completion.choices[0].message.content
    else:
        input_ids = tokenizer.apply_chat_template(
                messages,
                add_generation_prompt=True,
                return_tensors="pt"
            ).to(model.device)


        terminators = [
            tokenizer.eos_token_id,
            tokenizer.convert_tokens_to_ids("<|eot_id|>")
            ]

        outputs = model.generate(
            input_ids,
            max_new_tokens=256,
            eos_token_id=terminators,
            do_sample=True,
            temperature=0.2,
            top_p=0.9,
        )
        response = tokenizer.decode(outputs[0][input_ids.shape[-1]:])
    return {"context":list_res,"answer":response}

这两个函数都是 POST 方法,我们使用 Item 类通过 JSON 主体传递查询。第一个方法返回 10 个最相似的文档块,并附带路径,并分配 0-9 之间的文档 ID。因此,它仅使用点积作为相似度度量(这是在 Qdrant 中索引期间定义的 — 请记住包含 distance=Distance.DOT 的行)执行简单的语义搜索。

第二个函数称为 ask_localai,略微复杂一些。它包含第一个方法中的搜索机制(因此,通过那里的代码来理解语义搜索可能更容易),但添加了生成部分。它为 Llama 3 创建了一个提示,其中包含系统提示消息中的说明:

使用上下文中给出的文档回答用户的问题。上下文中的文档应包含答案。请始终引用用于提出主张的文档的文档 ID(在方括号中,例如 [0],[1])。使用尽可能多的引用和文档来回答问题。

用户的消息包含一个文档列表,其结构为 ID(0-9),下一行是文档块。为了维护 ID 和文档路径之间的映射,我们创建了一个名为 list_res 的列表,其中包含 ID、路径和内容。用户提示以单词“Question”结尾,后跟用户的查询。

响应包含上下文和生成的答案。但是,答案再次由 Llama 3 70B 模型(使用 NVIDIA NIM API)、本地 Llama 3 8B 或根据传递的参数量化的本地 Llama 3 8B 生成。

API 可以从包含以下代码行的单独文件启动(假设我们的生成组件位于名为 api.py 的文件中,因为 Uvicorn 中的第一个参数映射到文件名):

import uvicorn


if __name__=="__main__":
    uvicorn.run("api:app",host='0.0.0.0', port=8000, reload=False,  workers=3)

4、简单的用户界面

我们的本地生成式搜索引擎的最后一个组件是用户界面。我们将使用 Streamlit 构建一个简单的用户界面,其中包括一个输入栏、一个搜索按钮、一个用于显示生成的答案的部分以及一个可以打开或下载的参考文档列表。

Streamlit 中用户界面的整个代码不到 45 行(准确地说是 44 行):

import re
import streamlit as st
import requests
import json
st.title('_:blue[Local GenAI Search]_ :sunglasses:')
question = st.text_input("Ask a question based on your local files", "")
if st.button("Ask a question"):
    st.write("The current question is \"", question+"\"")
    url = "http://127.0.0.1:8000/ask_localai"

    payload = json.dumps({
      "query": question
    })
    headers = {
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    }

    response = requests.request("POST", url, headers=headers, data=payload)

    answer = json.loads(response.text)["answer"]
    rege = re.compile("\[Document\ [0-9]+\]|\[[0-9]+\]")
    m = rege.findall(answer)
    num = []
    for n in m:
        num = num + [int(s) for s in re.findall(r'\b\d+\b', n)]


    st.markdown(answer)
    documents = json.loads(response.text)['context']
    show_docs = []
    for n in num:
        for doc in documents:
            if int(doc['id']) == n:
                show_docs.append(doc)
    a = 1244
    for doc in show_docs:
        with st.expander(str(doc['id'])+" - "+doc['path']):
            st.write(doc['content'])
            with open(doc['path'], 'rb') as f:
                st.download_button("Downlaod file", f, file_name=doc['path'].split('/')[-1],key=a
                )
                a = a + 1

最终看起来会像这样:

构建的用户界面中已回答问题的示例

5、可用性

所述项目的完整代码可在 GitHub 上找到。过去,我曾参与过几个生成搜索项目,也有一些出版物,你可以查看这里这里

6、结束语

本文展示了如何使用 Qdrant 利用生成 AI 进行语义搜索。它通常是本地文件的检索增强生成 (RAG) 管道,带有将声明引用到本地文档的指令。整个代码大约有 300 行,我们甚至通过让用户在 3 种不同的 Llama 3 模型之间进行选择来增加复杂性。对于此用例,8B 和 70B 参数模型都运行良好。

我想解释一下我所做的步骤,希望这对将来的某人有帮助。但是,如果你想使用这个特定的工具,最简单的方法就是从 GitHub 获取它,它是完全开源的!


原文链接:How to Build a Generative Search Engine for Your Local Files Using Llama 3

BimAnt翻译整理,转载请标明出处