被拆分后的文档存储在weaviate向量数据库中时如何建立前后关联关系

最近在做基于大模型的知识库应用,具体应用不做介绍,主要针对应用中所碰到的场景问题进行说明,并给出解决方案,希望对遇到同类问题的同学提供一些帮助。

一、问题描述

此文默认大家对RAG的工作原理及流程有所了解,还没了解的可以自行查找相应资料。在实现RAG的流程中,由于需要对文件进行拆分——笔者使用的拆分规则是设置每段文本长度chunk_size=250及重叠文本长度overlap=50,难以避免出现一个完整句子被拆分为两段,然后被分别存储在向量数据库中。

这里再说明一下,笔者前期使用的Faiss来构建向量索引,但是发现(拆分后的)文档量超过10万级别后,搜索结果不尽人意,尝试通过更改索引策略来优化(Flat改为IVF+Flat),但始终没有达到想要的效果。知识库的查询需求,不仅只有向量检索,对于关键字检索也有很强的需求,甚至关键字检索的权重还会要求更高一些,而Faiss提供的仅是向量检索,因此,笔者猜测,Faiss对知识库文档检索的需求场景并不匹配。

当然,也很有可能是笔者知识有限,没能理解Faiss的高级用法。

通过查看langchain官网文档,发现weaviate向量数据库提供了混合检索功能(关键字检索+向量检索),并经过测试发现weaviate完美解决了检索需求,因此向量检索由Faiss改为weaviate,相应的文档数据也存储到weaviate向量数据库中。

回到第一段的问题描述,由于文档中存在一个句子被拆分为两段或者多段的情况,因此在进行检索召回时,可能只查出了第一段文本,其它文本由于相似度较低未能匹配,这就会导致输出给大模型的知识缺失,进而降低大模型输出给用户的答案质量。

二、方案探索

笔者想到通过以下两种方式来解决此问题:

  1. 将拆分的每段文本长度加长,譬如到1000个字符,尽量避免出现完整句子被切断的情况。
  2. 从weaviate中查询匹配的文本,(通过文本结束符,如“。!?.!?”)判断其是否是一段完整句子,如果不是,则找到其对应的下一段文本进行补齐,并判断此文本是否包含结束符,若包含则不再查找下一段文本,如不包含则继续查找下一段文本,直到补齐文本为止。

第一个方案,虽然笔者知道治标不治本,如果极端情况下出现一个句子超过1000个字符的,那么还是会出现此问题,但是更重要的是,当把文本切分长度增加后,weaviate的搜索召回率就变低了,导致文本无法召回。

文本长度变大后,同样的query文本,与变长后的文本向量距离就会增大很多,进而导致结果无法被召回,这应该是可以预期到的结果,因此,在发现第一个方案的缺点后,重点转向通过第二个方案来实现。

三、方案实现

weaviate的资料在网上很少,所以,只能直接在weaviate官网查看提供的api信息。但是笔者认为官网提供的信息也比较浅,只是给出了简单的demo,具体的参数并没有给出详细的解释和用法。

通读官网提供的信息可以知道,存入weaviate中的每一条数据会有一个唯一的uuid来标识,那么,如果能够匹配定位到某一条数据,是否可以找到它的下一条文本数据呢?

笔者找到了after函数,函数的作用也确实是找到当前元素的下一个元素,见如下官方demo。

import os
import weaviate
import weaviate.classes as wvc
from weaviate.classes.query import Sort

client = weaviate.connect_to_local()

try:
    articles = client.collections.get("Article")
    response = articles.query.fetch_objects(
        limit=5,
        after="002d5cb3-298b-380d-addb-2e026b76c8ed"
    )

    for o in response.objects:
        print(f"Answer: {o.properties['title']}")

finally:
    client.close()

经本地测试发现,尽管找到的确实是下一个元素,但是weaviate中的元素排列与传统关系型数据库并不相同,传统关系型数据库写入的顺序是按照时间先后数据排列的,但是weaviate是向量数据库,它的数据排列应该是按照向量相似度来排列的(这是笔者的合理猜测),因此,即使文本拆分后是按时间先后顺序写入到weaviate,但是他们的前后关系并不以时间为准。

此路不通,于是寻找其它途径。经过详细查看方法fetch_objects的返回值,可以发现返回的属性值properties中自带了一个parent_id参数,于是联想到这个可能就是weaviate预留用来做向量的前后关联的。

既然数据有了uuid,也有了parent_id,再结合weavite提供了强大的Filters工具,那么整个方案就能够走通了,后面只是具体的实现。但是,实现起来也并不容易,笔者首先阅读了langchain提供的文档加载方法WeaviateVectorStore.from_documents所对应的源代码,其中提供具体实现的方法add_texts的参数metadatas和可变参数kwargs里,metadatas中可以设置属性值,可变参数kwargs中可以写入自行生成的uuid。

为什么要用自行生成uuid,而不用weaviate自带生成的?

如果用了weaviate自带生成的,数据已经“无规则”写入到weaviate中,就无法再建立前后关系,而应该在数据未写入weaviate前,提前做好数据前后关系的构建。

以下是节选数据构建及将文档写入向量数据库的代码:

kwargs = build_document_hierarchy(docs)
WeaviateVectorStore.from_documents(docs, embeddings, client=client,
                                                   index_name=index_name, **kwargs)

def build_document_hierarchy(docs):
    """
        构建文档的上下级关系,便于在查询时,查出前后关联的文档内容,拼凑出完整的句子
    """
    parent_id = None
    kwargs = {"uuids": []}
    from weaviate.util import get_valid_uuid
    from uuid import uuid4
    for doc in docs:
        _id = get_valid_uuid(uuid4())
        kwargs["uuids"].append(_id)
        doc.metadata.setdefault("parent_id", parent_id)
        parent_id = _id
    return kwargs

至此,写入的数据就带了前后的连接关系,后面就是在通过weaviate提供的数据查询方法来对确实的文本进行补全。但是这里还需要注意的一点,在使用查询方法,如笔者使用带有score分值的方法similarity_search_with_score时,默认返回值中不带uuid值,这就又得阅读源码找到WeaviateVectorStore里的_perform_search方法,可以看到return_uuids默认是False不返回的,因此,需要调用similarity_search_with_score方法时,传值return_uuids=True,即可返回uuid值。

return_uuids = kwargs.pop("return_uuids", False)
...
merged_props = {
                **obj.properties,
                **filtered_metadata,
                **({"vector": obj.vector["default"]} if obj.vector else {}),
                **({"uuid": str(obj.uuid)} if return_uuids else {}),
            }

最主要的问题已经解决,最后就是补全文本的逻辑,主要的逻辑是通过weaviate查询方法fetch_objects查询到上下的文本内容,然后根据判断文本是否已结束来确定是否结束补文本的过程。以下代码是笔者写的补全文本逻辑,包括了数据查询方法实现,供参考。

def complete_text(client, item, knowledge_id):
    """
        当前文本未结束前(以中英文结束符为标志),对文本内容进行补全,避免出现一段完整文本被切分的情况
    """
    punctuation_marks = ['.', '?', '!', '。', '?', '!']
    end_char = item[0].page_content[-1]
    _uuid = item[0].metadata["uuid"]
    from weaviate.classes.query import Filter
    while end_char and end_char not in punctuation_marks:
        collection = client.collections.get("index_" + knowledge_id)
        data_objects = collection.query.fetch_objects(
            filters=Filter.by_property("parent_id").equal(_uuid)
        ).objects
        if len(data_objects) > 0:
            item[0].page_content += data_objects[0].properties['text']
            end_char = data_objects[0].properties['text'][-1]
            _uuid = data_objects[0].uuid
        else:
            break

四、写在最后

由于笔者知识有限,如果读者在阅读过程中发现的问题,请不吝指出。

另外,笔者也还有一个问题未解决,similarity_search_with_score方法设置的alpha值,返回的数据中,有大于alpha值的情况出现,按照笔者的理解,与alpha相等时,匹配度应该是最高的,小于alpha则是越小越说明向量空间距离越大,但是大于alpha的情况就不知道是怎么来的,从返回的结果来看,这些文本对应与查询文本的向量空间距离应该是非常大,相似度很低。如果有了解的读者,望不吝赐教。

  • 13
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值