ローカルLLMの進化：Llama3.2で特許検索システムを再構築！

![aozora.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/28600395-305e-c9dd-257e-c338c12cdbd2.jpeg)

# はじめに
今回は、「ローカルLLM」にリベンジします。以前、ローカル環境にLLMを構築した際、その過程を記事にまとめました。しかし、結果として応答に10分以上かかることが多く、実用には程遠い状況でした。（記事公開後も再度トライしてみましたが、1時間以上応答がないことも珍しくありませんでした…）

https://qiita.com/ogi_kimura/items/45dffc2bc8334561a432

諦めかけていたところ、@coitateさんの投稿を見て、もう一度挑戦してみることにしました。Meta社から**Llama3.2**が発表され、さらに軽量化されたという記事を読んだからです。@coitateさん、素晴らしい投稿ありがとうございます！

https://qiita.com/coitate/items/0ab96c3f092e4f8b1fa0


# 早速インストール

早速、**Llama3.2**をインストールして実行してみようと思います。以前の記事「ローカルLLMをWindowsで動かしてみた話」では、**ollama**を事前にインストールし、さまざまなモデルを試しました。今回も**ollama**を基盤として、**Llama3.2**を実行していきたいと考えています。

```
ollama run llama3.2
```

このコマンドは「コマンドプロンプト」または「PowerShell」で実行します。（私の環境はWindows 11、メモリ8GBです）数分待てばインストールが完了し、プロンプト上で入力待ちの状態になるはずです。特に必要がなければ、「コマンドプロンプト」を閉じても問題ありません。裏でタスクが引き続き実行されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/a6ef8966-38e7-d8f8-b2f5-5989c14658dc.png)

こんな感じで、Llamaのロゴが表示されていれば、無事に動作している証拠です。

# プログラムの紹介
プログラムは、以前の記事を再利用することにします。

https://qiita.com/ogi_kimura/items/4bbf2c987856a9435280

まずは、特許情報のXMLファイルから必要な情報を抽出し、それをベクトル化してSQLiteデータベースに格納する（RAG作成）処理を行います。`Embedding`については、後日自作できるように頑張る予定ですが、今回は前回同様、`Embedding`の部分は**OpenAI**の力を借りることにします。
```python:chroma_retriever.py
import glob
import os
import xml.etree.ElementTree as ET
from dotenv import load_dotenv
from langchain.text_splitter import CharacterTextSplitter
from langchain.embeddings.openai import OpenAIEmbeddings
from langchain.vectorstores import Chroma

load_dotenv()

docs = []

# 取り出したい名前空間-タグ名
name_spaces_tag_names = [
    "{http://www.wipo.int/standards/XMLSchema/ST96/Common}PublicationNumber",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Common}PublicationDate",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Common}RegistrationDate",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Common}ApplicationNumberText",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Common}PartyIdentifier",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Common}EntityName",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Common}PostalAddressText",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Common}PatentCitationText",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Common}PersonFullName",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Common}P",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Common}FigureReference",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Patent}PlainLanguageDesignationText",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Patent}FilingDate",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Patent}InventionTitle",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Patent}MainClassification",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Patent}FurtherClassification",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Patent}PatentClassificationText",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Patent}SearchFieldText",
    "{http://www.wipo.int/standards/XMLSchema/ST96/Patent}ClaimText",
]

def set_element(level, trees, el):
    trees.append({"tag" : el.tag, "attrib" : el.attrib, "content_page" :el.text})

def set_child(level, trees, el):
    set_element(level, trees, el)
    for child in el:
        set_child(level+1, trees, child)

def parse_and_get_element(input_file):
    tmp_elements = []
    new_elements = []
    tree = ET.parse(input_file)
    root = tree.getroot()
    set_child(1, tmp_elements, root)
    for name_space_tag_name in name_spaces_tag_names:
        for tmp_element in tmp_elements:
            if tmp_element["tag"] == name_space_tag_name:
                new_elements.append(tmp_element)
    return new_elements

title = ""
entryName = ""
patentCitationText = ""

files = glob.glob(os.path.join("C:/Users/ogiki/JPB_2023185", "**/*.*"), recursive=True)
for file in files:
    base, ext = os.path.splitext(file)
    if ext == '.xml':
        # --- topic名称 ---
        topic_name = os.path.splitext(os.path.basename(file))[0]
        # --- file名称 ---
        print(file)

        text_splitter = CharacterTextSplitter(chunk_size=300, chunk_overlap=0)
        new_elements = parse_and_get_element(file)
        for new_element in new_elements:
            try:
                text = new_element["content_page"]
                tag = new_element["tag"]
                title = text if tag == "{http://www.wipo.int/standards/XMLSchema/ST96/Patent}InventionTitle" else ""
                entryName = text if tag == "{http://www.wipo.int/standards/XMLSchema/ST96/Common}EntityName" else ""
                patentCitationText = text if tag == "{http://www.wipo.int/standards/XMLSchema/ST96/Common}PatentCitationText" else ""

                documents = text_splitter.create_documents(texts=[text], metadatas=[{
                    "name": topic_name, 
                    "source": file, 
                    "tag": tag, 
                    "title": title,
                    "entry_name": entryName, 
                    "patent_citation_text" : patentCitationText}]
                )
                docs.extend(documents)
            except Exception as e:
                continue

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
db = Chroma(persist_directory="C:/Users/ogiki/vectorDB/local_llm_chroma", embedding_function=embeddings)

# トークン数制限のため、500 documentずつ処理をする
intv = 500
ln = len(docs)
max_loop = int(ln / intv) + 1
for i in range(max_loop):
    splitted_documents = text_splitter.split_documents(docs[intv * i : intv * (i+1)])
    db.add_documents(splitted_documents)
```

次に、**streamlit**を使って画面表示を行い、受け取った質問に対して回答するプログラムを作成します。
```python:chroma_streamlit.py
import chainlit as cl
import streamlit as st
from langchain_community.chat_models.ollama import ChatOllama
from langchain.chat_models import ChatOpenAI
from langchain.embeddings import OpenAIEmbeddings
from langchain.prompts import PromptTemplate
from langchain.schema import HumanMessage
from langchain.vectorstores import Chroma
from qdrant_client.http.models import Filter, FieldCondition, MatchValue

from langchain.agents import AgentType, initialize_agent, load_tools
from langchain.callbacks import StreamlitCallbackHandler

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
chat = ChatOllama(model="llama3.2", temperature=0)
#chat = ChatOpenAI(model="gpt-4o-mini", temperature=0)

prompt = PromptTemplate(template="""文章を元に質問に答えてください。 

文章: 
{document}

質問: {query}
""", input_variables=["document", "query"])

database = Chroma(
    persist_directory="C:/Users/ogiki/vectorDB/local_llm_chroma", 
    embedding_function=embeddings
)

st.title("特許検索システム")

if "messages" not in st.session_state:
    st.session_state.messages =[]

for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.markdown(message["content"])

input_message = st.chat_input("準備ができました！メッセージを入力してください！")
text_input = st.text_input("ここに番号を入力してください")

if input_message:
    st.session_state.messages.append({"role": "user", "content": input_message})
    print(f"入力されたメッセージ: {input_message}")
    
    with st.chat_message("user"):
        st.markdown(input_message)

    with st.chat_message("assistant"):
            
        documents = database.similarity_search_with_score(input_message, k=3, filter={"name":text_input})
        documents_string = ""
        for document in documents:
            print("---------------document.metadata---------------")
            print(document[0].metadata)
            print(document[1])
            documents_string += f"""
                ---------------------------
                {document[0].page_content}
                """
        print("---------------documents_string---------------")
        print(input_message)
        print(documents_string)
        result = chat([
            HumanMessage(content=prompt.format(document=documents_string,
                                            query=input_message))
        ])
        st.markdown(result.content)
        st.session_state.messages.append({"role": "assistant", "content": result.content})
```

この処理の大まかな流れは以下の通りです。

1. 画面から受け取った質問を「自前のRAG」で検索
2. 「自作のRAG」からスコアの高い文章を取得（上位3つ）
3.  取得した文章を「ローカルLLM」に投入し、成型された回答を取得
4.  取得した回答を画面に表示

以前は「3」の部分でOpenAIのAPIを使用していたため課金対象になっていましたが、今回は「ローカルLLM」に投げる処理に変更（課金対象外）した点が大きな違いです。

今回変更した部分は以下のコードです。
```python:
from langchain_community.chat_models.ollama import ChatOllama

.......

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
chat = ChatOllama(model="llama3.2", temperature=0)
#chat = ChatOpenAI(model="gpt-4o-mini", temperature=0)
```

以前は`gpt-4o-mini`のOpenAIモデルを使用していましたが、今回は**llama3.2**を採用しました。また、'embeddings'についてはローカルで対応できるものがないため、**OpenAI**のものをそのまま使用しています。これはプログラムの以下の部分で使用されています。
```python:
database = Chroma(
    persist_directory="C:/Users/ogiki/vectorDB/local_llm_chroma", 
    embedding_function=embeddings <=ここで使われています。
)
```


# streamlitのデバッグ環境
記事を投稿していくうちに、プログラムが徐々に複雑化してきました。そのため、**streamlit**でもデバッグ環境で実行したいと考えています。私自身、過去に**Java**で開発を行い、ブレークポイントを設定してデバッグし、インスタンスが保持する変数の値を確認することでバグを早期に解消した経験があります。このように、デバッグ環境は非常に重要だと感じています。

現在、開発環境としては**VSCode**を使用しています。以前は**PyCharm**を使っていましたが、最近はほとんどの開発が**VSCode**で行われているため、私もそれに合わせました。一昔前は**Eclipse**を使っていたのが懐かしいです。

さて、本題に戻りますが、**PyCharm**であればデバッグ環境の設定はそれほど難しくありません。しかし、**VSCode**では少し手間がかかることが分かりました。そこで、どのようにデバッグモードを設定するかについて、手順を解説します。

ちなみに、通常実行する場合は以下のコマンドを「コマンドプロンプト」などで実行します。
```
python -m streamlit run [実行ファイル]
```

　**VSCode**でデバッグを行う際には、プロジェクトフォルダ内にある`.vscode`フォルダに含まれる`launch.json`を参照する設定になっています。そのため、`launch.json`を作成し、適切に修正する必要があります。
　まず、VSCodeの左側にある虫のアイコン（デバッグアイコン）をクリックしてください。すると「実行とデバッグ」というボタンが表示され、その下に「launch.jsonファイルを作成します」というリンクがあるので、そこをクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/836ac24d-e8a9-e87e-a8b7-da4e17e74ce1.png)

そうすると、**VSCode**の上部に以下のような表示が出てくるので、「Python Debugger」をクリックしてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/c85319fe-98e9-9c7e-74dc-e48bc1abc284.png)

さらに以下のように表示されるので、「Pythonファイル」をクリックしてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/d35b1d23-225e-4851-bdb0-243c0ba4d24a.png)

すると、以下のようにjson形式のファイル(lounch.json)が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/96457ab0-2688-db42-3716-3527722a6bee.png)

ただし、これでは**Python**のデバッグにしか対応していないため、**streamlit**でデバッグできるようにするためには、以下のように変更してください（そのまま貼り付けても大丈夫です）。これにより、**streamlit**でもデバッグが可能となり、ブレークポイントを設定することができます。
```json:lounch.json
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "debug",
            "type": "debugpy",
            "request": "launch",
            "module": "streamlit",
            "console": "integratedTerminal",
            "env": {
                "PYTHONPATH": "${workspaceFolder}",
            },
            "args": [
                "run",
                "${file}",
                "--server.port",
                "5678"
            ]
        }
    ]
}
```

# プログラム実行
それでは、プログラムを実行します。以下のように、無事に立ち上がりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/c7e710e1-18dd-a365-2303-d561d6e16eee.png)

すると、Webブラウザには次のような画面が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/2e0378cf-20f9-1f91-c1a6-d54c7a909d38.png)

それでは、いくつか入力してみましょう。

### ケース１
まず、特許番号「0007350061」を入力し、「フューリンの意味は？」と質問してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/388ef08d-9874-2603-fd61-a45f58e9d862.png)

何やら正しい結果が得られているようです。ただし、一般的な回答かもしれないので、一度ログを確認してみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/033a15ef-5578-7214-b990-d7fafc116b77.png)

ログを確認すると、しっかりと3つの文章を取得しており、それを基にして回答していることがわかります。つまり、「自作のRAG」から適切に情報を取得できていることが確認できました。回答までにかかった時間は約40秒でした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/538543c0-7f32-a7f4-3829-9813b9cb14f5.png)

### ケース２
次に、特許番号「0007350061」を入力して「概要を教えて」と質問してみました。その回答は「概要は、発明の基本的な説明です。」とのこと。少し冷たい反応ですね…何か怒っているのでしょうか？
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/58cabe6d-75b7-16f2-bfa6-41e592eaf9e8.png)

ログを確認しても「自作のRAG」から情報は取得されているのですが、関連性のない内容が返ってきているようです。これはベクトルDBの特性上、「質問の文言に関連したものを回答する」という仕組みのため、「概要」に関連付けて検索しているからだと思われます。回答までの時間は約30秒でした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/c284f6e1-f80b-211a-a25a-0110fc9dfd76.png)

### ケース３
最後に、少し難しい質問をしてみましょう。データベースを確認すると、「00073535931」の文章の中に「『静的表面張力』という用語は25℃及び大気圧における静的表面張力を指す。」という文を見つけました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/cfc132d3-4df3-1e95-792f-dcc892f773fe.png)

それを踏まえて「静的表面張力の温度は何度？」と質問してみると、「25℃です」という回答が返ってきました。まずまずの結果かと思います。回答までにかかった時間は約40秒でした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/a860b367-3f68-9a04-d359-7dbecc32a460.png)

ログもこのような感じでした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/24756aca-be24-ae9a-4780-f46f53170179.png)



# おわりに
　処理待ち時間も少しずつ短縮されてきました。この調子でいけば、「ローカルLLM」でも`OpenAI`や`Gemini`などのAPIを利用しなくても良くなる日がそう遠くはないかもしれません。
　技術やITに詳しくない保守的な経営者でも、「自社内で構築するならセキュリティ的に問題ないだろう、やってよろしい」と言ってくれる可能性があるかもしれません。知らんけど（大阪のおばちゃんの言い回しです）。