Skip to main content
RD EZ

241103 - Najprostszy RAG

Spis Treści #


1. Co tu zrobimy #

Kontynuujemy artykuł 241102-embeddings, dodajemy LLM do bazy wektorowej i dostajemy pełnoprawnego (choć prostego) RAGa.

2. Od czego zaczynamy? #

Nie podaję żadnych funkcji pomocniczych (bo je macie); zacznijcie od poprzedniego dokumentu.

.

A jako kod - wychodzimy od tego (wszystkie funkcje macie):


def run():
    content = _url_to_markdown('https://bestmotherfucking.website')
    sections = _cut_into_sections(content)

    collection_name = "bestmfwebsite"
    vdb_client = _embed_in_vectorstore(collection_name, sections)

3. Dodajmy lokalny LLM #

3.1. Co i dlaczego #

Założenie - już zainstalowaliście lokalny LLM. Jak nie, tu macie informację jak.

Naszym celem jest zainicjalizować lokalny LLM; w moim wypadku Microsoft phi-3.5-mini-instruct, który wyszedł całkiem niedawno. Dlaczego ten (wszystkie dane są pod linkiem):

Na podstawie powyższych danych można ocenić kluczowe rzeczy:

.

Niestety, jako, że phi-3.5-instruct-fp16 kosztuje 7.6 GB, nie mogę go użyć. Ściągnę zatem q8 (kwantyzacja wykorzystująca 8b a nie 16b).

3.2. Implementacja #

Wpiszmy zatem:

ollama pull phi3.5:3.8b-mini-instruct-q8_0

.

Teraz napiszmy kod konfigurujący klienta LLM (punkt 3 poniżej):


def run():

    # 1. Acquire content
    content = _url_to_markdown('https://bestmotherfucking.website')
    sections = _cut_into_sections(content)

    # 2. Embed content in vectorstore
    collection_name = "bestmfwebsite"
    vdb_client = _embed_in_vectorstore(collection_name, sections)

    # 3. Add local LLM
    selected_llm = "phi3.5:3.8b-mini-instruct-q8_0"
    llm_client = openai.OpenAI(base_url="http://localhost:11434/v1", api_key="nokeyneeded")

Jeśli chcecie przetestować czy to działa, robicie tak:


    # 4. Diagnostics
    question = "What is a cat? Answer in 10 words."
    print(question)
    messages = [{"role": "system", "content": "you are an assistant answering questions" }]
    messages.append({"role": "user", "content": question})
    response = llm_client.chat.completions.create(
        model=selected_llm,
        messages=messages,
        temperature=0.3,
        max_tokens=400
    )

    print(response.choices[0].message.content)

Dostaniecie następujący wynik:

.

Na razie kod odczytujący dane z LLM nas nie interesuje. Ale ważne, że mamy:

I teraz czas to połączyć. Zróbmy RAG.

4. RAG - Retrieval Augmented Generation #

4.1. Co i dlaczego #

Jedną z największych obietnic LLMa jest zrobienie sobie własnego RAGa; czegoś, co pozwoli LLMowi pracować na naszej własnej bazie wiedzy.

O co chodzi:

Mamy różne strategie jak do tego podejść:

Serio, jest mnóstwo sposobów. My najpierw zrobimy to z vectorstore; mamy wszystko co nam potrzebne (vectorstore, llm). Ale i tak problem leży mniej w idealnym LLM a bardziej w prawidłowym odpytywaniu bazy wiedzy, przygotowaniu danych itp.

Więc, zróbmy to naiwnie.

4.2. Implementacja #

4.2.1. Kroki #

Podzielmy to na sensowne kroki:

  1. Pozyskanie query od użytkownika
  2. f(Query -> Kontekst) z bazy wiedzy (u nas: ChromaDB)
  3. f(Kontekst -> Prompt); wzbogacamy prompt mający trafić do LLMa
  4. Wysyłamy prompt do LLMa i dostajemy odpowiedź
  5. Powtórzmy powyższe

Czyli wysokopoziomowo (bez implementacji funkcji):

.

Kod całościowo podam Wam potem. Czas przejść do implementacji poszczególnych funkcji i konceptów.

4.2.2. Tworzymy prompta (krok 4.3) #

Najprostszy prompt wyglądałby jakoś tak (pokazuję a potem wyjaśniam):


def _create_prompt(user_question: str, context) -> str:
    
    aggregated_context = ""
    for doc in context['documents'][0]:
        aggregated_context += doc + "\n"

    prompt = f"""<|user|>
    
    Answer the question considering this context.
    User question: {user_question}
    
    Here's the context to assist you:
    {aggregated_context}
    
    <|assistant|>"""
    return prompt

Oczywiście, można też zrobić to lepiej. Co by nam się przydało?

Do tego warto zauważyć, że LLM zwykle świetnie sobie radzi z tagami xmlowymi. Dlatego mam zamiar je dobrze wykorzystać :-).


def _create_prompt(user_question: str, context) -> str:
    opener = "<context_component>"
    closer = "</context_component>"

    aggregated_context = ""
    for doc in context['documents'][0]:
        aggregated_context += opener + "\n" + doc + "\n" + closer

    prompt = f"""<|user|>
        <instruction>
        Answer the question considering this context. Some pieces of context will be relevant, some will not be.
        Separate pieces of context are encapsulated in the xml tags '<context_component>'. 
        In case of no relevant information in the context at all, write "No relevant information in context".
        </instruction> 

        <user_question>
        {user_question}
        </user_question>

        Here's the context to assist you:

        {aggregated_context}

        <|assistant|>
        """
    return prompt

Ten prompt da się usprawnić, ale na razie - do naszych celów - wystarczy.

4.2.3. Przekażmy wiadomość do LLMa (krok 4.4) #

To już zrobiliśmy w dokumencie 1. Tak, wiem, że w Pythonie nie muszę zwracać obiektów przekazanych przez referencję jako parametry funkcji, ale uważam za dobrą praktykę zwrócenie wszystkiego co zmieniam, nawet jak to przekazałem wcześniej (dla czytelności).

Ustawiłem temperaturę 0.2, by zredukować kreatywność (0 oznacza "niska kreatywność", 1 oznacza "wysoka kreatywność + potencjał na halucynacje")


def _query_llm(llm_client, selected_llm, messages, prompt):
    messages.append({"role": "user", "content": prompt})

    response = llm_client.chat.completions.create(
        model=selected_llm,
        messages=messages,
        temperature=0.2,  # Low temperature to hallucinate less
        max_tokens=400
    )

    bot_response = response.choices[0].message.content
    messages.append({"role": "assistant", "content": bot_response})

    return bot_response, messages

4.2.4. Wyświetlamy odpowiedź (krok 4.5) #

Zobaczmy odpowiedź LLMa oraz jakie dane zostały przekazane do LLMa z vectorstore.

I wynikowo:


def _aggregate_contexts_to_str(context):
    opener = "<context_component>"
    closer = "</context_component>"
    aggregated_context = ""
    for doc in context['documents'][0]:
        aggregated_context += opener + "\n" + doc + "\n" + closer
    return aggregated_context

def _format_answer_for_print(bot_response, context):
    return f"""# ANSWER
    
    {bot_response}
    
    # CONTEXT
    
    {_aggregate_contexts_to_str(context)}
    """

4.3. Odpalmy naszą aplikację #

To... nie będzie najczytelniejsze. Ale zadziała :-).

.

Zrobiłem kilka testów. Zadziałało jak chciałem:

Oczywiście, to jest tylko proof of concept. Na jednym dokumencie to nic wielkiego. Ale teraz można:

Widzicie jak to działa. Dość proste :-).

5. Całość kodu wynikowego #


import html2text
import requests
import re
import chromadb
import openai

# Nasz jedyny plik: 004-SimpleRag.py

def run():
    # 1. Acquire content to query
    content = _url_to_markdown('https://bestmotherfucking.website')
    sections = _cut_into_sections(content)

    # 2. Embed content in vectorstore
    collection_name = "bestmfwebsite"
    vdb_client = _embed_in_vectorstore(collection_name, sections)

    # 3. Add local LLM
    selected_llm = "phi3.5:3.8b-mini-instruct-q8_0"
    system_message_for_llm = "<|system|>\nYou are a helpful and concise assistant answering questions."
    messages = [{"role": "system", "content": system_message_for_llm }]
    llm_client = openai.OpenAI(base_url="http://localhost:11434/v1", api_key="nokeyneeded")

    # 4. Main application loop
    while True:
        # 4.1. Get user question and exit the application
        user_question = input("What is your question?")
        if user_question == "exit":
            break

        # 4.2. Extract data from knowledge base (vectorstore)
        collection = vdb_client.get_collection(collection_name)
        context = collection.query(query_texts=[user_question], n_results=5)

        # 4.3. Build an appropriate prompt from the template, question and context
        prompt = _create_prompt(user_question, context)

        # 4.4. Pass prompt to LLM and get the answer
        bot_response, messages = _query_llm(llm_client, selected_llm, messages, prompt)

        # 4.5 Display answer
        print(_format_answer_for_print(bot_response, context))

def _format_answer_for_print(bot_response, context):
    return f"""# ANSWER
    
    {bot_response}
    
    # CONTEXT
    
    {_aggregate_contexts_to_str(context)}
    """

def _query_llm(llm_client, selected_llm, messages, prompt):
    messages.append({"role": "user", "content": prompt})

    response = llm_client.chat.completions.create(
        model=selected_llm,
        messages=messages,
        temperature=0.2,  # Low temperature to hallucinate less
        max_tokens=400
    )

    bot_response = response.choices[0].message.content
    messages.append({"role": "assistant", "content": bot_response})

    return bot_response, messages

def _aggregate_contexts_to_str(context):
    opener = "<context_component>"
    closer = "</context_component>"
    aggregated_context = ""
    for doc in context['documents'][0]:
        aggregated_context += opener + "\n" + doc + "\n" + closer
    return aggregated_context

def _create_prompt(user_question: str, context) -> str:
    aggregated_context = _aggregate_contexts_to_str(context)
    prompt = f"""<|user|>
        <instruction>
        Answer the question considering this context. Some pieces of context will be relevant, some will not be.
        Separate pieces of context are encapsulated in the xml tags '<context_component>'. 
        In case of no relevant information in the context at all, write "No relevant information in context".
        </instruction> 

        <user_question>
        {user_question}
        </user_question>

        Here's the context to assist you:

        {aggregated_context}

        <|assistant|>
        """
    return prompt

def _embed_in_vectorstore(collection_name: str, sections: list[str]):
    chroma_client = chromadb.Client()
    collection = chroma_client.get_or_create_collection(
        name=collection_name,
        metadata={"hnsw:space": "cosine"}
    )

    ids = [f"id_{index}" for index, _ in enumerate(sections)]
    collection.add(
        documents=sections,
        ids=ids
    )
    return chroma_client

def _cut_into_sections(markdown_text: str) -> list[str]:
    # Ten regex działa tak:
    # dzielimy po h1-h4 ('#' - '####'); '#' to h1, '##' to h2...
    # następnie ?= oznacza lookahead; czyli tak długo dodaje do grupy którą dzieli aż się pojawi kolejny nagłówek lub \Z, czyli koniec pliku / tekstu
    pattern = r'(#{1,4} .+?)(?=\n#{1,4} |\Z)'
    sections = re.findall(pattern, markdown_text, re.DOTALL)
    return sections

def _url_to_markdown(url: str):
    response = requests.get(url)

    if response.status_code == 200:
        converter = html2text.HTML2Text()
        converter.ignore_links = True
        markdown_text = converter.handle(response.text)
        return markdown_text

run()

6. Wartościowe linki powiązane #