# Llama Index (RAG Note)

[入門教學](https://docs.llamaindex.ai/en/stable/getting_started/starter_example.html)
[學習用 colab](https://colab.research.google.com/drive/1kfLYZ9sF9i8nkKWxJYzZ0NXYgAv34Ttk#scrollTo=2ALGJXDIibDR)
# Llama Index 可以幹麻?
當我們使用 openai api 的 GPT 來問答時, GPT 會無法給出特定領域的詳細資料, 例如 自家公司的背景、人員與過往經歷或充當客服人員, 這時候有兩種方式可以解決:
1. **Fine-tunning** 微調訓練 LLM: 訓練完後語言模型會思考過後給出相對應的解答。
**優點:** 語言模型會真的知道這些知識與資料, 回答上也較為快速。
**缺點:** 有的語言模型不提供 Fine-tunning API, 而 Fine-tunning 過程收費高昂, 且成效不一訂好, 因為我們給的資料對於它原本的參數來說只是小蝦米。以及上傳資料的**隱私性**問題。
2. **RAG (資料檢索生成)**: 給定資料文件, 將文件轉成空間項量化的資料後, 根據目前問題找尋最相關的向量資料, 檢索後將資料餵給模型理解, 最後給出它的答案。
**優點:** 不用花那麼多時間做前置訓練處理、如果資料給對的話答案較為精準。不用上傳全部文件, 也比較安全。
**缺點:** 須先 embeding 文件, 因為是使用類似 prompt 加入訊息的方式給模型, 訊息內容較多, 所以每次回答上都會耗費更多時間。

Llama Index 皆有提供以上兩種方式的框架, 可以快速且簡單的方式建構 RAG 與 Fine-tunning。
# 入門
**使用 llama-index 快速建立 RAG 資料查詢器**
:::info
以下皆使用 colab 做示範, 我們要做一個簡單的 RAG , 給一個關於維生素 PDF 文件讓 GPT 回答相關問題。
:::
## 安裝套件
首先安裝 **llama-index** 及 **openai** 兩大套件
```bash=
!pip install llama-index
!pip install openai
```
## 匯入 OpenAI api-key
將 OpenAI 的 api-key 存在 colab 的 Secret 中

匯入 openai 跟讀取 api-key的 colab 套件
```python=
from google.colab import userdata
import openai
openai.api_key = userdata.get('OPENAI_API_KEY')
```
## 上傳文件
上傳文件到 colab 上, 資料結構為 **data/文件名稱**
[範例文件-人體每日所需養分建議攝取量表(.pdf)](http://www.egc.org.tw/attach/1056783966.pdf)-用分頁開啟就能下載

## 載入文件
接著我們使用 **SimpleDirectoryReader** 將本機檔案中的資料載入到 LlamaIndex
[官方文件](https://docs-llamaindex-ai.translate.goog/en/stable/module_guides/loading/simpledirectoryreader.html?_x_tr_sl=en&_x_tr_tl=zh-TW&_x_tr_hl=zh-TW&_x_tr_pto=sc)
```python=
from llama_index.core import SimpleDirectoryReader
documents = SimpleDirectoryReader("./data").load_data(show_progress=True)
```
- show_progress=True 顯示載入文件的進度

documents 物件就是文件本身的資料
```
[Document(id_='e346fc26-ded2-4325-ab9e-aa87cbd43e64', embedding=None, metadata={'page_label': '1', 'file_name': '維生素.pdf', 'file_path': '/content/data/維生素.pdf', 'file_type'....:
```
SimpleDirectoryReader 支援的文件格式如下:
| 格式 | 說明 | 格式 | 說明 |
| -------- | -------- | -------- | -------- |
| .csv | 逗號分隔檔 |.mbox |MBOX 電子郵件存檔 |
| .docx | Microsoft Word|.md|Markdown|
| .epub | EPUB 電子書格式|.mp3、.mp4|音樂和影片|
| .hwp | 韓文文字處理器|.pdf|便攜式文件格式|
| .ipynb | Jupyter 筆記本 |.png|手提網路圖形|
| .ppt、.pptm、.pptx|Microsoft PowerPoint|.txt|記事本文字檔|
JSON 格式的檔案可以使用 [JSON Loader](https://docs.llamaindex.ai/en/stable/examples/query_engine/json_query_engine.html)
## 文件處理 (開始 RAG)

在 RAG 之中最重要的就是 **Document (文件)** 和 **Node (節點)**:
文件就是上方列表的各種文件, 節點就是文件中每段資料的切片成一個個區塊(預設是 512 byte 為一節點), 也就是將文件拆分出取多塊。
之所以要切塊就是為了不要將大量資料丟給 GPT , 希望它可以透過 RAG 快速檢索出需要的資料。
**Embedding:**接著需要先將文件資料載入到 [VectorStoreIndex](https://docs.llamaindex.ai/en/stable/module_guides/indexing/vector_store_index.html) (向量儲存索引), 也就是將**資料轉換成空間向量**的形式, 以利後續方便檢索。
只需要以下一行程式就可以做完 RAG 的所有步驟, **原始資料 -> 切片 -> 向量資料**
```python=
from llama_index.core import VectorStoreIndex
index = VectorStoreIndex.from_documents(documents,show_progress=True)
```
- show_progress=True 用於顯示向量儲存索引的進度

可以看到會先將資料轉換成節點, 再將節點轉換成空間向量的形式。
:::success
VectorStoreIndex 預設會以 2048 個節點為一批轉成向量, 可以透過 insert_batch_size=2048 來修改每批次的節點數量 (依照系統的 RAM 來修改)

:::
:::success
上述 VectorStoreIndex 的 RAG 用法可以自訂, 包含 embedding 的模型、資料庫種類等等
:::
[llama index 支援的向量資料庫種類](https://docs.llamaindex.ai/en/stable/module_guides/storing/vector_stores.html)
預設的 embedding 模行為 **Text-embedding-ada-002-v2**
## 資料查詢
接下來就可以開始向語言模型詢問有關文件內容的問題了, 測試 RAG 的效果好不好
**query_engine 查詢引擎**:
此物件會將傳入的訊息先做 embdding 轉成向量資料後, 去找尋最相關的文件向量節點, 接著把相關資料與問題一併傳送給語言模型, 最後回傳 GPT (預設 gpt-3.5-turbo) 的回覆。
```python=
query_engine = index.as_query_engine()
response = query_engine.query("成人每日需要攝取多少鈣, 並用繁體中文說明")
print(response)
```
```
成人每日需要攝取600毫克的鈣。
```

結果很理想, 它答對了成人應該攝取的份量, 並且不會廢話。
---
也有提供串流模式:
```python=
query_engine = index.as_query_engine(streaming=True)
streaming_response = query_engine.query("請介紹菸鹼酸, 並用繁體中文說明")
streaming_response.print_response_stream()
```
```
菸鹼酸是一種維生素B群中的一員,也被稱為煙鹼酸或維生素B3。它在人體中扮演著重要的角色,有助於能量的產生,促進消化系統的健康,以及維持皮膚的正常功能。菸鹼酸也對心血管系統和神經系統有益。
```

如果叫它總結整份文件:
```python
"請介紹這份文件, 並用繁體中文說明"
```
```
這份文件主要包含關於山藥的營養價值和功效。山藥是一種多年生蔓性植物,其主要食用部位為地下塊莖,富含多種人體必需胺基酸、蛋白質、維生素A、B1、B2、E等營養成分,具有抗菌、抗氧化、抑制癌細胞、調節生殖系統、增強免疫力等功能。此外,山藥還含有多種微量元素,如鈣、磷、鐵等,對健康十分有益。
```
可以看出它對於整份文件的整理與表達並不好, 但其實 RAG 的概念並不是要對長篇大論做總結, 而是針對**最相關的問題**找答案、理解答案後回答。
// llama_index 是支援英文的, 可以使用官方文件的範例來測試。
## 儲存 index (向量資料)
預設會將剛才的向量資料儲存在記憶體中, 我們可以將它存在硬碟中, 就不怕處理過的向量資料消失了。
使用以下這一行就可以儲存 index, 預設資料會儲存到目錄中 storage,但可以透過 persist_dir 來變更資料夾名稱。
```python
index.storage_context.persist(persist_dir="資料夾名稱")
```
儲存好 index 後, 下次就能快速建立 index 物件並建立問答器
```python=
storage_context = StorageContext.from_defaults(persist_dir="資料夾名稱")
index = load_index_from_storage(storage_context)
query_engine = index.as_query_engine()
response = query_engine.query("成人每日需要攝取多少鈣, 並用繁體中文說明")
print(response)
```
## 如果 RAG 效果不好怎麼辦?
如果利用剛才的快速建立 RAG 製作問答器果不好的話, 就需要手動自訂義 RAG
可以自訂義的內容如下:
1. embedding 的模型:可以選擇其他型號的模型, 或是到 hugging face 上面找尋合適的 embedding model
2. 向量資料庫的種類:請參考[支援資料庫](https://docs.llamaindex.ai/en/stable/module_guides/storing/vector_stores.html)
3. 語言模型:可以使用 GPT-4-turbo 或其他模型測試
4. 文件切片的大小
接下來就要進入到自訂義的 RAG , 製作出最佳的 RAG GPT。
# 暴力拆解 Llama Index
## 讀進去的文件長怎樣?
當我們上傳了 PDF、docx、csv 後, SimpleDirectoryReader 會以紀錄文字的方式存在記憶體中。
我們一樣使用[維生素.pdf](http://www.egc.org.tw/attach/1056783966.pdf) 為例:
```python=
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
documents = SimpleDirectoryReader("./data").load_data(show_progress=True)
print(documents)
```
輸出結果如下:
```
[Document
(id_='bf002d55-ecd3-4fed-b909-8d0b27927712',
embedding=None,
metadata={
'page_label': '1',
'file_name': '維生素.pdf',
'file_path': '/content/data/維生素.pdf',
'file_type': 'application/pdf',
'file_size': 1182146,
'creation_date': '2024-03-14',
'last_modified_date': '2024-03-14'},
excluded_embed_metadata_keys=[
'file_name',
'file_type',
'file_size',
'creation_date',
'last_modified_date',
'last_accessed_date'],
excluded_llm_metadata_keys=[
'file_name',
'file_type',
'file_size',
'creation_date',
'last_modified_date',
'last_accessed_date'],
relationships={},
text=' 1 第一章 人體每日所需養分建議攝取量表 \n \n名稱 建議攝取量 附註 \n....',
tart_char_idx=None,
end_char_idx=None,
text_template='{metadata_str}\n\n{content}',
metadata_template='{key}: {value}',
metadata_seperator='\n')]
```
## VectorStoreIndex.from_documents 被隱藏的 RAG
回顧一下 RAG 的過程, 我們把剛才的 documents 丟到這裡:
```python
index = VectorStoreIndex.from_documents(documents,show_progress=True)
```
### 拆解 RAG 的第一步: 對文件做切片
匯入 SentenceSplitter 模組
```python
from llama_index.core.node_parser import SentenceSplitter
```
以下為切片的詳細步驟:
我們來做個簡單的測試, 會使用到切片的主要類別 SentenceSplitter
使用的範例文件為以下文字的 txt 檔:
:::info
不過要注意的是, 實際上每一切片還會放置詮釋資料 (metadata), 單一切片的 token 數量要扣除詮釋資料的 token 數, 才是真正可以放置內容的 token 數量。以本例來說, 詮釋資料的內容是 "filepath: " 加上文件檔案的完整路徑, 我測試時是放在 "C:\Users\meebo\code\python\debug_llama_index\data\test.txt", token 數量為 23, 所以若是預設的 1024 個 token, 減去 23 就是 1001 個 token。
:::
```python=
from llama_index.core import SimpleDirectoryReader
from llama_index.core.node_parser import SentenceSplitter
documents = SimpleDirectoryReader('data_ch').load_data()
splitter = SentenceSplitter(chunk_size=80, chunk_overlap=20, include_metadata=False)
print(splitter)
nodes = splitter.get_nodes_from_documents(documents)
for i, node in enumerate(nodes):
print(f'[節點{i}]:{node.text}')
```
當我們使用 SimpleDirectoryReader 讀取資料後會回傳 documents。
這時就會開始進到切片的主模組 **SentenceSplitter** 中,先來介紹目前看的三個參數:
| 參數 | 說明 |
| -------- | -------- |
| chunk_size = 80 | 切片每一段的 tokens 大小限制 |
| chunk_overlap = 20 | 重疊上一切片的大小限制, overlap 用於延續上文, 防止資訊被截斷 |
| include_metadata = False | 是否要包含資料本身的訊息(檔名、建立日期等等), 為 True 的話可以幫助模型辨識資料的種類 |
接著我們就來探究每一步動作。
1. 重疊大小必須小於 tokens 限制:
如果重疊的部分大於 tokens 限制就沒辦法切片了, 因為每次重疊都會大於 tokens 限制, 更不用說新增新的切片資料。
```pytyhon
if chunk_overlap > chunk_size: return error
```
使用 tokenizer (nltk) 判斷是否是句子, tokens 計算方式為 gpt-3.5-turbo:
```
enc = tiktoken.encoding_for_model("gpt-3.5-turbo")
```
---
舊版
建立 sentencsplitter 物件
```python
t = SentenceSplitter()
```
#### 1. 按段落分隔符號分割:把文字放入段落分割器
- 使用正規標達式分割

- 按預設分隔符號分割("")

```python
text_splits_by_fns, _ = t._get_splits_by_fns(documents[0].text)
print(text_splits_by_fns)
```
text_splits_by_fns 輸出結果:可以看到都是以句號或點為基準做切割
```
([' 1 第一章 人體每日所需養分建議攝取量表 \n \n名稱 建議攝取量 附註 \n醣類 醣類的攝取量隨個人熱量的需要而定,建議醣類的熱量佔\n總熱量的 45~55%,不得少於 20%。',
' 宜多攝取未經精製的醣類, 對身體\n健康較好。',
' \n蛋白質 每日蛋白質的攝取量應為總熱量的 10%。',
'理想的蛋白質每\n日建議攝取總量是 55~65克。',
' 食品資訊網:建議成年人每 公斤體\n重攝取 1~1.',
```
#### 2. 使用分詞器分割
使用 tiktoken 套件並根據 "gpt-3.5-turbo" 的設置來取得分詞器, 接著依照設定的分詞大小做切割。
我們使用第一段做示範:
```python
print(text_splits_by_fns[0])
```
輸出結果:
```
1 第一章 人體每日所需養分建議攝取量表
名稱 建議攝取量 附註
醣類 醣類的攝取量隨個人熱量的需要而定,建議醣類的熱量佔
總熱量的 45~55%,不得少於 20%。
```
開始用分詞器:
```python
print(t._token_size(text_splits_by_fns[0]))
```
輸出結果:
```
116
```
如果你把它放到 OpenAI 的 token 計算器上, 並使用 GPT-3.5 的計算方式會得出一樣的結果。

分詞器中是可以設定切割的大小, 預設是 chunk_size = 200 , 代表每次切割的 token 數量皆會小於 200 (若超過的段落則再重做一次步驟 1、2、3、4), 後續會教如何自定義 chunk_size。

在程式中已經把上述兩個步驟包裝起來了, 演化如下:
```python=
splits = t._split(documents[0].text,200)
for i in splits:
print(i)
```
輸出:已將文件切分成 200 tokens_size 以下的片段

接著要對以上 splits 做組合成指定大小的文本區塊:
```python=
splits = t._split(documents[0].text,200) # 做完 merge 時 splits 會變成 [], 如果要重新執行此儲存格就需要重定義物件
chunks = t._merge(splits, 200)
for i in chunks[:2]:
print(i)
print('---------')
```
輸出:我們先觀察第一區塊
```
1 第一章 人體每日所需養分建議攝取量表
名稱 建議攝取量 附註
醣類 醣類的攝取量隨個人熱量的需要而定,建議醣類的熱量佔
總熱量的 45~55%,不得少於 20%。 宜多攝取未經精製的醣類, 對身體
健康較好。
蛋白質 每日蛋白質的攝取量應為總熱量的 10%。
```
接著是第二區塊:會發現內容只有增加一個片段, 並且整體 tokens 超過 200, 不是說會設定區塊的大小嗎?
```
1 第一章 人體每日所需養分建議攝取量表
名稱 建議攝取量 附註
醣類 醣類的攝取量隨個人熱量的需要而定,建議醣類的熱量佔
總熱量的 45~55%,不得少於 20%。 宜多攝取未經精製的醣類, 對身體
健康較好。
蛋白質 每日蛋白質的攝取量應為總熱量的 10%。理想的蛋白質每
日建議攝取總量是 55~65克。
```
之所以會這樣就要來看 **merge** 到底做了什麼事情。
- 首先我們看一下 split (就是傳給 merge 的參數) 的第一項 , 是由 text、is_sentence、token_size 組合的 tuple。
```python
_Split(text=' 1 第一章 人體....', is_sentence=False, token_size=116)
```
這時候會有一個 cur_chunk_len 變數專門儲存累加的 token_size。
並且將 split 儲存在 cur_chunk 中
```python
cur_chunk_len = 0
cur_chunk = []
```
當我們按照順序累加 split 時, 會自動限制 cur_chunk_len < 200(預設): 所以看起來會像
```
cur_chunk_len = 116+37+43 = 196
cur_chunk = [
_Split(text=' 1 第一章 人體....', is_sentence=False, token_size=116)
_Split(text='宜多攝取未經....', is_sentence=False, token_size=37)
_Split(text='\n蛋白質 每日....', is_sentence=False, token_size=43)
]
```
透過剛才的 splits 列表中我們可以知道下一個 split 的 token_size 是 32, 但 cur_chunk_len 再加上去就會爆表, 這時候到下一階段**特殊處理** cur_chunk_len 超過 200 的問題 (這時候 cur_chunk 沒有加入 32 的 split 喔)
**特殊處理**
既然再加上下一個會超過限制, 那就需要將目前儲存的 split 組成一塊存到新變數 chunks 中,如下:
```python
chunks = [[' 1 第一章 人體....'+
'宜多攝取未經...'+
'\n蛋白質 每日...']]
```
:::success
請注意, 這個 chunks 就是剛才我們輸出的第一區塊
:::
再來會將 cur_chunk 從尾到頭反過來計算累加是否小於 200,此次計算一定小於 200 (因為裡面三組總和 196)
(裡面有用 insert(0), 所以此次 cur_chunk 元素排序不變)
---
接下來回到原本的階段, 繼續加上下一個 split:
因為剛才去過特殊處理, 所以本次 196 + 32 並不會再次跑到特殊處理去,
cur_chunk 這時候加上了新的 split
```
cur_chunk = [
_Split(text=' 1 第一章 人體....', is_sentence=False, token_size=116)
_Split(text='宜多攝取未經....', is_sentence=False, token_size=37)
_Split(text='\n蛋白質 每日....', is_sentence=False, token_size=43)
_Split(text='理想的蛋白質每....', is_sentence=False, token_size=32)]
```
加完之後 cur_chunk_len 肯定超過 200 (狀態改變), 又會跑去**特殊處理**:
**特殊處理**
先將現有的資料儲存在 chunks 中, 再反過來做計算 token_size。
目前的狀況是:
```python
chunks = [[' 1 第一章 人體....'+
'宜多攝取未經...'+
'\n蛋白質 每日...'],
[' 1 第一章 人體....'+
'宜多攝取未經...'+
'\n蛋白質 每日...'+
'理想的蛋白質每....']]
```
可以發現此處兩區塊就是剛才我們輸出的部分, 第二區塊會多一個的原因就是進到特殊處理時就會先儲存已跑到的部分, 至於 merge 的 參數 200 限制, 雖然是指累加時的限制, **可以想像成加完這次超過 200 就停**。
反過來計算的 token_size 只會計算到第 2、3、4 個片段, 再加上第一個就會超過 200 了。
我們拿 2、3、4 個片段(cur_chunk)再回到主程式中加上第 5 個 split 持續到大於 200 就進入到 **特殊處理**。
最後的樣子可以想像成以下:

每個區塊都會含有上一個區塊的資料, 這樣一來可以最大化保有碎片訊息, 讓之後檢索資料時準確度更高。
現在我們已經將完整的文檔切塊了, 下一步就是將這些區塊組合成一個個 node (節點)
#### 組合成節點
# 客製化 RAG
## 更改文件切片的大小
我們可以改動節點中的文件切割區快要多大, 針對長篇且連續的文章會需要使用更大的區塊為一個節點。Chunk_size 其實就是 tokes 的大小。

```python=
from llama_index.core import Settings
Settings.chunk_size = 512
# Local settings
from llama_index.core.node_parser import SentenceSplitter
index = VectorStoreIndex.from_documents(
documents, transformations=[SentenceSplitter(chunk_size=512)]
)
```
## 可以使用其他向量資料型態
首先需要建立儲存向量資料的設定物件 StorageContext , 其中包含向量資料的格式與各項資料, 方便我們建立向量資料庫。
```python=
import chromadb
from llama_index.vector_stores.chroma import ChromaVectorStore
from llama_index.core import StorageContext
chroma_client = chromadb.PersistentClient()
chroma_collection = chroma_client.create_collection("quickstart")
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
storage_context = StorageContext.from_defaults(vector_store=vector_store)
```
```python=
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
documents = SimpleDirectoryReader("data").load_data()
index = VectorStoreIndex.from_documents(
documents, storage_context=storage_context
)
query_engine = index.as_query_engine()
response = query_engine.query("What did the author do growing up?")
print(response)
```
## 如果有多個文件, 可以使用線程載入
官方說明:使用 windows 執行此行時效能不會提升多少, linux 才有明顯提升。
```python
documents = reader.load_data(num_workers=4)
```
## 檢索時取得更多相關資料
預設是取得最相關的兩筆資料。
```python
query_engine = index.as_query_engine(similarity_top_k=5)
```
## 更換語言模型
利用 ollama 把以下載的模型拿來作為回答的模型。
```python=
from llama_index.core import Settings
from llama_index.llms.ollama import Ollama
Settings.llm = Ollama(model="mistral", request_timeout=60.0)
# Local settings
index.as_query_engine(llm=Ollama(model="mistral", request_timeout=60.0))
```
request_timeout 代表限制超時的時間, 當設置為 60 秒, 若 60 秒內都沒有回覆就會判定為失敗。
## 改變回應模式
```python
query_engine = index.as_query_engine(response_mode="tree_summarize")
```
有以下回應模式:
| 回應模式 | 說明 | 速度 |
| ------------------ | ------------------------------------------ |:----:|
| refine | 利用多節點的答案迭帶讓回答更加準確 | 慢 |
| compact | 最後輸出的回應會變得更加簡潔、精簡 | 快 |
| simple_summarize | 將所有區塊整合一併給 LLM, 超過限制則會失敗 | 慢 |
| tree_summarize | 根據查詢建立樹狀索引, 可以全面且快速找到相關資料 | 快 |
| generation | 不參考給定的文件直接回覆 | 快 |
| no_text | 只會檢索節點, 不會生成回應 | 快 |
| accumulate | 對每個區塊取得回覆, 最後再將回覆合併| 慢|
| compact_accumulate | 先合成出區塊較大的文本, 再一一對這些文本生成回覆 | 慢 |
## Settings 直接更改預設值
```python
from llama_index.core import Settings
```
### 更改語言模型
```python=
from llama_index.llms.openai import OpenAI
Settings.llm = OpenAI(model="gpt-3.5-turbo", temperature=0.1)
```
也可以使用本地端的模型
```python=
from llama_index.llms.ollama import Ollama
Settings.llm = Ollama(model="llama2", request_timeout=60.0)
```
### Embeddings 模型
```python=
from llama_index.embeddings.openai import OpenAIEmbedding
Settings.embed_model = OpenAIEmbedding(
model="text-embedding-3-small", embed_batch_size=100
)
```
### 文句分割器
```python=
from llama_index.core.node_parser import SentenceSplitter
Settings.text_splitter = SentenceSplitter(chunk_size=1024)
```
### 只想更改區塊大小或區塊重疊
```python=
Settings.chunk_size = 512 # 更改區塊大小
Settings.chunk_overlap = 20 # 更改區塊重疊
```
### 變更 tokens 分詞器
#### openai
```python=
import tiktoken
Settings.tokenizer = tiktoken.encoding_for_model("gpt-3.5-turbo").encode
```
#### open-source
```python=
from transformers import AutoTokenizer
Settings.tokenzier = AutoTokenizer.from_pretrained(
"mistralai/Mixtral-8x7B-Instruct-v0.1"
)
```
### Callbacks
可以設定一個全域回呼管理器,它可用於觀察整個 llama-index 程式碼中產生的事件
```python=
from llama_index.core.callbacks import TokenCountingHandler, CallbackManager
token_counter = TokenCountingHandler()
Settings.callback_manager = CallbackManager([token_counter])
```