4  Local LLM - OLLAMA

4.1 개요

이 문서는 Streamlit, MySQL 데이터베이스, LangChain과 Ollama LLM을 결합하여 사용자가 데이터베이스와 상호작용할 수 있는 AI 기반 챗봇을 구현한 코드에 대한 설명을 제공한다.

CS 업무와 관련된 모든 구글시트와 아카라 카페의 내용을 MySQL DB에 데이터베이스화 하였다.

파일 또는 웹사이트 기반으로 RAG을 진행할 수 있으나 최종 목적은 DB의 내용 기반으로 RAG하는 것이다.

4.2 OLLAMA 설정 방법

4.2.1 HuggingFaxe-Hub 설치

#pip install huggingface-hub

4.2.2 GGUF파일 다운로드

https://huggingface.co/heegyu/EEVE-Korean-Instruct-10.8B-v1.0-GGUF

#huggingface-cli download \
#  heegyu/EEVE-Korean-Instruct-10.8B-v1.0-GGUF \
#  ggml-model-Q5_K_M.gguf \
#  --local-dir 본인의_컴퓨터_다운로드폴더_경로 \
#  --local-dir-use-symlinks False

4.2.3 Modelfile

EEVE-Korean-Instruct-10.8B-v1.0 예시


#FROM ggml-model-Q5_K_M.gguf
#
#TEMPLATE """{{- if .System }}
#<s>{{ .System }}</s>
#{{- end }}
#<s>Human:
#{{ .Prompt }}</s>
#<s>Assistant:
#"""
#
#SYSTEM """A chat between a curious user and an artificial intelligence assistant. The #assistant gives helpful, detailed, and polite answers to the user's questions."""
#
#PARAMETER stop <s>
#PARAMETER stop </s>

4.2.4 OLLAMA 실행

#ollama create EEVE-Korean-10.8B -f EEVE-Korean-Instruct-10.8B-v1.0-GGUF/Modelfile

4.2.4.1 OLLAMA 모델 목록

#ollama list

4.2.4.2 OLLAMA 모델 실행

ollama run EEVE-Korean-10.8B:latest

4.2.4.3 ngrok에서 터널링(포트 포워드)

#streamlit default port: 8501
#ngrok http localhost:8501

4.3 환경 설정 및 Streamlit 페이지 구성

from dotenv import load_dotenv
load_dotenv()
  • .env 파일에서 환경 변수를 로드하여 코드에서 민감한 정보를 안전하게 불러올 수 있다.
st.set_page_config(page_title="MySQL DB GPT", page_icon="🔒", layout="wide")
  • Streamlit 페이지의 제목과 아이콘, 레이아웃을 설정한다.

4.4 ChatCallbackHandler

# Custom callback handler inheriting from BaseCallbackHandler
class ChatCallbackHandler(BaseCallbackHandler):
    message = ""

    def on_llm_start(self, *args, **kwargs):
        self.message_box = st.empty()

    def on_llm_end(self, *args, **kwargs):
        save_message(self.message, "ai")

    def on_llm_new_token(self, token, *args, **kwargs):
        self.message += token
        self.message_box.markdown(self.message)
  • LangChain의 콜백 핸들러를 사용하여 챗봇의 실시간 응답을 처리합니다. 메시지를 저장하고 업데이트하며, 실시간으로 토큰이 생성될 때마다 사용자에게 표시한다.

4.5 Ollama LLM 설정

llm = ChatOllama(
    model="EEVE-Korean-10.8B:latest",
    temperature=0.1,
    streaming=True,
    callbacks=[ChatCallbackHandler()],
)
  • Ollama의 “EEVE-Korean-10.8B” 모델을 사용하여 질문에 답변한다.
  • temperature=0.1: 모델의 응답이 얼마나 창의적인지 조정한다.
  • streaming=True: 모델의 출력이 실시간으로 스트리밍되어 사용자에게 즉시 표시된다.

4.6 MySQL 테이블 불러오기

def load_database_data(tables):
    try:
        connection = mysql.connector.connect(
            host=db_host,
            database=db_database,
            user=db_user,
            password=db_password,
            charset='utf8mb4',
            collation='utf8mb4_unicode_ci'
        )

        data_frames = {}
        for table in tables:
            query = f"SELECT * FROM {table}"
            df = pd.read_sql(query, connection)
            data_frames[table] = df

        return data_frames

    except mysql.connector.Error as err:
        st.error(f"Error connecting to MySQL: {err}")
        return None
    finally:
        if connection.is_connected():
            connection.close()
  • MySQL 데이터베이스에서 사용자가 선택한 테이블 데이터를 Pandas DataFrame으로 불러온다. 이 데이터는 AI에게 제공될 “컨텍스트”로 사용된다.

4.7 데이터 테이블 선택 및 로드

def table_selector():
    available_tables = ["aqara_cafe", "cs_table", "doorlock_malfunction_ledger", "curtain_ledger", "installation_ledger", "service_ledger"]
    selected_tables = st.sidebar.multiselect("Select tables to load", available_tables, default=available_tables)
    
    load_button = st.sidebar.button("Load selected tables")
    
    if load_button:
        data_frames = load_database_data(selected_tables)
        st.session_state["data_frames"] = data_frames
        st.success(f"Loaded data for tables: {', '.join(selected_tables)}")

    return st.session_state.get("data_frames", {})
  • 사용자가 MySQL 데이터베이스에서 불러올 테이블을 선택할 수 있게 한다. Streamlit의 multiselect 위젯을 사용하여 여러 테이블을 선택하고, 그 데이터를 로드하여 session state에 저장한다.

4.8 (참고 1) File Embedding 하기

def embed_file(file):
    # Define the directory path
    directory = "./private_files/"
    
    # Create the directory if it does not exist
    if not os.path.exists(directory):
        os.makedirs(directory)
    
    # Save the file to the directory
    file_path = os.path.join(directory, file.name)
    
    with open(file_path, "wb") as f:
        f.write(file.read())
    
    cache_dir = LocalFileStore(f"./private_embeddings/{file.name}")
    splitter = CharacterTextSplitter.from_tiktoken_encoder(
        separator="\n",
        chunk_size=600,
        chunk_overlap=100,
    )
    
    loader = UnstructuredFileLoader(file_path)
    docs = loader.load_and_split(text_splitter=splitter)
    
    embeddings = OllamaEmbeddings(model="EEVE-Korean-10.8B:latest")
    cached_embeddings = CacheBackedEmbeddings.from_bytes_store(embeddings, cache_dir)
    
    vectorstore = FAISS.from_documents(docs, cached_embeddings)
    retriever = vectorstore.as_retriever()
    
    return retriever

4.9 (참고 2) Webpage Embedding 하기

# Function to scrape website pages and extract text
def scrape_website(url):
    visited_urls = set()
    base_url = url
    texts = []

    def scrape_page(current_url):
        if current_url in visited_urls or not current_url.startswith(base_url):
            return
        visited_urls.add(current_url)

        # Request the page
        response = requests.get(current_url)
        soup = BeautifulSoup(response.content, "html.parser")

        # Extract text from the page
        page_text = soup.get_text(separator="\n").strip()
        texts.append(page_text)

        # Find all links on the page and recursively scrape them
        for link in soup.find_all("a", href=True):
            absolute_link = requests.compat.urljoin(base_url, link['href'])
            if absolute_link not in visited_urls:
                scrape_page(absolute_link)

    scrape_page(base_url)
    return texts

4.10 대화 기록 저장 및 표시

# Function to save the conversation history
def save_message(message, role):
    if "messages" not in st.session_state:
        st.session_state["messages"] = []
    st.session_state["messages"].append({"message": message, "role": role})
  • Streamlit의 session state를 사용하여 사용자와 AI 간의 대화 기록을 저장하고 페이지를 새로고침해도 대화 기록이 유지되도록 한다.

4.11 질문과 데이터 기반 응답 처리

prompt = ChatPromptTemplate.from_template(
    """Answer the question using ONLY the following context and not your training data.
    If you don't know the answer just say you don't know. DON'T make anything up.
    
    Context: {context}
    Question: {question}
    """
)
  • LangChain의 ChatPromptTemplate을 사용하여 AI가 훈련 데이터가 아닌, 제공된 데이터(컨텍스트)를 기반으로만 질문에 답변하도록 한다.
message = st.chat_input("Ask anything about your database...")
...
chain = (
    {
        "context": RunnableLambda(lambda _: context),
        "question": RunnablePassthrough(),
    }
    | prompt
    | llm
)
with st.chat_message("ai"):
    chain.invoke(message)
  • 사용자의 질문을 입력받고, 데이터베이스에서 가져온 컨텍스트와 함께 AI에게 전달하여 답변을 생성한다.

4.12 전체 워크플로우

  1. 사용자는 MySQL 데이터베이스 테이블을 선택한다..
  2. 선택된 데이터가 Pandas DataFrame으로 불러온다..
  3. 사용자가 질문을 입력하면, AI가 데이터베이스에서 가져온 데이터를 바탕으로 답변을 제공한다.
  4. 대화 기록이 유지되며, 이전 대화 내용을 확인할 수 있다.