基于Llama3为本地文件创建生成式AI搜索引擎

大家好,下面将介绍一个开源项目,即一款创新的生成式搜索引擎,能够实现用户与本地文件的智能互动。此项目在微软Copilot等现有工具的基础上,推出了一种开放源代码的替代方案,旨在推动技术共享与创新。

1.系统设计

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

  • 内容索引系统:负责存储本地文件内容,并配备信息检索引擎,以便高效地搜索与用户查询或问题最相关的文档。

  • 语言模型:用于分析选定的本地文档内容,并据此生成精炼的总结性答案。

  • 用户界面:为用户提供直观的操作界面,以便轻松地进行查询和获取信息。

组件之间的交互方式如下所示:

图片

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

构建本地生成式搜索引擎的第一步是创建索引,用以存储和检索本地文件内容。当用户提出问题,系统会通过这个索引快速定位到最相关的文档。随后,选定的文档内容被送入高级语言模型,该模型不仅生成答案,还提供对引用文档的明确标注。最终,用户界面将这些信息以清晰、易于理解的方式展示给用户。

2.语义索引

语义索引旨在通过分析文件内容与查询之间的相似度,提供最相关的文档匹配。索引的构建采用了Qdrant作为其向量存储解决方案。Qdrant客户端库的便利之处在于,它不需要完整的服务器端安装,便能在工作内存中直接进行文档相似性比较,极大地简化了部署流程,仅需通过pip命令安装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数据集为例,该数据集基于Bing的搜索查询和文档,由Microsoft发布,并针对此类问题进行了优化。MSMARCO数据集的模型经过微调,能够提供出色的搜索效果,非常适合解决当前面临的问题。

在本次实现中,选用了针对MSMARCO数据集进行过微调的模型,名为:

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

这个模型基于BERT架构,并针对点积相似性度量进行了特别优化。在初始化Qdrant客户端时,已明确采用点积作为衡量相似性的方法(注意此模型的维度为768):

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

在选择相似性度量标准时,虽然余弦相似性是一个可行选择,但鉴于模型已针对点积优化,采用点积能够实现更优的性能表现。点积的优势在于它不仅关注向量间的角度差异,还包括了向量的大小因素,这在评估向量整体相似度时尤为重要。通过归一化处理,可以在特定条件下使两种度量标准达到相同效果。然而,当向量的大小成为一个关键考量时,点积显然是更为合适的度量手段。

模型初始化建议利用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
)

BERT类模型受限于其内存消耗的二次方增长特性,只能处理有限长度的上下文,通常不超过512个token。面对这一局限,有两种应对策略:一是仅利用文档前512个token生成答案,舍弃之后的内容;二是将文档切分为多个小块,每块作为一个独立单元存储于索引之中。为了保留完整的信息,我们选择了后者。文档的分块工作,计划利用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个token的段落,并设置了50个token的重叠区域,这样做是为了在段落的首尾保持上下文的连贯性。接着,为每个段落创建了包含文档存储路径的元数据,并将其与文本段落一并索引。

在将文件内容索引之前,必须先读取这些文件。而在读取之前,需要先确定哪些文件需要被索引。本项目简化了这一流程,允许用户指定他们希望索引的文件夹。索引器将递归地搜索该文件夹及其子文件夹中的所有文件,并索引那些支持的文件类型,如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个token的段落,并在段落间保留了50个token的重叠,确保了内容的连续性。在此基础上,已经成功建立了索引。接下来,将开发一个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 的无缝对接。

为了更高效地处理请求参数,开发了一个名为 Item 的类,它基于 Pydantic 的 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",
    )

系统已完成对基于MSMARCO数据集优化的BERT模型的加载,该模型用于文档索引工作。

若存在nvidia_key,系统会调用NVIDIA NIM API,启用具有70亿参数的Llama 3 instruct模型。若无nvidia_key,鉴于本地部署限制,将加载或量化处理后的8亿参数Llama 3模型,使其在减少内存占用的同时,保持模型性能。

接下来,启动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方法,并通过JSON格式利用Item类传递查询参数。第一个函数负责返回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构建,界面简洁,包含查询输入框、搜索按钮、结果展示区以及可交互的文档列表。关键代码不足45行:

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

最终得到:

图片

综上所述,本文阐述了如何融合Qdrant的语义搜索技术与生成式人工智能,构建了针对本地文件的检索增强生成(RAG)流程,这一流程能够对文档中的声明进行引用说明。代码总计约300行,用户可选择三种不同参数规模的Llama 3模型以满足不同场景的需求。在本用例中,无论是8亿还是70亿参数的模型,均能稳定运行并提供出色的性能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

python慕遥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值