基于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_cnotallow=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_cnotallow=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
最终:
5 结语
本文阐述了如何融合Qdrant的语义搜索技术与生成式人工智能,构建了针对本地文件的检索增强生成(RAG)流程,这一流程能够对文档中的声明进行引用说明。代码总计约300行,用户可选择三种不同参数规模的Llama 3模型以满足不同场景的需求。在本用例中,无论是8亿还是70亿参数的模型,均能稳定运行并提供出色的性能。
本文转载自 AI科技论谈,作者: AI科技论谈