LLM應用實戰:當KBQA整合LLM(二)

mengrennwpu發表於2024-04-25

1. 背景

又兩週過去了,本qiang~依然奮鬥在上週提到的專案KBQA整合LLM,感興趣的可透過傳送門查閱先前的文章《LLM應用實戰:當KBQA整合LLM》。

本次又有什麼更新呢?主要是針對上次提到的缺點進行最佳化改進。主要包含如下方面:

1. 資料落庫

上次文章提到,KBQA服務會將圖譜的概念、屬性、實體、屬性值全部載入到記憶體,所有的查詢均在記憶體中進行,隨之而來的問題就是如果圖譜的體量很大呢,那記憶體不爆了麼…

2. 支援基於屬性值查實體

上篇文章不支援屬性值查詢實體,比如”最會照顧寶寶的是什麼龍”,”什麼龍是大龍和大龍生活,小龍和小龍生活”。本次已經此問題最佳化。

此篇文章是對這兩週工作的一個整體總結,其中包含部分工程層面的最佳化。

2. 整體框架

整體框架和上篇大致相同,不同之處在於:

1. 對齊模組:先前是基於SIM篩選候選實體,本次基於ES進行候選實體召回

2. 解析模組:先前是基於hugegraph和記憶體中的實體資訊進行解析,本次最佳化為基於hugegraph和elasticsearch

3. 核心功能

3.1 資料庫選型

由於需要支撐語義相似度檢索,因此資料庫選型為Milvus與Elasticsearch。

二者之間的比對如下:

Milvus

Elastic

擴充套件性層面

儲存和計算分離

查詢和插入分類

元件級別支援

伺服器層面支援

多副本

動態分段 vs 靜態分片

動態分段

靜態分片

雲原生

十億級規模向量支援

功能性層面

許可權控制

磁碟索引支撐

混合搜尋

分割槽/名稱空間/邏輯組

索引型別

11個(FLAT, IVF_FLAT, HNSW)等

1個(HNSW)

多記憶體索引支援

專門構建層面

為向量而設計

可調一致性

流批向量資料支援

二進位制向量支援

多語言SDK

python, java, go, c++, node.js, ruby

python, java, go, c++, node.js, ruby, Rust, C#, PHP, Perl

資料庫回滾

但由於Milvus針對國產化環境如華為Atlas適配不佳,而Es支援國產化環境,因此考慮到環境通用性,選擇Es,且其文字搜尋能力較強。

3.2 表結構設計

由於知識圖譜的概念、屬性一般量級較少,而實體數隨著原始資料的豐富程度客場可短。因此將實體及其屬性值在Es中進行儲存。

針對KBQA整合LLM的場景,有兩塊內容會涉及語義搜尋召回。

1. 對齊prompt中的候選實體

2. 解析模組中存在需要基於屬性值查詢實體的情況。

3. 涉及到數值型別的查詢,如大於xx,最大,最小之類。

綜合考慮,將Es的index結構設計如下:

屬性

含義

型別

備註

name

實體名

keyword

concepts

所屬概念

keyword

一個實體可能存在多個概念

property

屬性

keyword

屬性名稱

value

屬性值

text

ik分詞器進行分詞

numbers

數值屬性值

double_range

會存在一個區間範圍

embeddings

向量

elastiknn_dense_float_vector

1. 非數值屬性對應value的向量

2. 使用elastiknn外掛

3.3 安裝部署

專案使用的Es版本是8.12.2,原因是elastiknn外掛和Ik外掛針對該版本均支援,且8.12.2版本是當前階段的次新版本。

3.3.1 基於docker的ES部署

# 拉取映象(最好先設定國內映象加入)
docker pull elasticsearch:8.12.2

# es容器啟動,存在SSL鑑權
docker run -d --name es01 --net host  -p 9200:9200 -it -e "ES_JAVA_OPTS=-Xms1024m -Xmx1024m" elasticsearch:8.13.2

# 容器中拉取需要鑑權的資訊到本地
docker cp es01:/usr/share/elasticsearch/config/certs/http_ca.crt .
chmode 777 http_ca.crt

# 密碼第一次啟動的日誌中有,需要儲存下來
export ELASTIC_PASSWORD=xxxxxx

# 驗證es是否啟動成功
curl --cacert http_ca.crt -u elastic:$ELASTIC_PASSWORD https://localhost:9200

3.3.2 elastiknn外掛整合

elastiknn外掛是為了最佳化ES自身的向量檢索效能,安裝此外掛後,ES的向量檢索效能會提升數倍,如果再增加SSD固態硬碟,效能會進一步提升數倍。

#下載外掛包
wget https://github.com/alexklibisz/elastiknn/releases/download/8.12.2.1/elastiknn-8.12.2.1.zip

# 匯入容器中指定目錄
docker cp  elastiknn-8.12.2.1.zip es01:/usr/share/elasticsearch/

# 進入容器,預設目錄即為/usr/share/elasticsearch/
docker exec -it es01 bash

# 安裝外掛
elasticsearch-plugin install file:elastiknn-8.12.2.1.zip

# 退出,重啟容器
docker restart es01

# 驗證
# 建立mapping
curl --cacert http_ca.crt -u elastic:$ELASTIC_PASSWORD -XPOST https://localhost:9200/test/_mapping -H 'Content-Type:application/json' -d '
{
    "properties": {
        "embeddings": {
            "type": "elastiknn_dense_float_vector",
            "elastiknn": {
                "model": "lsh",
                "similarity": "cosine",
                "dims": 768,
                "L": 99,
                "k": 3
            }
        }
    }
}'

# 驗證mapping是否生效
curl --cacert http_ca.crt -u elastic:$ELASTIC_PASSWORD -XGET https://localhost:9200/test/_mapping?pretty

採坑總結:

1. elastiknn外掛匯入始終無法安裝,且報錯。

解決:

(1) 一定要注意,安裝es外掛需要指定路徑,且增加”file:” 的字首,不加此字首,那就等著報錯吧

(2) 複製到容器內部,一定要注意,不要將elastiknn-8.12.2.1.zip複製至/usr/share/elasticsearch/plugins目錄,否則安裝也報錯。

3.3.3 ik分詞器外掛整合

#下載外掛包
wget https://github.com/infinilabs/analysis-ik/releases/download/v8.12.2/elasticsearch-analysis-ik-8.12.2.zip

# 匯入容器中指定目錄
docker cp elasticsearch-analysis-ik-8.12.2.zip es01:/usr/share/elasticsearch/

# 進入容器,預設目錄即為/usr/share/elasticsearch/
docker exec -it es01 bash

# 安裝外掛
elasticsearch-plugin install file:elasticsearch-analysis-ik-8.12.2.zip

# 退出,重啟容器
docker restart es01

# 驗證是否生效
curl --cacert http_ca.crt -u elastic:$ELASTIC_PASSWORD -XPOST https://localhost:9200/_analyze?pretty -H 'Content-Type:application/json' -d '{"text":"三角龍或者霸王龍","analyzer": "ik_smart"}'
# 返回結果中不包含”或者”,因為”或者”在預設的停用詞表中。

採坑總結:

1. ik分詞器外掛匯入始終無法安裝,且報錯。

解決:一定要注意,安裝es外掛需要指定路徑,且增加”file:” 的字首,不加此字首,那就等著報錯吧

2. ik分詞器新增自定義專有名詞以及停用詞不生效(浪費了1天的時間來排查)

解決:

(1) 一定要注意,8.12.2版本的ik分詞器如果想要配置自定義專有名詞或停用詞,配置的完整目錄是/usr/share/elasticsearch/config/analysis-ik,而不是/usr/share/elasticsearch/plugins/analysis-ik,這點需要注意下。

在config/analysis-ik中配置IKAnalyzer.cfg.xml,修改內容如下:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
    <comment>IK Analyzer 擴充套件配置</comment>
    <!--使用者可以在這裡配置自己的擴充套件字典 -->
    <entry key="ext_dict">extra_main.dic</entry>
     <!--使用者可以在這裡配置自己的擴充套件停止詞字典-->
    <entry key="ext_stopwords">extra_stopword.dic</entry>
    <!--使用者可以在這裡配置遠端擴充套件字典 -->
    <!-- <entry key="remote_ext_dict">words_location</entry> -->
    <!--使用者可以在這裡配置遠端擴充套件停止詞字典-->
    <!-- <entry key="remote_ext_stopwords">words_location</entry> -->
</properties>

(2) 一定要注意,extra_main.dic和extra_stopword.dic的編碼格式是UTF-8,如果編碼格式不對的話,分詞也不生效。

4. Es操作相關原始碼

4.1 es_client連線

self.es_client = Elasticsearch(config['url'], 
                               basic_auth=(config['user'], config['password']), 
                               ca_certs=config['crt_path'],
                               http_compress=True,
                               request_timeout=int(config['request_timeout']) if 'request_timeout' in config else 60,
                               max_retries=int(config['max_retries']) if 'max_retries' in config else 5,
                               retry_on_timeout=True)

4.2 構建表結構

def index(self, kg_id, force=False):
    """
    構建表
    """
    if force:
        try:
            self.es_client.indices.delete(index=kg_id, ignore_unavailable=True)
        except EngineError as e:
            logger.exception(f"code:{ES_DELETE_INDEX_ERROR}, message:{str(e)}")
            raise e

    if not self.es_client.indices.exists(index=kg_id):
        body = {
            'settings': {'index': {'number_of_shards': 2}},
            'mappings': {
                'dynamic': False,
                'properties': {
                    'name': {'type': 'keyword'},
                    'concepts': {'type': 'keyword'},
                    'property': {'type': 'keyword'},
                    'value': {'type': 'text', 'analyzer': 'ik_max_word', 'search_analyzer': 'ik_smart'},
                    'numbers': {'type': 'double_range'},
                    'embeddings': {'type': 'elastiknn_dense_float_vector', 'elastiknn': {'dims': 768, 'model': 'lsh', 'similarity': 'cosine', 'L': 99, 'k': 3}}
                }
            }
        }
        try:
            self.es_client.indices.create(index=kg_id, body=body)
        except EngineError as e:
            logger.exception(f"code:1008, message:{str(e)}")
            raise e
    try:   
        self.es_client.indices.refresh(index=kg_id, ignore_unavailable=True)
    except EngineError as e:
        logger.exception(f"code:1008, message:{str(e)}")
        raise e

說明:

1. value欄位需要經過IK分詞,分詞方式ik_max_word,查詢方式是ik_smart

2. embeddings的型別為elastiknn_dense_float_vector,其中向量維度為768,相似度計算使用cosine

4.3 候選實體查詢

def get_candidate_entities(self, kg_id, query, limit=15):
    """
    基於查詢串查詢候選實體名稱
    """
    body = {
        '_source': {'excludes': ["embeddings"]},
        'query': {
            'function_score': {
                'query': {
                    'bool': {
                        'must': [
                            {'match': {'value': query}},
                            {'bool': {
                                'filter': {
                                    'bool': {
                                        'should': [
                                            {'term': {"property": "名稱"}},
                                            {'term': {"property": "別名"}},
                                        ]
                                    }
                                }
                            }}
                        ]
                    }
                },
                'functions': [
                    {
                       'elastiknn_nearest_neighbors': {
                           'field': 'embeddings',
                           'vec': self.get_callback_ans({'query': [query]})['result'][0]['embeddings'],
                           'model': 'lsh',
                           'similarity': 'cosine',
                           'candidates': 100
                       } 
                    }
                ]
            }
        },
        'size': limit
    }
    return self.es_client.search(index=kg_id, body=body)['hits']['hits']

說明:

1. '_source': {'excludes': ["embeddings"]}表示輸出結果中過濾embeddings欄位

2. 查詢以function_score方式,其中的query表示別名或名稱與問題的匹配程度,functions表示打分方式,目前的打分是基於向量相似度進行打分,其中, self.get_callback_ans表示語義相似度模型將文字轉換為向量。注意:最終的得分由兩部分組成,一部分是文字匹配,一部分是語義相似度匹配,不過可以增加引數boost_mode進行設定。

4.4 基於屬性及屬性值進行查詢

def search_by_property_value(self, kg_id, property, value, limit=100):
    body = {
        '_source': {'excludes': ["embeddings"]},
        'query': {
            'function_score': {
                'query': {
                    'bool': {
                        'must': [
                            {'match': {'value': value}},
                            {'term': {"property": property}}
                        ]
                    }
                },
                'functions': [
                    {
                       'elastiknn_nearest_neighbors': {
                           'field': 'embeddings',
                           'vec': self.get_callback_ans({'query': [value]})['result'][0]['embeddings'],
                           'model': 'lsh',
                           'similarity': 'cosine',
                           'candidates': 100
                       } 
                    }
                ],
                'boost_mode': 'replace'
            }
        },
        'size': limit
    }
    try:
        return self.es_client.search(index=kg_id, body=body)['hits']['hits']
    except EngineError as e:
        logger.exception(f"code:{ES_SEARCH_ERROR}, message:{str(e)}")
        raise e

4.5 數值屬性範圍查詢

主要解決的場景有:體重大於9噸的恐龍有哪些?身長小於10米的角龍類有哪些?

其中,如果提供了實體名稱,則查詢範圍是基於這些實體進行查詢比較。

def search_by_number_property(self, kg_id, property, operate, entities, limit=100):
    musts = [{'term': {'property': property}}, {'range': {'numbers': operate}}]
    if entities:
        musts.append({'terms': {'name': entities}})

    body = {
        '_source': {'excludes': ['embeddings']},
        'query': {
            'bool': {
                'must': musts
            }
        },
        'size': limit
    }
    try:
        return self.es_client.search(index=kg_id, body=body)['hits']['hits']
    except EngineError as e:
        logger.exception(f"code:{ES_SEARCH_ERROR}, message:{str(e)}")
        raise e

4.6 數值屬性最大最小查詢

實現最大最小的邏輯,採用了sort機制,按照numbers進行排序,最大則順排,最小則倒排。

def search_by_number_property_maxmin(self, kg_id, property, entities, sort_flag):
    musts = [{'term': {'property': property}}]
    if entities:
        musts.append({'terms': {'name': entities}})

    body = {
        '_source': {'excludes': ["embeddings"]},
        'query': {
            'bool': {
                'must': musts
            }
        },
        'sort': {'numbers': sort_flag},
        'size': 1
    }
    try:
        return self.es_client.search(index=kg_id, body=body)['hits']['hits']
    except EngineError as e:
        logger.exception(f"code:{ES_SEARCH_ERROR}, message:{str(e)}")
        raise e 

5. 效果

上一版未解決的問題,在本版本最佳化的結果。

1. 問:頭像鴨頭的龍有哪些?

答:頭像鴨頭的有慈母龍、原角龍、鸚鵡嘴龍、姜氏巴克龍、奇異遼寧龍、多背棘沱江龍、陸家屯鸚鵡嘴龍、蓋斯頓龍、小盾龍、腫頭龍、彎龍

2. 問:老師說的有一個特別會照顧寶寶的恐龍是什麼龍?

答:慈母龍會照顧寶寶。

3. 問:有哪些恐龍會游泳啊?

答:滑齒龍、慢龍和色雷斯龍是會游泳的恐龍。

4. 問:科學家在義大利阿爾卑斯山脈Preone山谷的烏迪內附近發現了一個會飛的史前動物化石,它是誰的化石?

答:科學家在義大利阿爾卑斯山脈Preone山谷的烏迪內附近發現的會飛的史前動物化石是沛溫翼龍的化石。

6. 總結

一句話足矣~

本文主要是針對KBQA方案基於LLM實現存在的問題進行最佳化,主要涉及到圖譜儲存至Es,且支援Es的向量檢索,還有解決了一部分基於屬性值倒查實體的場景,且效果相對提升。

其次,提供了部分Es的操作原始碼,以飧讀者。

附件:

1. es vs milvus: https://zilliz.com/comparison/milvus-vs-elastic

2. docker安裝es:https://www.elastic.co/guide/en/elasticsearch/reference/8.12/docker.html

3. elastiknn效能分析:https://blog.csdn.net/star1210644725/article/details/134021552

4. es的function_score: https://www.elastic.co/guide/en/elasticsearch/reference/8.12/query-dsl-function-score-query.html

相關文章