1. 部署向量知识库到生产机
1.1. 基本环境配置
生产机上的环境还没有配好,这里我记录下需要配置的环境。
python 3.11.8
首先python环境最好选用3.11(准确来说支持3.8-3.11的任何release),我选择了python3.11.8
Python Release Python 3.11.8 | Python.orghttp://PY3118
Git
另外我需要在生产机上部署一些我自己写的程序,例如分词程序、建立易学知识库的程序,这就需要拉代码(不然回回拿着硬盘上机房拷代码太繁琐了),因此还得配置好git的环境。
Git - Downloading Package (git-scm.com) http://Git
1.2. 安装依赖库
创建虚拟环境
这台服务器上还跑着其它的服务,每个服务都有自己的依赖库,为了防止不同环境的依赖冲突,需要配置虚拟环境(python virtual environment)。
有两种方式可以配置,第一种利用IDE,第二种使用命令行:
利用IDE:File->Settings->Project Interpreter,随后新创建一个虚拟环境(最好不要继承基解释器的包)。
利用命令行:
python -m venv path.to.your.venv
随后激活虚拟环境:
path.to.your.venv/Scripts/activate
即可进入虚拟环境。
Git - Downloading Package (git-scm.com)
想要退出,deactivate即可。
安装依赖库
到项目根目录下:
pip install -r requirements.txt
pip install -r requirements_api.txt
pip install -r requirements_webui.txt
切换torch到Cuda对应版本
由于我们是带着大模型启动的(Model Worker),而配置上是GPU版本的PyTorch。但是Requirements.txt中的torch是CPU版本的。
如果是GPU版本,应该是torch==2.1.2+cu的某个版本。
所以之后我们要手动下载一遍GPU版本,首先先查看本机cuda版本:
找到英伟达控制面板
找到系统信息
随后在组件中查看cuda驱动版本
随后我们就要找到对应的torch版本,先来到torch官网:
随后选择适合自己机器的选项,例如我是:
- Windows系统
- Pip包管理
- 语言python
- cuda 12.2
这样选完之后,即可获得安装对应torch的命令:
利用这个命令安装即可。
现在我们的torch带cuda版本了。
1.3. 运行服务
一行命令启动:
python startup.py -a
2. 分词与文档分割
这个任务主要还是非结构化文本,像之前我们整理出来的json文件,基本上可以不用动。
有哪些非结构化文本呢?主要还是集中于《周易研究》期刊的各个文章。之前我们是对它进行了Self-QA,提取了问答对,形成了json文件。不过Self-QA仅仅是阅读整篇文章后,提出问题并予以解答,可能会遗漏知识。因此我们需要对这部分文档进行分词,向量化,建立索引,并存入知识库。
2.1. 建立工作站
由于文件的存储结构是文件系统中一棵文件树的结构。因此为了防止掉电和宕机,以便于恢复现场,需要先建立工作站文件。
这一部分我复用了之前生成语料的工作站,详情见于:
2.2. 工作流程
简单来说:
- 从配置文件中提取源文件路径、目标存储路径、工作站名称、分词参数等
- 遍历所有需要分词的源文件根目录
- 提取当前路径下的工作站文件,遍历每一条工作任务记录
- 如果任务是文件夹,继续向下深度搜索
- 如果是文件,则进行分词
- 采用langchain的中文递归分词器,根据分词参数与模式串进行分词
- 将分词后得到的字符串列表返回
- 删除本条工作任务记录
- 回溯
获取配置参数
我将需要用到的参数全部配置到了一个json配置文件中:
- 其中file_path包括了来源不同的源文件的源路径以及分词后的存储目标路径,还有源路径下的工作站名称。
- 此外还有一个failed_path记录处理失败的任务,便于重新执行。
- 之后的split_args是分词时的参数
- chunk_size是分词的块的大小
- overlap_size是分词时的块的重叠区间长度(上限),这样便于留存上下文,更好地检索信息
此时我们就能够提取这些参数,准备深搜源文件:
text_split_path = sys.argv[1]
with open(text_split_path, 'r', encoding='utf-8') as f:
config = json.load(f)
fp = config['file_path']
failed_root = config['failed_path']
split_args = config['split_args']
chunk = int(split_args['chunk_size'])
overlap = int(split_args['overlap_size'])
遍历所有需要分词的源文件根目录,因为可能源文件的根目录有多个,例如《周易研究》2020-2023、《周易研究》2008-2015等。
for root in fp.values():
src_paths = root['src_paths']
target_paths = root['target_paths']
for i in range(len(src_paths)):
dfs_split(src_paths[i], target_paths[i], work_station=root['WorkStation'])
深搜处理任务
对于每一个路径,提取当前路径下的工作站文件,遍历每一条工作任务记录。如果任务是文件夹,继续向下深度搜索。如果是文件,则进行分词
def dfs_split(path: str, target: str, work_station: str):
# 获取工作站名称
ws = os.sep.join([path, work_station])
# 副本
ws_tmp = work_station.replace('.txt', '.tmp')
ws_tmp = os.sep.join([path, ws_tmp])
# print(f'{ws}\n{ws_tmp}')
while True:
with open(ws, 'r+', encoding='utf-8') as file, open(ws_tmp, 'a+', encoding='utf-8') as tmp:
lines = file.readlines()
if len(lines) == 0:
return
# 读取第一条任务
task = path + '\\' + lines[0].rstrip()
# 剩余任务写入副本
for k in range(1, len(lines)):
tmp.write(lines[k].rstrip() + '\n')
print(f'正在处理任务:{task}')
# 文件夹深搜
if os.path.isdir(task):
dfs_split(task, target, work_station)
else:
# 分词
res = do_split(task)
# 保存
do_save(target, res, task, lines[0].rstrip())
if os.path.exists(ws):
os.remove(ws)
# 重命名副本覆盖工作站
os.rename(ws_tmp, ws)
分词器
对于分词,我才用langchain集成的一个递归分词器。
这个api在langchain官网有介绍:
LangChain中文网:500页中文文档教程,助力大模型LLM应用开发从入门到精通https://www.langchain.com.cn/
因此我根据这个递归字符文本分割器,封装了一个专门用于中文的(也即模式串上更符合汉语特点的)分词器:
def __init__(
self,
separators: Optional[List[str]] = None,
keep_separator: bool = True,
is_separator_regex: bool = True,
**kwargs: Any,
) -> None:
"""Create a new TextSplitter."""
super().__init__(keep_separator=keep_separator, **kwargs)
self._separators = separators or [
"\n\n",
"\n",
"。|!|?",
"\.\s|\!\s|\?\s",
";|;\s",
",|,\s"
]
self._is_separator_regex = is_separator_regex
在类对象初始化时,我先看用户是否穿了自定义的模式串(因为用户肯定是对待分词文本更了解的那个),如果没有,用一个默认的模式串分隔符。
def _split_text(self, text: str, separators: List[str]) -> List[str]:
"""Split incoming text and return chunks."""
final_chunks = []
# Get appropriate separator to use
separator = separators[-1]
new_separators = []
for i, _s in enumerate(separators):
_separator = _s if self._is_separator_regex else re.escape(_s)
if _s == "":
separator = _s
break
if re.search(_separator, text):
separator = _s
new_separators = separators[i + 1:]
break
_separator = separator if self._is_separator_regex else re.escape(separator)
splits = _split_text_with_regex_from_end(text, _separator, self._keep_separator)
# Now go merging things, recursively splitting longer texts.
_good_splits = []
_separator = "" if self._keep_separator else separator
for s in splits:
if self._length_function(s) < self._chunk_size:
_good_splits.append(s)
else:
if _good_splits:
merged_text = self._merge_splits(_good_splits, _separator)
final_chunks.extend(merged_text)
_good_splits = []
if not new_separators:
final_chunks.append(s)
else:
other_info = self._split_text(s, new_separators)
final_chunks.extend(other_info)
if _good_splits:
merged_text = self._merge_splits(_good_splits, _separator)
final_chunks.extend(merged_text)
return [re.sub(r"\n{2,}", "\n", chunk.strip()) for chunk in final_chunks if chunk.strip()!=""]
-
初始化:创建一个空列表
final_chunks
,用于存储最终的文本块。 -
选择分隔符:从
separators
列表中选择一个分隔符。如果separators
的最后一个元素不是空字符串,并且它在文本中存在,则选择它作为分隔符。如果存在多个分隔符,将选择第一个在文本中找到的分隔符,并使用剩余的分隔符进行后续的分割。 -
转义分隔符:如果
_is_separator_regex
为False
,则使用re.escape()
对分隔符进行转义,以确保分隔符中的任何特殊字符都被正确处理。 -
分割文本:使用
_split_text_with_regex_from_end
函数(这个函数没有在代码中定义,可能是外部定义的)来根据选定的分隔符分割文本。self._keep_separator
参数决定是否保留分隔符。 -
合并文本块:遍历分割后的文本块,如果文本块的长度小于
self._chunk_size
,则将其添加到_good_splits
列表中。否则,将_good_splits
中的文本块合并,然后添加到final_chunks
中,并清空_good_splits
以进行下一轮的合并。 -
递归分割:如果当前文本块不能直接合并,并且
new_separators
(剩余的分隔符)不为空,则对当前文本块使用剩余的分隔符进行递归分割。 -
最终合并:如果在遍历结束后
_good_splits
中还有文本块,则将它们合并并添加到final_chunks
中。 -
清理和返回:最后,使用正则表达式替换连续的换行符(
\n
),并去除空白字符,然后返回非空的文本块列表。
因此在分词过程中,我们初始化一个类实例,随后把之前获取到的配置参数传入:
def do_split(task: str) -> List[str]:
global chunk, overlap
try:
text = read_docx(task)
# 调分词器
t_splitter = ChineseRecursiveTextSplitter(
keep_separator=True,
is_separator_regex=True,
chunk_size=chunk,
chunk_overlap=overlap
)
res = t_splitter.split_text(text)
return res
except Exception as e:
print(f'对{task}进行分词时出现错误:{e}')
do_fail_rec(task)
分词效果大致如下:
可以看到,两段相邻分块之间是有重合的部分,这可以更好的保存上下文线索,便于检索。
3. 建立易学知识库
3.1. 去除冗余数据
这里需要说一下,LangChain ChatChat在上传文件时,会根据参数选项自动对文档做分词和向量化。因此我们这里有两条路:
- 我们先跑本地脚本做分词,然后在上传时不做分词仅作向量化
- 本地仅准备原始文本,然后在上传时分词且做向量化
我最终选择了第二条路。其实就是把原来本地脚本的分词器给去掉,直接将原始文本保存为文本文件。可能会疑惑这一步是不是必要的?其实是的,因为原始的docx的数据格式是open xml,这个里面会有大量不必要的样式、标签、格式等数据,这些数据向量化后可能会污染我们的知识库。
最终本地保存的原始文本文件如下:
其中每一个文件的内容都是完整的原始文本:
3.2. 易学资料批量上传
易学知识库服务架构
现在我们需要做的就仅剩下将本地文件上传到服务器的向量知识库中了。在此之前我们要先理解这个易学知识库的架构(如下图):
向量知识库是以文件的形式驻留在服务器的文件系统上,服务器开放两个端口,一个跑web应用客户端,另一个跑web应用服务端。前者请求后者向向量知识库添加新的文件。
一般用户是通过浏览器访问web应用客户端,然后通过点击并选择文件进行文件上传的:
这个方式有什么不好呢?我们有这样300多个文件 ,一个一个选太慢了。如果是300w个文件呢?
所以我们要支持本地文件批处理上传,就需要在本地拉起一个脚本,把之前准备好的原始文本上传上去。因此我的解决方案是,在这个脚本中直接请求服务端的上传文件的接口,绕过客户端。
文件批量上传
首先我们要先知道这个web应用的服务端在哪个端口。config/server_config.py中,有这么一段源码:
# api.py server
API_SERVER = {
"host": DEFAULT_BIND_HOST,
"port": 7861,
}
也就是这个服务在7861端口,同时,server/api.py也佐证了这一点:
parser.add_argument("--host", type=str, default="0.0.0.0")
parser.add_argument("--port", type=int, default=7861)
parser.add_argument("--ssl_keyfile", type=str)
parser.add_argument("--ssl_certfile", type=str)
# 初始化消息
args = parser.parse_args()
args_dict = vars(args)
app = create_app()
run_api(host=args.host,
port=args.port,
ssl_keyfile=args.ssl_keyfile,
ssl_certfile=args.ssl_certfile,
)
可以看到--port参数给默认值就是7861。因此我们就需要向7861端口发送请求。
请求格式
这就需要看接口源码了,看看他想要什么样的数据格式:
def upload_docs(
files: List[UploadFile] = File(..., description="上传文件,支持多文件"),
knowledge_base_name: str = Form(..., description="知识库名称", examples=["samples"]),
override: bool = Form(False, description="覆盖已有文件"),
to_vector_store: bool = Form(True, description="上传文件后是否进行向量化"),
chunk_size: int = Form(CHUNK_SIZE, description="知识库中单段文本最大长度"),
chunk_overlap: int = Form(OVERLAP_SIZE, description="知识库中相邻文本重合长度"),
zh_title_enhance: bool = Form(ZH_TITLE_ENHANCE, description="是否开启中文标题加强"),
docs: Json = Form({}, description="自定义的docs,需要转为json字符串",
examples=[{"test.txt": [Document(page_content="custom doc")]}]),
not_refresh_vs_cache: bool = Form(False, description="暂不保存向量库(用于FAISS)"),
) -> BaseResponse:
首先是一个文件的列表,然后是一些参数,例如向量知识库名称,是否覆写已有文件,是否向量化,分词参数等。
因此我们的请求数据也要包含一个文件的二进制数据,还有这些参数。
工作流程
现在需要做的就是把原始文本上传重塑成这个接口想要的数据格式,然后往7861的/knowledge_base/upload_docs发请求。
我采取的解决方案是,每次请求仅上传一个文件,遍历整个target文件夹下的所有文件,逐个请求上传。
for root in fp.values():
src_paths = root['src_paths']
api_path = root['api_path']
for i in range(len(src_paths)):
for fn in os.listdir(src_paths[i]):
path = os.path.join(src_paths[i], fn)
request_api(path, api_path)
其中root是我的json配置文件中的json对象,通过py的json库解析成了字典。
随后我们构造请求,向web服务端请求上传文件接口:
form_data = {
'knowledge_base_name': knowledge_base_name,
'override': override,
'to_vector_store': to_vector_store,
'chunk_size': chunk,
'chunk_overlap': overlap,
'zh_title_enhance': zh_title_enhance,
'docs': None,
'not_refresh_vs_cache': 'False'
}
# 准备请求中的文件参数
files = {'files': (file_path,open(file_path, 'rb'))}
# 发送POST请求
response = requests.post(url, files=files, data=form_data)
print(f'响应内容为{response.text}')
注意这里参数的key要和源码中的接口对应的上。
运行结果如下:
1. 脚本
2. 服务端窗口
3. web ui