如何实现参加RAG比赛但进不了复赛的总结

好久没写文章了,断更了一个多月了,刚开始一段时间主要是上班精神内耗太严重没有精力去写文了,到六月初的时候,参加了一个RAG相关的比赛,初赛本周结束,作为菜鸟的我也是理所应当的没进复赛,跟第一名差了十分多,尝试了很多办法,但的确已经到个人能力的尽头了,决定就此放弃,这也是我第一次参加跟AI相关的比赛,而且还是自己单打独斗,也不能再强求更好了,总的来说,四个字:菜就多练🐶。

今天写这篇文章主要就是总结一下我使用的一些基本方法,虽然肯定比不上前十的大佬们的操作,但对于常规RAG实现来说也是够用的。这次的考题是给了一堆HTML的知识文档,基于这些文档来进行知识问答。这些文档是企业内部的运维相关文档,里面的内容我都看不太懂,包括有些题目我人工也没找到正确答案,主要还是依赖RAG基本实现和LLM的能力来进行解答。

知识库处理

首先第一步是对官方提供的这些HTML文件进行处理,它的根目录下有个xml文件,类似于目录的效果,我也是基于这个目录来进行文件夹的遍历的。当然我觉得如果直接遍历文件夹的每个HTML文件应该也不是不行,只是HTML文件里面包含了很多类似目录一样的页面,这些对于构建我的向量库来说作用不是很大,但如果构建知识图谱的话,我觉得还是很有用的,但我对知识图谱是在约等于一无所知,就放弃了这些数据。

HTML文件的分段处理有很多种方法,在 langchain 里面就有很多用于分段的工具,比如直接分割HTML的HTMLHeaderTextSplitter ,有递归分割的 RecursiveCharacterTextSplitter , 针对Markdown 文件的 MarkdownHeaderTextSplitter 等。我这里是将HTML处理成Markdown后使用 Markdown 的分割器进行分割的。但我这里做了一些特殊处理:

1. HTML转Markdown之前的特殊处理

首先,观察HTML里面的代码, 找到适合作为标题的标签对应的class,将这些元素的标签转换成h1h2 这种一二级标题的常规HTML标签。在原HTML中,用的不是这种标签,会导致我转换Markdown的时候丢失标题的标记。

此外,找到HTML中表格相关标签,因为我使用的html2text库进行的html转换,并不能很好的处理表格,因此我这里是对于表格标签放置了特殊标记,然后转换的过程中对于特殊标记进行了转换。实现代码如下:

def html_to_markdown(dst_url):  
    try:  
        with open(dst_url, 'r', encoding='utf-8') as f:  
            html_content = f.read()  
    except UnicodeDecodeError:  
        with open(dst_url, 'r', encoding='gb2312') as f:  
            html_content = f.read()  
    # 解析HTML内容  
    soup_root = BeautifulSoup(html_content, 'html.parser')  
  
    body = soup_root.find('body')  
    soup = BeautifulSoup(str(body), 'html.parser')  
  
    # 根据class属性修改HTML结构  
    for element in soup.find_all(class_=["title", "topictitle"]):  
        if "topictitle" in element.get("class", []):  
            element.name = "h1"  # 将class为title的标签转换为<h1>  
        elif "title" in element.get("class", []):  
            element.name = "h2"  # 将class为topictitle的标签转换为<h1>  
  
    # 处理表格部分  
    markdown_tables = {}  
    table_id = 0  
    for table in soup.find_all('table'):  
        markdown_table = convert_table_to_markdown(table)  
        placeholder = f"[[TABLE_{table_id}]]"  
        markdown_tables[placeholder] = markdown_table  
        table.replace_with(soup.new_string(placeholder))  
        table_id += 1  
  
    # 使用html2text处理剩余的HTML内容  
    h = html2text.HTML2Text()  
    h.ignore_links = True  
    markdown = h.handle(str(soup))  
  
    # 替换占位符为转换后的Markdown表格  
    for placeholder, markdown_table in markdown_tables.items():  
        markdown = markdown.replace(placeholder, markdown_table)  
  
    # 移除多余的空行  
    markdown = '\n'.join([line for line in markdown.split('\n') if line.strip() != ''])  
  
    return markdown  
  
  
def convert_table_to_markdown(table):  
    rows = table.find_all('tr')  
    markdown = []  
  
    for row in rows:  
        cols = row.find_all(['th', 'td'])  
        col_text = [col.get_text(strip=True) for col in cols]  
        markdown.append('| ' + ' | '.join(col_text) + ' |')  
  
    # 添加表头分隔符  
    if len(rows) > 1:  
        header_cols = rows[0].find_all(['th', 'td'])  
        header_separator = '| ' + ' | '.join(['---'] * len(header_cols)) + ' |'  
        markdown.insert(1, header_separator)  
  
    # 将表格内容用换行符连接起来  
    return '\n'.join(markdown) + '\n\n'  # 添加两个换行符

2. 关于分段

上面说了我使用的分割器是 MarkdownHeaderTextSplitter ,这种可以会把段落内容和标题放不同的字段返回,标题是作为拆分后的元数据存在的,这个切分方式相对于直接使用 RecursiveCharacterTextSplitter 的好处在于能知道标题和段落的关系,对于我数据存储的时候,可以标记每个段落对应的标题是什么,同样的如果构建知识图谱,我想这也是一种必要的手段。

第二点要注意的跟上面预处理html类似,就是要额外处理表格部分。因为我们知道,一个段落是不能太长,否则会影响搜索以及作为背景知识给LLM的时候很容易超长,那么一般都会设置一个最大值,我这里针对段落大于最大值的会特殊处理,会逐行读取,避免超过最大值的情况。普通段落倒是好说,表格如果也这么处理就会导致表格数据割裂,因为后面的数据都没有表头了,自然就变成了脏数据。因此这里遇到表格的时候,我会将一个大表格拆分成N个小表格,但每个小表格还是会保留表头,这样如果搜索到了表格的数据,至少会是一个完整的表格形式。我想这个小技巧应该还是很实用的。

3. 知识库数据模型

知识库的存储方案我使用的是ElasticSearch,很多做Java的同学应该对他都不陌生,一些需要搜索引擎的需求都会使用到它。那么把它用到RAG的知识库搜索和存储上自然也是合适的。而且它也是支持向量检索的,只需要设置一下分数计算函数,就可以让返回的score变成向量的余弦相似度,非常方便。

ES的数据建模我设置了挺多的元数据字段,原本是期望这些元数据能帮我提升搜索的准确性,但后来还是没有用上,但我觉得是我使用的方式不对,因此我也贴一下:

class DataModel:  
    def __init__(self, root: str, name: str, content: str, url: str, doctype: str, catalogs: [str], keywords: [str],  
                 vector: [float], titles: [str] = [], parent: str = '', seg_index: int = 0):  
        # 根目录名称
        self.root = root  
        # 文档名称(不是文件名称)  
        self.name = name  
        # 文本内容  
        self.content = content  
        # 文档路径
        self.url = url  
        # 文档类型  
        self.doctype = doctype  
        # 目录,从上至下  
        self.catalogs = catalogs  
        # 关键词,目录也作为关键词存在  
        self.keywords = keywords  
        # 向量  
        self.vector = vector  
        # 标题,从1级到2级  
        self.titles = titles  
        # 父标题  
        self.parent = parent  
        # 在这个标题下的段落序号  
        self.seg_index = seg_index

确认好了数据模型,只要遍历目录去拿所有的文件,进行分段并存储,全部分段存储之后可以再使用embedding模型得到每个段落的向量,存储到vector这个字段。我这里使用的embedding模型是来自网易有道的bce-embedding-base_v1, 除了这个,我也推荐使用bge-large-zh-v1.5 以及 text2vec-base-chinese 都是比较好用的embedding模型,如果只是使用简单的向量检索,使用这几个都是OK的。

初版实现

基于上面的处理过的知识库就已经可以实现RAG了,将query向量化并进行向量匹配即可,此外本次试题里面是标注了每个问题是来自于哪个根目录,因此可以es搜索的时候额外加上根目录筛选的条件,对于搜索范围是小了很多的。

def search_by_vector(query_vector, root_value, top_n=10):  
    query = {  
        "size": top_n,  
        "query": {  
            "bool": {  
                "must": [  
                    {  
                        "script_score": {  
                            "query": {  
                                "match_all": {}  
                            },  
                            "script": {  
                                "source": "(cosineSimilarity(params.query_vector, 'vector') + 1.0) / 2",  
                                "params": {  
                                    "query_vector": query_vector  
                                }  
                            }                        }                    }                ],  
                "filter": [  
                    {  
                        "term": {  
                            "root.keyword": root_value  
                        }  
                    }                ]  
            }  
        }    }  
    response = es.search(index=index_name, body=query)  
    return response

es中要想使用使用相似度作为分数,可以在搜索语句里面加上script,将相似度的计算方式放进去,同样为了使得分数都是大于0,公式使用(similarity+1)/ 2 即可。这种Indexing->Retrieval ->Query 的方式就是最常见的RAG流程,可以称作Naive RAG.

检索优化

基于上面这个策略的到的结果只是堪堪及格,这倒是很合理的结果,虽然我一开始以为及格都难,可能是分段策略还凑合,所以结果没那么差。如果想自己本地部署一个RAG系统,我是觉得只要做好分段这一块,就能解决六成以上的问题了,因为如果要采取一些其他优化策略,势必会对硬件资源以及响应时间有很大影响,有时候没有这个必要。

那么现在如果想再提升效果,有什么简单又快速的方法呢?

我这里首先想到的还是检索优化,因为分段以及搜索使用向量检索的原因,如果一个段落的上下文实际上和当前段落是紧密相关的,但跟query的相似度又不高,很容易导致一些关键信息的丢失,尤其是我看到有些文档实际上是针对某个概念的解释,那如果只是检索到其中一个段落,很容易丢失关键信息。现在大家看到的上面的DataModel这个类实际上一开始我是没有设计seg_index这个字段的。是在这次检索优化中使用的,当检索到某个段落的时候,会带上它前面的n段和后面的m段,我实际使用的n=m=1。并且为了防止结果的list里面存在重复的段落,要记得进行段落的去重,不然很容易背景知识的list里面实际上都是重复的内容。

def retrieve(query: str, document: str, top_n=10):  
    vec = embedding.embedding(query)  
    kg = es.search_by_vector(vec, document, top_n=top_n)  
    hits = kg['hits']['hits']  
    # 找到对应的上下文  
    combines = []  
    for hit in hits:  
        _id = hit['_id']  
        source = hit['_source']  
        score = hit['_score']  
        url = source['url']  
        hit_content = source['content']  
        seg_index = source['seg_index']  
        parent = source['parent']  
        current_hit = {'id': _id, 'content': hit_content}  
        # print(f'{url}, {score}, {hit_content}')  
  
        if seg_index == 0:  
            query_index = [1]  
        else:  
            query_index = [seg_index - 1, seg_index + 1]  
  
        context = []  
        for index in query_index:  
            results = es.search_documents(url, parent, index)  
            # 相邻结果  
            if len(results['hits']['hits']) == 0:  
                continue  
  
            near_hit = results['hits']['hits'][0]  
            near_id = near_hit['_id']  
            near_content = near_hit['_source']['content']  
  
            content = {'id': near_id, 'content': near_content}  
            context.append(content)  
  
        if len(context) > 0:  
            if len(query_index) > 1:  
                if len(context) == 1:  
                    combine = [context[0], current_hit]  
                else:  
                    combine = [context[0], current_hit, context[1]]  
            else:  
                combine = [current_hit, context[0]]  
            combines.append(combine)  
        else:  
            combines.append([current_hit])  
    # 合并重复段落  
    distinct_results = merge_combinations(combines)  
    distinct_contents = ["\n".join(item['content'] for item in sublist) for sublist in distinct_results]  
    return distinct_contents  
  
  
def merge_combinations(combines):  
    def find_combination_with_id(combinations, target_id):  
        for combination in combinations:  
            if any(item['id'] == target_id for item in combination):  
                return combination  
        return None  
  
    merged_combinations = []  
  
    for combination in combines:  
        current_combination = []  
        for item in combination:  
            existing_combination = find_combination_with_id(merged_combinations, item['id'])  
            if existing_combination:  
                # 合并当前组合中的元素到已存在的组合中  
                existing_combination.extend(x for x in combination if x not in existing_combination)  
                break  
        else:  
            # 如果没有找到包含当前id的组合,则添加新的组合  
            merged_combinations.append(combination)  
  
    return merged_combinations

基于这个策略,结果会比前面的版本好不少,这也是我最终分数的策略,听起来挺好笑的,这是我开赛第一周就拿到的结果,当时还排名靠前,但后面两周做的所有优化反而还不如这个Naive RAG 的版本,但比赛这玩意就是不进则退,等到最后比赛排名就很垃圾了。虽然心有不甘,但谁让自己太菜了呢。

Query 优化

接下来讲讲我都做了哪些优化,虽然没做好,但思想应该是对的,只是策略使用的方式不对,所以理论不等于实践,要想真的学习好还是要多进行实践,哪怕失败了,这些失败的经验对自己的成长也是有帮助的。因此我也给大家分享一下这些策略。

首先是Query 方面的修改,常见的策略有很多,比如Query扩写改写、HyDE、问题拆解、提取关键词进行查询等。我这里尝试了HyDE, 问题拆解以及提取关键词的策略。对于改写Query的策略为什么不使用,因为我觉得一般来说是提问比较不精确的时候可以使用,但这次的题目都是比较明确的问题,因此没必要进行改写或者扩写。

问题拆解

对于问题拆解,其实就是将问题拆分成多个子问题,比如张三在24年的奥林匹克数学竞赛上有没有超过李四? 可以拆解成:

  • 24年奥林匹克数学竞赛成绩排名
  • 张三比赛中成绩排名是多少
  • 李四比赛中成绩排名是多少

通过综合这几个问题的查询结果可以得到最终张三是否超过了李四。但也有一些无法拆解的问题,比如张三的数学成绩是多少?那么其实只需要搜索张三的分数即可,这种情况的子查询就等于它原本的问题。对于问题拆解, 可以直接使用LLM来进行拆分,我使用的prompt如下:

你是一名顶级运维工程师,可以针对用户的输入问题生成多个子查询问题,每个问题独立一行输出。  
首先你需要判断用户是否真的问了多个问题,如果没有,你就原样输出用户问题;  
如果用户真的询问了多个问题,请你拆解成多个子问题。  
  
重要提示:  
- 不要添加任何解释和文本。

这个拆分的效果还有待斟酌,有时候会拆出一些奇奇怪怪的问题,可以通过LLM的反思等策略进行过滤。

HyDE(Hypothetical Document Embeddings)

这个策略来自于一篇论文:Precise Zero-Shot Dense Retrieval without Relevance Labels ,实现如其名字描述的一样,假设文档嵌入。具体做法就是使用LLM生成虚构的文档,再将文档进行嵌入搜索。简单来说,就是首先让LLM不依赖于外部知识的情况下,对Query生成一个答案,再使用这个答案来进行向量检索。

我使用的prompt比较简单,忘了是从Langchain还是llamaIndex里面薅的了。

请写一段话回答问题  
尽量包含关键细节。  
  
{content}

这个策略使用下来的感受就是,它可能不太适合知识过于私有化的情况,就是你的问题和答案几乎不可能存在于互联网上的那种,全是公司特有名词的知识。总之如果想单独使用这个策略的话,效果会非常差,建议如果想用的话,要考虑结合其他策略来进行进一步的知识过滤,否则很容易降低效率和准确度。

关键词搜索

对于关键词搜索这个我尝试了两种方式,一种准确来讲不是关键词搜索,而是直接使用ES的全文搜索能力,ES是支持使用其他的分词器的,我使用的中文支持比较好的ik分词器。ES进行搜索的时候如果使用 match 的方式,就会对Query进行分词搜索而不是完全匹配,我的理解这是跟关键词检索有点类似,这也是我多路召回的其中一路。

另外一种方式就是使用大模型进行了关键词提取,无论是Query和段落都要进行提取,上面的 DataModel 也能看到我是留了关键词这个字段的,一开始预处理数据的时候,关键词就是目录名称,但这肯定是不够的,因此我用大模型针对每条数据又进行了新的关键词补充。

你是一名运维技术专家,能阅读并理解运维相关的技术文档,熟悉当前市面上的各种运维产品,对于常见的品牌如华为/中兴等的硬件设备都很熟悉。  
现在我将会给你发送一些运维文档的段落内容,你需要从段落中提取这段内容的关键词,并遵守以下规则:  
1. 多个关键词用英文逗号隔开,如 关键词1,关键词2,关键词3  
2. 关键词必须在原文中出现过,不可以随便臆造  
3. 允许出现某个关键词包含了另一个关键词的情况,举个例子:高等数学,数学。这两个关键词有包含关系,但允许同时出现。  
请务必按照规则给我提取关键词。  

数据处理完之后,查询时先让LLM提取出Query的关键词,然后使用关键词进行匹配得到一些段落。在某些情况下,关键词可以召回一些向量相似度低但实际很重要的知识,因为embedding的模型使用的是通用模型,对于一些私有化知识的embedding效果并不一定那么好,而且向量相似度的高低并不完全等价于语义相似度,可能两句语义完全相反的内容但相似度却也很高。

搜索结果处理

上面对于Query进行了多种预处理,就意味着一个Query进入我们的RAG链路后,会分叉处多个Query,每个Query都需要进行检索,那么就需要对所有检索的结果进行一个筛选或者排序,找到真正需要的参考资料。最简单的方式就是重排序,我使用的模型是 bge-reranker-base ,其实一开始用的是大一点的版本bge-reranker-large ,但我是在自己的PC电脑上跑这些程序的,家里显卡比较烂,一个embedding模型加一个reranker模型很容易崩,因此就降级了reranker模型的规模,而且就算降级了,如果对所有召回结果进行重排序,也出现过显存不足的情况,我是通过分多次进行小批量的重排才让代码能顺利跑下去。不排除使用large版本并且进行整体重排序的效果会更好一点,但不会好到让我能产生质变,因此也没有想其他办法解决硬件问题了。

除了重排序,我还尝试了另一个方式,就是利用大模型的反思来过滤文档,这个方法怎么说呢,我觉得我使用的方式大概率是错误的,即让模型来判断段落能否支撑它来进行问答:

我有一段关于运维的材料的文本,内容如下:  
  
{content}  
  
然后我现在需要根据上述文本内容回答一个问题如下:  
{question}  
  
你觉得依靠这些内容能回答这个问题么?如果能,回复是;如果不能,回复否。  
  
重要提示:  
 - 不要添加任何解释和文本。

我做的最错误的可能是对于每个段落都让它去判断了,因为有时候一个问题需要多个段落才能判断的,那么可能对于很多实际有价值的都会返回否。而对于能回答的段落,也没啥过滤的必要,这个策略使用的很失败,因此我最终版的代码也是完全没用上的。要利用反思来增强RAG效果,更有效的应该是使用类似SelfRAG这样的框架,而不是简单的让LLM去判断。

知识图谱

最后,我要提一下我觉得最最最重要的方式,就是结合知识图谱去做RAG,我本次没有成功实现出来,所以很难给出太多的分享,主要还是这方面几乎是小白,学习起来没那么快,但也觉得它一定是当前将RAG做到极致的最佳方式。

RAG最难的问题是什么?我觉得就是检索,无论是query改写、重排序、反思等,都是为了让LLM能排除掉错误信息,只拿到最精准的文档来进行问答。最麻烦的场景就是多跳问题,即问题的答案存在于多个文档或段落中,甚至你需要通过推理才能得到应当要查询哪些段落。

在llamaIndex里面我有找到使用LLM提取知识图谱的方式,但我尝试提取了里面的prompt然后使用LLM去创建知识图谱,效果并不好,而且不知道是不是段落太长,提取速度也比较慢,下面是我提取的prompt翻译后的中文版:

你是一个顶级算法工程师,旨在从结构化格式的文本中提取信息,以构建知识图谱。你的任务是从给定的文本中识别用户提示中请求的实体和关系。  
你必须生成包含JSON对象列表的输出。每个对象应具有以下键:“head”、“head_type”、“relation”、“tail”和“tail_type”。  
“head”键必须包含从提供的列表中提取的实体文本。  
“head_type”键必须包含提取的head实体的类型  
“relation”键必须包含head和tail之间关系的类型  
“tail”键必须表示提取实体的文本,该实体是关系的tail  
“tail_type”键必须包含提取的tail实体的类型  
  
尝试提取尽可能多的实体和关系。保持实体一致性:在提取实体时,确保一致性非常重要。如果一个实体(例如“John Doe”)在文本中多次提到,但使用不同的名称或代词(例如“Joe”、“他”),始终使用最完整的标识符来表示该实体。知识图谱应该是连贯且易于理解的,因此保持实体引用的一致性至关重要。  
重要提示:  
- 不要添加任何解释和文本。

蚂蚁和微软最近都开源了其Graph RAG的框架,感兴趣的可以去看看相关论文以及代码。

最后给一些我整理的关于提升RAG效果的一些论文,感兴趣的可以看看,如果觉得不够,References里面的论文也很多都值得一看的:

  • GRAG: Graph Retrieval-Augmented Generation
  • Retrieval-Augmented Generation for Large Language Models: A Survey
  • From Local to Global: A Graph RAG Approach to Query-Focused Summarization
  • Multi-Head RAG: Solving Multi-Aspect Problems with LLMs
  • Enhancing Knowledge Graph Construction Using Large Language Models
  • Improving the Domain Adaptation of Retrieval Augmented Generation (RAG) Models for Open Domain Question Answering
  • Prompt-Guided Retrieval Augmentation for Non-Knowledge-Intensive Tasks
  • RAGAS: Automated Evaluation of Retrieval Augmented Generation
  • Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection
  • Precise Zero-Shot Dense Retrieval without Relevance Labels
  • Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks
  • Knowledge-Augmented Language Model Prompting for Zero-Shot Knowledge Graph Question Answering
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇