การค้นหาความหมายด้วย HuggingFace และ Elasticsearch

Nov 29 2022
มาจัดอันดับข้อความในชุดข้อมูลโดยใช้การค้นหาเพื่อนบ้านที่ใกล้ที่สุด
การฝังแบบหนาแน่นเป็นตัวเปลี่ยนเกมในแมชชีนเลิร์นนิง โดยเฉพาะอย่างยิ่งในเครื่องมือค้นหาและระบบผู้แนะนำ ขณะนี้การฝังแบบหนาแน่นกำลังถูกนำไปใช้ในการดึงข้อมูลเฉพาะกิจ การค้นหาผลิตภัณฑ์ เครื่องมือแนะนำ ฯลฯ
ภาพถ่ายโดย Markus Winkler บน Unsplash

การฝังแบบหนาแน่นเป็นตัวเปลี่ยนเกมในแมชชีนเลิร์นนิง โดยเฉพาะอย่างยิ่งในเครื่องมือค้นหาและระบบผู้แนะนำ ขณะนี้มีการใช้การฝังแบบหนาแน่นในการดึงข้อมูลแบบเฉพาะกิจ การค้นหาผลิตภัณฑ์ เครื่องมือแนะนำ ฯลฯ ปัจจุบันหลายบริษัทกำลังใช้รูปแบบการค้นหาแบบฝังในเวิร์กโฟลว์ของตน

บทความบางบทความเกี่ยวกับวิธีการรวมการฝังที่หนาแน่นเข้ากับบริษัทชั้นนำได้แก่Instacart , DoorDash , Etsy , GoogleและAirbnb

พูดคุยกับหนังสือโดย Google เป็นการสาธิตที่ดีว่าการค้นหาโดยใช้เวกเตอร์หนาแน่นทำงานอย่างไร การค้นหารูปแบบนี้นิยมเรียกว่าการค้นหาความหมาย การสาธิตใช้โมเดลตัวเข้ารหัสเพื่อสร้างการฝังจากเอกสาร (หนังสือในบริบทนี้) ที่จัดเก็บไว้ในดัชนีและเปรียบเทียบกับเวกเตอร์การค้นหาในเวลาค้นหาเพื่อดึงเอกสารที่คล้ายกับข้อความค้นหาที่กำหนดมากที่สุด การค้นหาความหมายเป็นการอัปเกรดครั้งใหญ่จากอัลกอริทึมการค้นหาคำหลักแบบดั้งเดิม เช่น BM25 เนื่องจากสามารถดึงเอกสารที่เกี่ยวข้องกับคำค้นหาที่กำหนดได้ แต่ไม่จำเป็นต้องมีคำที่ตรงกับคำค้นหา

หมายเหตุด้านข้าง : โมเดลนี้ (" ตัวเข้ารหัสประโยคสากล ") ที่ใช้ในการสาธิตนั้นค่อนข้างเก่าในลำดับวงศ์ตระกูลการเรียนรู้เชิงลึก และมีโมเดลที่สร้างการฝังที่ดีกว่า ซึ่งบางโมเดลสามารถพบได้ที่นี่

การฝังตัวแบบหนาแน่นคืออะไร?

การฝังแบบหนาแน่นคือการแสดงข้อมูลเป็นตัวเลข (ข้อความ ผู้ใช้ ผลิตภัณฑ์ ฯลฯ) โดยใช้เวกเตอร์มิติสูง เวกเตอร์หนาแน่นมีความยาวแตกต่างกันและคาดว่าจะเข้ารหัสข้อมูลเกี่ยวกับข้อมูลดิบ เพื่อให้ง่ายต่อการค้นหาจุดข้อมูลที่คล้ายกันโดยใช้อัลกอริทึมความคล้ายคลึงของเวกเตอร์ เช่นความคล้ายโคไซน์ ดูด้านล่างสำหรับการนำไปใช้อย่างง่าย:

from sklearn.metrics.pairwise import cosine_similarity
from numpy import random

array_vec_1 = random.rand(1,10)
array_vec_2 = random.rand(1,10)
print(cosine_similarity(array_vec_1, array_vec_2))

ในการสร้างการฝังข้อมูลคุณภาพสูง คุณต้องใช้โมเดลการเรียนรู้ของเครื่องที่ได้รับการฝึกอบรมเกี่ยวกับตัวอย่างคู่หลายล้านรายการ และมีเทคนิคการฝึกอบรมบางอย่าง (เช่น การเรียนรู้เชิงเปรียบเทียบโดยใช้การปฏิเสธอย่างหนัก) ที่นำไปใช้เพื่อสร้างคุณภาพสูง การฝัง สำหรับการค้นหาความหมายและการแสดงประโยคโดยทั่วไป มีโมเดลที่ได้รับการฝึกฝนล่วงหน้าหรือปรับแต่งอย่างละเอียดจำนวนมากที่เผยแพร่ต่อสาธารณะบนHuggingFace และ API บางส่วนที่มีจำหน่ายในท้องตลาด เช่นการฝังแบบเชื่อมโยงและ การ ฝังแบบ OpenAIสำหรับการเข้ารหัสข้อความ

การจัดทำดัชนีและการค้นหา

หลังจากเข้ารหัสเอกสารของเรา (สร้างการฝังเอกสาร) ตอนนี้เราต้องคิดถึงการจัดทำดัชนีเวกเตอร์และค้นหาดัชนีที่หนาแน่น

การค้นหาเวกเตอร์มักทำโดยใช้อัลกอริธึมการจัดกลุ่ม เช่น การค้นหาเพื่อนบ้านที่ใกล้ที่สุด และอาจต้องใช้การคำนวณสูงและท้าทายในการดำเนินการด้วยเหตุผลหลายประการ ซึ่งบางส่วนได้แก่:

(i.) ตัวเข้ารหัสบางตัวสร้างเวกเตอร์ที่เป็นตัวแทนที่มีขนาดใหญ่ การฝังขนาดใหญ่นำไปสู่ตารางการฝังขนาดใหญ่ ซึ่งมีค่าใช้จ่ายหน่วยความจำสูงเมื่อดำเนินการเวกเตอร์ และสามารถเพิ่มเวลาแฝงในการค้นหาได้

(ii.) การอัปเดตดัชนีหนาแน่นด้วยเวกเตอร์ใหม่อาจเป็นเรื่องยุ่งยาก เนื่องจากคุณอาจต้องอัปเดตกลุ่มดัชนีสำหรับเวกเตอร์ใหม่

ไลบรารีโอเพ่นซอร์สบางไลบรารีถูกสร้างขึ้นสำหรับการค้นหาเวกเตอร์ที่รวดเร็วเพื่อจัดการกับปัญหาที่กล่าวถึงข้างต้น เช่นFaissจาก Meta, Annoyจาก Spotify และScaNNจาก Google ในเวอร์ชัน 8.0 + ทาง Elasticsearch ได้ประกาศว่าเครื่องมือค้นหาโอเพ่นซอร์สยอดนิยมของพวกเขารองรับการค้นหาเพื่อนบ้านที่ใกล้ที่สุดแล้ว

หมายเหตุด้านข้าง : ดูการสนทนานี้เกี่ยวกับข้อเสียและวิธีแก้ไขในการใช้การค้นหาความหมายด้วยเวกเตอร์ที่หนาแน่น นอกจากนี้โปรดดูเกณฑ์มาตรฐานนี้สำหรับอัลกอริทึมการค้นหาเพื่อนบ้านใกล้เคียงโดยประมาณและไลบรารีต่างๆ

การค้นหาโดยประมาณด้วย ElasticSearch

มาถึงส่วนที่น่าสนใจกันเถอะ!

เรากำลังเข้ารหัสและจัดทำดัชนี คอลเลคชันการจัดอันดับข้อความของ MS MARCOซึ่งประกอบด้วยข้อความ 8.8 ล้านข้อความ เป้าหมายคือการจัดอันดับข้อความตามความเกี่ยวข้องกับข้อความค้นหาที่กำหนด เพื่อดาวน์โหลดและเปิดเครื่องรูดคอลเลกชัน

wget https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/msmarco.zip
unzip msmarco.zip

#inspect the data
head -1 msmarco/corpus.jsonl

#output
"""
{
  '_id': '0', 
  'title': '', 
  'text': 'The presence of communication amid scientific minds was equally important to the success of the Manhattan Project as scientific intellect was. The only cloud hanging over the impressive achievement of the atomic researchers and engineers is what their success truly meant; hundreds of thousands of innocent lives obliterated.', 
  'metadata': {}
}
"""

โมเดลสร้างชุดการฝังที่แตกต่างกันสำหรับแต่ละโทเค็นในประโยคอินพุต เราใช้การรวมค่าเฉลี่ย (หรือที่เรียกว่าการรวมค่าเฉลี่ย) เพื่อรวมการฝัง อีกทางหนึ่ง เราสามารถใช้เวกเตอร์การฝังที่สร้างขึ้นสำหรับโทเค็น [CLS]

หมายเหตุด้านข้าง: นี่คือไพรเมอร์ในการรวม

class MSMarcoEncoder:
    def __init__(self, model_name: str, device : str='cpu'):
        self.device = device
        self.tokenizer = AutoTokenizer.from_pretrained(model_name)
        self.model = AutoModel.from_pretrained(model_name)
        self.model.to(self.device)

    def encode(self, text:str, max_length: int):
        inputs = self.tokenizer(text, return_tensors="pt", padding=True, truncation=True, max_length=max_length)
        inputs = inputs.to(self.device)
        with torch.no_grad():
            model_output = self.model(**inputs, return_dict=True)
        # Perform pooling
        embeddings = self.mean_pooling(model_output, inputs['attention_mask'])
        # Normalize embeddings
        embeddings = F.normalize(embeddings, p=2, dim=1)
        return embeddings.detach().cpu().numpy()

    def mean_pooling(self, model_output, attention_mask):
        token_embeddings = model_output[0] #First element of model_output contains all token embeddings
        input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
        return torch.sum(token_embeddings * input_mask_expanded, 1) / torch.clamp(input_mask_expanded.sum(1), min=1e-9)

if __name__ == "__main__":
    encoder = Encoder('sentence-transformers/msmarco-MiniLM-L6-cos-v5')
    embeddings = encoder.encode(batch_info['text'], 512)

สำหรับการตั้งค่าในเครื่อง หลังจากดาวน์โหลด elasticsearch ให้รันคำสั่งต่อไปนี้เพื่อเริ่มคลัสเตอร์ด้วยโหนดเดียว:

./elasticsearch-8.5.1/bin/elasticsearch

curl --cacert config/certs/http_ca.crt -u elastic https://localhost:9200

from elasticsearch import Elasticsearch

es_client = Elasticsearch( "https://localhost:9200", 
                  http_auth=("username", "password"),
                  verify_certs=False)
config = {
    "mappings": {
        "properties": {
            "title": {"type": "text"},
            "text": {"type": "text"},
            "embeddings": {
                    "type": "dense_vector",
                    "dims": 384,
                    "index": false
                }
            }
    },
    "settings": {
        "number_of_shards": 2,
        "number_of_replicas": 1
    }
}

es_client.indices.create(
    index="msmarco-demo",
    settings=config["settings"],
    mappings=config["mappings"],
)

#check if the index has been created successfully
print(es.indices.exists(index=["msmarco-demo"]))
#True

collection_path = 'path/to/corpus.jsonl'
collection_iterator = JsonlCollectionIterator(collection_path, fields=['title','text'])
encoder = Encoder('sentence-transformers/msmarco-MiniLM-L6-cos-v5')
index_name = "msmarco-demo"

for batch_info in collection_iterator(batch_size=256, shard_id=0, shard_num=1):
    embeddings = encoder.encode(batch_info['text'], 512)
    batch_info["dense_vectors"] = embeddings

    actions = []
    for i in range(len(batch_info['id'])):
        action = {"index": {"_index": index_name, "_id": batch_info['id'][i]}}
        doc = {
                "title": batch_info['title'][i],
                "text": batch_info['text'][i],
                "embeddings": batch_info['dense_vectors'][i].tolist()
            }
        actions.append(action)
        actions.append(doc)
    
    es_client.bulk(index=index_name, operations=actions)

result = es_client.count(index=index_name)

#print the total number of documents in the index
print(result.body['count'])
#8841823

#output one document
print(es_client.get(index=["msmarco-demo"], id="0", request_timeout=60))

'''
{'_index': 'msmarco-demo', '_id': '0', '_version': 2, '_seq_no': 27, '_primary_term': 1, 'found': True, 
'_source': {'title': '', 
'text': 'The presence of communication amid scientific minds was equally important to the success of the Manhattan Project as scientific intellect was. The only cloud hanging over the impressive achievement of the atomic researchers and engineers is what their success truly meant; hundreds of thousands of innocent lives obliterated.', 
'embeddings': [-0.032267116010189056, 0.05750396102666855,...]}}
'''

def search(query: str, es_client: Elasticsearch, model: str, index: str, top_k: int = 10):

    encoder = Encoder(model)
    query_vector = encoder.encode(query, max_length=64)
    query_dict = {
        "field": "embeddings",
        "query_vector": query_vector[0].tolist(),
        "k": 10,
        "num_candidates": top_k
    }
    res = es_client.knn_search(index=index, knn=query_dict, source=["title", "text", "id"])

    for hit in res["hits"]["hits"]:
        print(hit)
        print(f"Document ID: {hit['_id']}")
        print(f"Document Title: {hit['_source']['title']}")
        print(f"Document Text: {hit['_source']['text']}")
        print("=======================================================\n")


if __name__ == "__main__":

  search(query="What is the capital of France?", 
         es_client=es_client, 
         model="sentence-transformers/msmarco-MiniLM-L6-cos-v5", 
         index=index_name)
#output
"""
{'_index': 'msmarco-demo', '_id': '82390', '_score': 0.81541693, '_source': {'text': "In terms of total household wealth, France is the wealthiest nation in Europe and fourth in the world. It also possesses the world's second-largest exclusive economic zone (EEZ), covering 11,035,000 square kilometres (4,261,000 sq mi).", 'title': ''}}
Document ID: 82390
Document Title: 
Document Text: In terms of total household wealth, France is the wealthiest nation in Europe and fourth in the world. It also possesses the world's second-largest exclusive economic zone (EEZ), covering 11,035,000 square kilometres (4,261,000 sq mi).
=====================================================================

{'_index': 'msmarco-demo', '_id': '162291', '_score': 0.80739325, '_source': {'text': 'Paris in France lies on the Seine River. The docking location is Port de Grenelle/Quai de Grenelle. As one of the largest cities in Europe, finding a property that suit your budget is not a problem. Choose between low cost guest rooms to luxury 4 and 5 star hotels and apartments to rent.', 'title': ''}}
Document ID: 162291
Document Title: 
Document Text: Paris in France lies on the Seine River. The docking location is Port de Grenelle/Quai de Grenelle. As one of the largest cities in Europe, finding a property that suit your budget is not a problem. Choose between low cost guest rooms to luxury 4 and 5 star hotels and apartments to rent.
=====================================================================
"""

โวล่า! การพัฒนาสิ่งนี้ด้วย Github Copilot เป็นเรื่องสนุก หากต้องการทำดัชนีและค้นหาแบบออฟไลน์ อย่าลังเลที่จะตรวจสอบPyserini ห้องสมุดที่ยอดเยี่ยม ของเรา Pyserini ได้รับการออกแบบมาเพื่อให้การดึงข้อมูลในขั้นแรกที่มีประสิทธิภาพ ทำซ้ำได้ และใช้งานง่ายในสถาปัตยกรรมการจัดอันดับแบบหลายขั้นตอน

รหัสทั้งหมดสำหรับบทช่วยสอนนี้สามารถพบได้ที่นี่

อ้างอิง

  1. https://towardsdatascience.com/how-to-index-elasticsearch-documents-with-the-bulk-api-in-python-b5bb01ed3824
  2. https://www.elastic.co/