הקדמה

למעבירי הקורס יש גם מערכי לימוד בתחום. לJay יש המחשה נהדרת לרשתות טרנספורמרים. וספר שכתב Hands on Large language models, וLuis הוא מחבר הספר Grokking machine learning. בCohere יחד עם שותף נוסף הם עובדים על אתר שנקרא LLMU שמלמד מפתחים להשתמש בLLMs.

מבנה הקורס

  • Keyword vs Semantic Search
  • Ranking Responses
  • Embedding
  • Dense Retrieval
  • Evaluation Methods
  • Search-Powered LLms

חיפוש מילות מפתח (Keyword Search)

חיפוש הוא דרך מכריעה כיצד ננווט בעולם. חיפוש באמצעות מילות מפתח היא הדרך הנפוצה לבנות מערכות חיפוש.

שימוש בVecrotDB

בקורס זה נשתמש בVecrotDB הנקרא Weaviate. לWeaviate יש יכולות חיפוש גם באמצעות מילות מפתח וגם בצורות וקטוריות.

חיבור ומשמעות הDB

נחבר לVectorDB ציבורי פתוח שמכיל 10M דוגמאות של פסקאות מויקיפדיה מ10 שפות שונות, נתחבר בצורה הבאה כך שנקנפג את Cohere:

import weaviate
auth_config = weaviate.auth.AuthApiKey(
    api_key=os.environ['WEAVIATE_API_KEY'])
 
 
client = weaviate.Client(
    url=os.environ['WEAVIATE_API_URL'],
    auth_client_secret=auth_config,
    additional_headers={
        "X-Cohere-Api-Key": os.environ['COHERE_API_KEY'],
    }
)
 
client.is_ready() 

הסבר על החיפוש

חיפוש באמצעות מילות מפתח הוא בדיוק הצורה הנאיבית שהיינו חושבים: בהינתן שאילתה ורשימה של תוצאות חיפוש - כמות המילים המשותפות בין השאילתה לתוצאת חיפוש היא הציון של כל אחד מהם, והתוצאה היא התוצאות עם הציונים הגבוהים על פי סדר.

מימוש חיפוש

def keyword_search(query,
                   results_lang='en',
                   properties = ["title","url","text"],
                   num_results=3):
 
    where_filter = {
    "path": ["lang"],
    "operator": "Equal",
    "valueString": results_lang
    }
    
    response = (
        client.query.get("Articles", properties)
        .with_bm25(
            query=query
        )
        .with_where(where_filter)
        .with_limit(num_results)
        .do()
        )
 
    result = response['data']['Get']['Articles']
    return result

חיפוש בHigh-level

הקומפוננטה המרכזיות הן: השאילתה ומערכת החיפוש כאשר למערכת החיפוש יש גישה לארכיון המסמכים שמעובד באופן מקדים כך שבזמן השאילתה המערכת מחזירה לנו רשימה של תוצאות ממוינות בצורה הכי רלונטית לשאילתה

קצת יותר בפנים

אם נפרט קצת יותר את השלבים, השלב הראשון הוא שלב האחזור (או חיפוש), בו אנחנו משתמשים באלגוריתם לאתר (בצורה זולה) את התוצאות הרלוונטיות שלנו. השלב השני נקרא Reranking - הוא נדרש בגלל שאנחנו נדרשים לדברים נוספים מלבד רלוונטיות של הטקסט. (בסוף יש קצת הסבר על המימוש של BM25 - במילים בודדות יש טבלה ששומרת לכל מילה באיזה מסמכים היא נמצאת וכך זה מעלה את מהירות האלגוריתם - בטוח שזה יותר מורכב במציאות ולכן לא ארחיב).

מגבלות

המגבלה הברורה של חיפוש באמצעות מילות מפתח היא מה קורה במקרה שיש מסמך בעל אותו משמעות אח שמנוסח בצורה שונה לגמרי מבחינה מילולית.

מודלי שפה יכולים לשפר את 2 השלבים

מודלי שפה יכולים לשפר את השלב הראשון שלב האחזור, באמצעות Embeddings. מודלי שפה יכולים לשפר גם את השלב השני הReranking, באמצעות אלגוריתם של מודל שפה. לבסוף המסמכים הרלוונטים שאוחזרו יכולים גם להיכנס למודל שפה לייצר תשובה בהתבסס על המסמכים.

קידוד (Embeddings)

מבוא קצר

קידודים (Embeddings) הם יצוג נומרי (מתמטי) של טקסט כך שמחשבים יוכלו לעבד ביתר קלות. זה הופך אותם לאחד המרכיבים הכי חשובים של LLM.

דוגמא עם Cohere

ספריית Cohere היא ספרייה נרחבת של פונקציונליות של LLM-ים שניתן לקרוא דרך API. (הערה שלי: היא מצוינת בעיקר בעולמות הmultilangual ויש לה תוכנית עסקית של API חינמי לשלבים הראשוניים עד שמגיעים לscale בפרודקשיין)

# !pip install cohere umap-learn altair datasets
 
# create client
import cohere
co = cohere.Client(os.environ['COHERE_API_KEY'])
 
# when sentences is df with 'text' column
emb = co.embed(texts=list(sentences['text']),
               model='embed-english-v2.0').embeddings

כעת נראה ויזואליזציה של הEmbeddings שלנו.

ויזואליזציה עם UMAP

(הרחבה שלי ממה שקראתי באינטרנט) מבוסס על אלגוריתם שנקרא עם שינויים קלים ובעיקר החיזוק הוא שיפור הביצועים מבחינת חישוב. הרחבה תעשה בהמשך כאשר יש להתחיל מכאן:

דוגמאת קוד

מימוש ושימוש של ויזואליזציה עם UMAP כמו בקורס (בצורה אינטראקטיבית):

import umap
import altair as alt
 
def umap_plot(text, emb):
    cols = list(text.columns)
    # UMAP reduces the dimensions from 1024 to 2 dimensions that we can plot
    reducer = umap.UMAP(n_neighbors=2)
    umap_embeds = reducer.fit_transform(emb)
    # Prepare the data to plot and interactive visualization
    df_explore = text.copy()
    df_explore['x'] = umap_embeds[:,0]
    df_explore['y'] = umap_embeds[:,1]
    
    # Plot
    chart = alt.Chart(df_explore).mark_circle(size=60).encode(
        x=alt.X('x',scale=alt.Scale(zero=False)),
        y=alt.Y('y',scale=alt.Scale(zero=False)),
        tooltip=cols
    ).properties(
        width=700,
        height=400
    )
    return chart
 
chart = umap_plot(sentences, emb)
chart.interactive()

המקרה שלנו

ניתן לראות שבמשפטים שהצגנו בויזואליזציה, השאלות והתשובות שבאותו הנושא נמצאות קרוב אחד לשני, זה יהווה את הבסיס לאחזור מבוסס צפיפות (Dense Retrieval).

אחזור מבוסס צפיפות (Dense Retrieval)

עכשיו כשיש לנו קידוד, ניתן לחפש בצורה סמנטית (Semantic Search) או במילים החיפוש חיפוש באמצעות משמעות המשפט (Search By Meaning).

בחיפוש סמנטי נרצה בהינתן השאילתא, את מסמכי הארכיון הקרובים ביותר במרחב הקידוד, כלומר את אלה בעלי המשמעות הסמנתית הקרובים ביותר ע”פ הקידוד שלנו.

דוגמאת קוד

דוגמאת קוד לחיפוש סמנתי:

def dense_retrieval(query, 
                    results_lang='en', 
                    properties = ["text", "title", "url", "views", "lang", "_additional {distance}"],
                    num_results=5):
 
    nearText = {"concepts": [query]}
    
    # To filter by language
    where_filter = {
    "path": ["lang"],
    "operator": "Equal",
    "valueString": results_lang
    }
    response = (
        client.query
        .get("Articles", properties)
        .with_near_text(nearText)
        .with_where(where_filter)
        .with_limit(num_results)
        .do()
    )
 
    result = response['data']['Get']['Articles']
 
    return result

נשים לב שהחלק הרלוונטי אלינו הוא הפונקציה with_near_text (שמסיבות שטרם הצלחתי להבין לא מקבלת רק שאילתא אלא את המילון שבמשתנה nearText לעיל).

שפות מרובות (multilingual)

אחת החוזקות המרכזיות של חיפוש סמנתי, הוא החיפוש בארכיון מסמכים של שפות שונות. במקרה זה ברור שחיפוש באמצעות מילות מפתח לא יהיה מוצלח, אך חיפוש מבוסס צפיפות במקום בו הקידוד פועל על שפות שונות יהיה מוצלח מפני שהמרחב מתייחס למשמעות המשפט ולא לשפה בכלל.

חקר (exploration)

יישום נוסף של חיפוש סמנתי הוא חקר (exploration) הארכיון. חיפוש כאשר אתה לא יודע מה אתה מחפש של מסמכים המתייחסים לנושאים מסוימים שאתה רוצה לחקור.

בניית VectorDB (יותר נכון VectorSearch)

בחלק האחרון של ההרצאה בונים VectorDB פשוט ביותר עם Annoy (אלגוריתם של Approximate nearest neighbourhood).

יוצרים צ’אנקים, מייצרים קידוד ואז מייצרים AnnoyIndex ובונים אותו:

from annoy import AnnoyIndex
 
search_index = AnnoyIndex(embeds.shape[1], 'angular')
# Add all the vectors to the search index
for i in range(len(embeds)):
    search_index.add_item(i, embeds[i])
 
search_index.build(10) # 10 trees
search_index.save('test.ann')
 
 
pd.set_option('display.max_colwidth', None)
 
def search(query):
	query_embed = co.embed(texts=[query]).embeddings
	similar_item_ids = search_index.get_nns_by_vector(query_embed[0],3,include_distances=True)
	results = pd.DataFrame(data={'texts': texts[similar_item_ids[0]],
                              'distance': similar_item_ids[1]})
  print(texts[similar_item_ids[0]])
    
  return results

השוואת VectorDB מול ANN

ישנם שני סוגים מרכזיים של בניית מערכת חיפוש. הראשונה היא מערכת דלה ביותר שמכילה את הקידודים בלבד ואת אלגוריתם החיפוש - הApproximate nearest neighbourhood. היתרון הוא שהוא קל ביותר לבנייה. דוגמאות הן:

  • Annoy (by Spotify)
  • FAISS (by Facebook)
  • ScaNN (by Google) כולם open-source. הסוג השני הוא VectorDatabase. הוא רחב בהרבה ומנוהל. ישנם צורות שונות לאיך הוא בנוי ומה הוא מכיל אבל בין היתר הוא מאחסן טקסט ולא רק את הקידוד, הוא קל יותר להוספת רשומות חדשות ומאפשר פילטור ושאר פעולות מתקדמות של שאילתות, לחלקם יש שירותי ענן מנוהלים. דוגמאות נפוצות הן:
  • Weaviate
  • PostgreSQL
  • Pinecone
  • Qdrant
  • vespa
  • chroma

בעולם האמיתי אין באמצ צורך לבחור בין חיפוש באמצעות מילות מפתח לחיפוש סמנתי. ניתן לקחת את התוצאות של שני החיפושים ולבצע איזושהי אגריגציה בהעברה למודל השפה.

למידה נוספת

ללימוד נוסף על Dense Retrieval כדאי להתחיל מכאן: https://arxiv.org/pdf/2010.06467v3.pdf

דירוג מחדש (ReRank)

פעולת הReRank באה לשפר את התוצאות של רכיב החיפוש כאשר היא באה בנוסף אליו לאחריו כרכיב נוסף. זו הדרך לתת לLLM (הערה שלי: בהכרח לLLM?) לסדר את תוצאות החיפוש מהטובה ביותר לגרועה ביותר ביחס לרלוונטיות שלהם לשאילתא.

אחזור מבוסס צפיפות לא עובד מושלם

לפעמים יהיו לנו דוגמאות שקרובות סמנטית מאוד לשאלה, אך לא רלוונטיות כתשובה לשאלה. כלומר מבחינה הקרבה במשמעות השאלה והתשובה קרובות מאוד אך לא מתקיים ביניהם יחס של שאלה תשובה. לכן לאחזור מבוסס צפיפות יש פוטנציאל להחזיר תגובות שאינם רלוונטיות לשאלה.

הפתרון: ReRank

בהינתן כמות של תגובות פונטציאליות ReRank נותן ציון רלוונטיות (Relevance) למסמך ביחס לשאילתא לכל תגובה פוטנציאליות.

צורת האימון של ReRank

הצורה בה ReRank התאמן הוא באמצעות כמות גדולה של שאילתות עם תשובות נכונות עם ציון גבוהה ובנוסף כמות גדולה של שאלות עם תשובות לא נכונות וציון נמוך.

שיפור עם ReRank

חיפוש באמצעות מילות מפתח

במקרה של חיפוש באמצעות מילות מפתח, סיכוי טוב שכאשר נבקש כמות קטנה של תוצאות התוצאות הרלנווטיות לא יהיו במקומות הגבוהים, כי כזכור אין משמעות לרלווטיות או לקשר הסמנטי. נניח ואנחנו רוצים את 3 התוצאות הרלוונטיות. נבקש כמות גדולה מאוד של תוצאות חיפוש עם מילות מפתח ששם סיכוי גדול שהתוצאות יופיע, ואז נבצע ReRank על התוצאות שחזרו:

results = keyword_search(query, client, num_results = 500)
 
def rerank_responses(query, responses, num_responses=10):
    reranked_responses = co.rerank(
        model = 'rerank-english-v2.0',
        query = query,
        documents = responses,
        top_n = num_responses,
        )
    return reranked_responses
 
texts = [result.get('text') for result in results]
reranked_text = rerank_responses(query, texts)

חיפוש סמנתי

במקרה של חיפוש סמנתי, סיכוי סביר שכבר בחיפוש יחזרו התוצאות הרלוונטיות (וכך אכן היה בדוגמה בסרטון) ובכל זאת אלגוריתם הReRank יתן ציון רלוונטיות לאמת את הרלוונטיות לתשובה, ויסדר מחדש את התוצאות שחזרו כך שהרלוונטיות יותר יהיו ראשונות.

הערכה למערכות חיפוש

כאשר מעריכים מערכות חיפוש, מטריקות הערכה נפוצות הם:

  • דיוק ממוצע - ממוצע (Mean Average Precision - MAP)
  • הדירוג ההופכי הממוצע (Mean Reciprocal Rank - MRR)
  • תועלת מצטברת מופחתת מנורמלת (Normalized Discounted Cumulative Gain - NDCG)

דיוק ממוצע - ממוצע

הדיוק הוא החלק היחסי של המסמכים שאוחזרו שהם רלוונטיים מתוך כלל המסמכים. דיוק ממוצע - ממוצע (Mean Average Precision - MAP) הוא ממוצע הדיוק עבור כלל השאילתות. הערה שלי: דיוק במובן של 0-1 או רלוונטיות?

הדירוג ההופכי הממוצע

בהינתן רשימה של תגובות אפשריות לדגימה של שאילתות, מסודרת לפי הסבירות לנכונות הדירוג ההופכי הממוצע (Mean Reciprocal Rank - MRR) הוא ההופכי הכפלי של הדירוג של התשובה הנכונה הראשונה.

תועלת מצטברת מופחתת מנורמלת

לא הבנתי - להשלים

ייצור תשובות

בשלב הזה נלמד לייצר תשובה, כך שבמקום שנקבל תוצאות רלוונטיות לשאילתת חיפוש נקבל תשובה. זו דרך נהדרת לבניית אפליקציות כך שמשתמש יוכל לבצע שיחה עם מסמך או ספר. באופן כללי ניתן לשאול את הLLM שאלות ולקבל תשובות אך במקרה הרגיל אנחנו נסמכים על ה”זיכרון” של הLLM ע”פ מה שהוא למד. אבל לפעמים נרצה לקבל תשובות על בסיס ארכיון מסמכים ספציפי. במקרה זה נוסיף כשלב מקדים רכיב חיפוש לפני ייצור התשובה, נוסיף לו את המידע כcontext כך שיתבסס עליו בתשובה שלו באמצעות רכיב חיפוש מקדים. פתרון זה של אחזור מקרידם יעזור לLLM במיוחד עם:

  • מידע עובדתי
  • מידע פרטי
  • מידע עדכני

דוגמת קוד

בקוד נצטרך רק להוסיף רכיב LLM עם פרומפט מתאים המשאיר מקום לcontext:

def ask_andrews_article(question, num_generations):
    
    results = search(question)
 
    context = results[0]
 
    prompt = f"""
    Excerpt from ...: 
    {context}
    Question: {question}
    
    Extract the answer of the question from the text provided. 
    If the text doesn't contain the answer, 
    reply that the answer is not available."""
 
    prediction = co.generate(
        prompt=prompt,
        max_tokens=70,
        model="command-nightly",
        temperature=0.5,
        num_generations=num_generations
    )
 
    return prediction.generations
 
ask_andrews_article(query ,num_generations=5)