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