OS自作入門のカーネル起動時に起きたABI不一致と解決策

『ゼロからのOS自作入門』を引き続き。「3.5 カーネルからピクセルを描く」のところで、UEFI ブートローダから自作カーネル(kernel.elf)へジャンプした瞬間に画面が真っ白のまま固まってしまいました。

GDB でレジスタと逆アセンブルを追いかけた結果、「EDK II のツールチェーンを CLANGPDB にしたことで、ブートローダとカーネル間で呼び出し規約(ABI)の不一致が起きていたことが判明しました、という日記です。

発生した現象

ブートローダ側で画面全体を白色(値 255)で初期化した後、カーネルへフレームバッファのアドレスとサイズを渡してグラデーションを描画するコードを実行。

ブートローダ側(一部抜粋)

EFI_GRAPHICS_OUTPUT_PROTOCOL* gop;

// ...

Print(L"Frame Buffer: 0x%0lx - 0x%0lx, Size: %lu bytes\n",
      gop->Mode->FrameBufferBase,
      gop->Mode->FrameBufferBase + gop->Mode->FrameBufferSize,
      gop->Mode->FrameBufferSize);
// 白く塗りつぶす
UINT8* frame_buffer = (UINT8*)gop->Mode->FrameBufferBase;
for (UINTN i = 0; i < gop->Mode->FrameBufferSize; ++i) {
    frame_buffer[i] = 255;
}

// ...

// エントリポイント(カーネル側 main.cpp の KernelMain関数)を取得して呼び出す
UINT64 entry_addr = *(UINT64*)(kernel_base_addr + 24);
typedef void EntryPointType(UINT64, UINT64);
EntryPointType* entry_point = (EntryPointType*)entry_addr;

entry_point(gop->Mode->FrameBufferBase, gop->Mode->FrameBufferSize);

カーネル側(main.cpp)

#include <cstdint>

extern "C" void KernelMain(uint64_t frame_buffer_base,
                           uint64_t frame_buffer_size) {
    uint8_t* frame_buffer = reinterpret_cast<uint8_t*>(frame_buffer_base);
    // グラデーション
    for (uint64_t i = 0; i < frame_buffer_size; ++i) {
        frame_buffer[i] = i % 256;
    }
    while (1) __asm__("hlt");
}

前回と異なり、エントリーポイントである KernalMain 関数に2つの引数がある。

仮引数 実引数 実際の値(Printで確認した値)
frame_buffer_base gop->Mode->FrameBufferBase 0x80000000
frame_buffer_size gop->Mode->FrameBufferSize 0x1d5000

QEMU 上で起動したところ、ブートローダが塗った白色の画面のまま変化せず、カーネル側のグラデーションが 1 ピクセルも描画されない。。。

GDB で原因調査する

QEMU に -s -S オプションを付けて GDB を接続し、CPU の状態を調べた。

(gdb) target remote :1234
(gdb) info registers rip
rip            0xfff0              0xfff0

rip が 0xfff0 を指していた。これは x86-64 のリセットベクタ(CPU 起動直後の初期位置)。何が起きているのだろう。

AIさんと相談して他のレジスタも確認してみたら?ということで、カーネルの先頭(0x101000)にブレークポイントを張り、関数に突入した瞬間の引数レジスタを確認。

(gdb) b *0x101000
(gdb) c
Breakpoint 1, 0x0000000000101000 in ?? ()

(gdb) info registers
...
rcx            0x80000000          2147483648
rdx            0x1d5000            1921024
rsi            0x7248f98           119836568
rdi            0x80201a            8396826
...

渡したかった値は

  • rcx のフレームバッファのアドレス: 0x80000000
  • rdx のフレームバッファのサイズ: 0x1d5000

しかし、前回の記事でも確認したELFファイルのヘッダーを見てみるとABIが UNIX - System V となっている。

$ readelf -h kernel.elf
ELF Header:
  ...
  OS/ABI:                            UNIX - System V
  ...

ABI UNIX - System V では、第1引数と第2引数は rdi と rsi にセットされることになっている。

正しい値は rcx と rdx に設定されているのに!

原因:Microsoft x64 ABI と System V AMD64 ABI

同じ x86-64 アーキテクチャでも、ABIによって関数の引数を渡すレジスタの規約(呼出規約: Calling Convention)が異なる。

引数の順序 Microsoft x64 ABI System V AMD64 ABI
第1引数 RCX RDI
第2引数 RDX RSI
第3引数 R8 RDX
第4引数 R9 RCX

書籍にも書いてあったが、UEFIアプリケーションはEFI形式ではなく、Windowsの実行可能ファイルであるPE形式。そのため、ABIは Microsoft x64 ABI になる。カーネル側のC++ソースコードをコンパイルした結果はEFI形式で、ABIは System V AMD 64 ABI となる。

なので、

  1. UEFIアプリケーション(Microsoft X64 ABI)、つまりブートローダーは、第1引数を RCX、第2引数を RDX に入れて entry_point() を呼んだ
  2. ELF カーネル(System V AMD64 ABI)は、第1引数を RDI、第2引数を RSI から読み取ろうとした
    • 呼び出す側と呼ばれた側で利用するレジスタがずれてしまった
  3. その結果、カーネルは私の想定とは異なる RDI = 0x80201a を画面の描画先アドレスとして書き込みを開始した

なぜ書籍のコードは動いていたのか?

書籍のコードではなぜ動くのか。

差分を調べたところ、理由はUEFIアプリケーションのビルドで使う EDK II のビルド設定(TOOL_CHAIN_TAG)の違い(だと思う)。書籍では edk2/Conf/target.txt の TOOL_CHAIN_TAG の値を CLANG38 にしているのだけど、最新の EDK Ⅱ ではその値を指定できず CLANGPDB という値を指定した。この指定の違いが原因なのではないか。

という予想だけど、ちゃんとした理由ははっきりしないまま。。

GDB による操作での事前検証

修正前に「本当にレジスタの不一致だけが原因か?」を確かめるため、GDB 上で手動でレジスタをコピーして動かしてみた。

(gdb) b *0x101000
(gdb) c
(gdb) set $rdi = $rcx
(gdb) set $rsi = $rdx
(gdb) c

このコマンドを実行したらクラッシュすることなく、QEMU の画面が真っ白からグラデーションへと塗り替わった!

解決策:__attribute__((sysv_abi)) の明示

解決策はシンプル。ブートローダ側の関数ポインタ宣言に「この関数を呼ぶときだけは SysV ABI のレジスタを使ってね」とコンパイラに指示する属性を付与する。

// 修正前:
// typedef void EntryPointType(UINT64, UINT64);

// 修正後:
typedef void __attribute__((sysv_abi)) EntryPointType(UINT64, UINT64);

これで、コンパイラは entry_point() を呼ぶ直前に mov %rcx, %rdi や mov %rdx, %rsi を挟む機械語を出力してくれるようになる。

おしまい

GDBは前回も調査でお世話になったり、今回はその場でレジスタの値を書き換えて検証もしました。とても便利。

レジスタレベルを調査することでABIの呼出規約に対する理解が少しだけ深まりました!

ミカンOSが起動しなくてデバッグした:ELFのロードアドレスとリンカの-z separate-codeオプション

『ゼロからのOS自作入門』という本を読んでます。読みながら写経して動かして、という感じ。QEMU 上で動かしてみると想定通り動かなかったのですが、おそらく書籍に書かれたリンカと異なるバージョンのリンカを使っていたためです。

書籍の「3.3 初めてのカーネル」という章で以下のC++で書いた最小のカーネルを実装します。

extern "C" void KernelMain() {
    while (1) __asm__("hlt");
}

__asm__("hlt") というはアセンブリ言語の命令を直接埋め込む書き方です。ループで hlt 命令を実行していて、 hlt 命令はCPUを停止させる命令。

そして、これを呼び出すようなUEFIアプリケーション(ブートローダー)を実装します。

しかし、いざ QEMU 上で動かしてみると想定通りに停止しませんでした。UEFIアプリケーションでELFファイルをメモリに展開するやり方に原因があったこと、リンカの -z separate-code オプションで動くようになることを確認できました、という日記です。

デバッグしてみる

カーネルとなるC++のソースコードは、以下のオプションでリンクする。 --image-base 0x100000 で出力されたバイナリのベースアドレスを 0x100000 番地としている。

$ ld.lld --entry KernelMain -z norelro --image-base 0x100000 --static -o kernel.elf main.o

動かない理由を調べる。まず、readelfコマンドでELFファイルのヘッダを確認する。

$ readelf -h kernel.elf
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x101120
  Start of program headers:          64 (bytes into file)
  Start of section headers:          1032 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         4
  Size of section headers:           64 (bytes)
  Number of section headers:         14
  Section header string table index: 12

Entry point address が重要で、リンク時に --entry KernelMain と指定したC++の関数を呼び出すためのアドレス。 Entry point address 値は、ELF形式の仕様でオフセット24バイトの位置から、8バイト整数として書かれると決まっている。なので、現時点の自作UEFIアプリケーション(ブートローダー)の実装では以下のようになっている。

  1. kernel.elf をファイルシステムから開く
  2. メモリの 0x100000 番地にファイル全体をそのまま Read でコピーする
  3. ELFヘッダの仕様に基づき、オフセット24バイト目に書かれている8バイト整数を読み取ってエントリポイントアドレスを取得する
  4. そのアドレスへ関数ポインタ経由でジャンプする
// UEFI側の読み出しコード(抜粋)
EFI_PHYSICAL_ADDRESS kernel_base_addr = 0x100000;
kernel_file->Read(kernel_file, &kernel_file_size, (VOID*)kernel_base_addr);

UINT64 entry_addr = *(UINT64*)(kernel_base_addr + 24); // オフセット24バイト目
typedef void EntryPointType(void);
EntryPointType* entry_point = (EntryPointType*)entry_addr;
entry_point();

これで上記のソースコードから、C++コードの KernelMain() 関数を呼ぶことになる。

デバッグする。

ビルドして QEMU を -s -S オプション付きで起動。 -s はGDBをポート1234で待ち受け、 -S はCPUを起動直後(最初の1命令目を実行する前)で一時停止させる。そして、GDB でエントリポイント 0x101120 にブレークポイントを張り、実行時の命令を確認してみる。

$ gdb
(gdb) target remote localhost:1234
   0x000000000000fff0 in ?? ()
   (gdb) b *0x101120
Breakpoint 1, 0x0000000000101120 in ?? ()
(gdb) x /3i $rip
=> 0x101120:    add    %al,(%rax)
   0x101122:    add    %al,(%rax)
   0x101124:    add    %al,(%rax)

hlt があるはずの場所に、謎の add %al, (%rax) が並んでいる。なんだこれ?どうやら 0x00 0x00 x86-64 においてデコードされた結果であり、未初期化の空っぽなメモリ領域を実行してしまっているっぽい。

つまり、 想定外(未初期化)の 0x101120 番地を実行してしまっている。

カーネルコードはどこへ消えたのか?

0x101120 にコードがないなら、どこにあるのだろう?適当にメモリを探索してみると、エントリポイントよりも手前にある 0x100120 付近に本物のコードが見つかった。

(gdb) x /10i 0x100120
   0x100120:    push   %rbp
   0x100121:    mov    %rsp,%rbp
   0x100124:    data16 data16 cs nopw 0x0(%rax,%rax,1)
   0x100130:    hlt
   0x100131:    jmp    0x100130

hlt と jmp(ループ)がちゃんとある!

  • コードが実際に置かれた場所: 0x100120
  • CPU がジャンプした場所: 0x101120

なぜずれたのか?

もう一度 readelf コマンドの出力に戻る。

$ readelf -h kernel.elf
ELF Header:
  Entry point address:               0x101120
  Start of program headers:          64 (bytes into file)

ELF ヘッダの Entry point address は確かに 0x101120 となっている。

うーん、そもそもELF形式のこと知らないなぁと調べてみる。家に積んであった『リンカ・ローダの実践開発テクニック』を読んだり。

ELFヘッダ・セクションヘッダ・プログラムヘッダの3つがあって、プログラムヘッダは OSがプログラムをメモリに読み込んで実行する際にどこに何を配置するか を示しているらしい。

プログラムヘッダは readelf -l で確認できる。

$ readelf -l kernel.elf

Elf file type is EXEC (Executable file)
Entry point 0x101120
There are 4 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000100040 0x0000000000100040
                 0x00000000000000e0 0x00000000000000e0  R      0x8
  LOAD           0x0000000000000000 0x0000000000100000 0x0000000000100000
                 0x0000000000000120 0x0000000000000120  R      0x1000
  LOAD           0x0000000000000120 0x0000000000101120 0x0000000000101120
                 0x0000000000000013 0x0000000000000013  R E    0x1000
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x0

 Section to Segment mapping:
  Segment Sections...
   00
   01
   02     .text
   03

Program Headers: という行の下から「セグメント」が並んでいる。 Segment Sections... という行の下から「セクションが」並んでいる。

3つ目のセクション 02 .text という行、 02 は3つ目(0から始まるインデックス番号)のセグメントに含まれてますよ、 .text はプログラムの機械語コードが格納されるテキスト領域のセクションですよ、という意味。

この3つ目のセグメント LOAD に注目する Offset: 0x0000000000000120 で VirtAddr: 0x0000000000101120 となってる。

VirtAddr: 0x0000000000101120 出た!CPU がジャンプした場所は 0x101120 と一致してる。

そして、コードが実際に置かれた場所は 0x100120 だった。

ブートローダーの実装を思い出すと、 0x100000 番地にELFファイルを読み込んでいるだけだった。その結果、コードが 0x100000 番地から上記セグメントの Offset 0x120 の場所に命令が置かれてしまった。本当は VirtAddr: 0x0000000000101120 の位置に置かないといけなかったのに。

0x100000 ┌────────────────────────────────────────┐
         │ ELFヘッダ (オフセット24に 0x101120 が記録) │
0x100120 ├────────────────────────────────────────┤
         │ ★ KernelMain 本体 (hlt / jmp)           │
0x100800 ├────────────────────────────────────────┤ ── ファイル終端 (書き込み終了)
         │                                        │
         │ 未初期化領域 (0x00 0x00 ... )            │
         │                                        │
0x101120 ├────────────────────────────────────────┤ ── CPUがジャンプした場所
         │ add %al, (%rax) (0x00 0x00 の誤解釈)     │
         └────────────────────────────────────────┘

解決策

ブートローダーでELFファイルを読み込んでメモリに配置する際に、プログラムヘッダーの内容を確認して配置するのが正しい解決方法、だと思う。別の方法として、リンカの -z separate-code オプションを使うことで解決できた。

『ゼロからのOS自作入門』でうまくいっていて私の環境でうまくいかなったのは、おそらくリンカのバージョン違い。調べてみると -z separate-code というオプションがあった。

プログラムヘッダーにあるセグメントの VirtAddr はメモリ上のページサイズの倍数で決められる。1ページが複数のセグメントに跨がないことになり、読み込み専用のセグメントと書き込み可能なセグメントが同じページを共有しないようにしているらしい。 VirtAddr はそうなのだが、 ELFファイルにおけるセグメントの配置はメモリ上のサイズ・配置とは別 で、ファイルサイズ節約のためページの倍数とか関係ないサイズになっている模様。

しかし -z separate-code オプションをつけると、ファイル上においてもページの倍数単位にセグメントが置かれるようになる(セグメント間はパディングされるのでファイルサイズは大きくなる)。

今回のことに当てはめると、 .text セクションが Offset 0x120 だったけれども、 -z separate-code オプションをつけることでファイル上でも4KB(0x1000)のページ境界に配置される。結果、ファイル上のオフセット 0x1000 と、プログラム実行時の相対アドレス 0x101000 - ベースアドレス 0x100000 = 0x1000 がピッタリ一致するようになる。

-z separate-code ありで、ビルド後のELFヘッダを確認。

$ readelf -h kernel.elf
  Entry point address:               0x101000
  Start of section headers:          8920 (bytes into file)
  • ファイルサイズがパディングによって約 9KB に増加
  • エントリポイントが綺麗な 4KB 境界である 0x101000 に変化

readelf -l でプログラムヘッダを確認。

Type   Offset              VirtAddr
LOAD   0x0000000000000000  0x0000000000100000  (ヘッダ等)
LOAD   0x0000000000001000  0x0000000000101000  (.text)

Offset: 0x0000000000001000 となっている。 0x100000 番地にELFファイルをそのまま展開することでも、 Entry address point と実際に命令が置かれる場所が 0x101000 で一致する。

おしまい

『ゼロからのOS自作入門』まだ始まったばかりだけどおもしろい!『リンカ・ローダ実践開発テクニック』も読みたくなってきた。

書籍『LangChainとLangGraphによるRAG・AIエージェント』をローカルで進める その3:LangfuseのEvaluation

前回の記事で、Langfuseを使ってローカルでのPrompts (プロンプトの管理に関する機能)とTracing (トレースの収集に関する機能)を試してみました。

今回はEvaluation (評価に関する機能)です。書籍の7章のメインテーマである「RAGの評価(Evaluation)」に該当します。書籍ではLangSmithとRAG評価フレームワーク「Ragas」を組み合わせています。前回に引き続き、LangSmithの代わりにLangfuseを使うことでローカル&無料で試してみます。

全体の流れ

RAGを使った処理の評価には、オフライン評価とオンライン評価がありますが、オフライン評価をやる。

全体の流れとしては以下の通り。

    1. Ragasを使ってデータセットを作成しLangfuseに登録する
    1. 評価対象の処理を実行する
    2. このとき、上記のデータセットを利用する
    3. 処理に加えて評価が行われ、評価結果がLangfuseに登録される
    1. Langfuseの画面から評価結果を確認する

1. Ragasを使ってデータセットを作成しLangfuseに登録する

RAGを評価するためには「問題」と「模範解答」のデータセット(Dataset)が必要。手で作るのは大変なので、Ragasの合成データ生成機能を利用する。LLMなどを使って生成したデータを合成データと呼ぶみたい。

LangChainリポジトリのドキュメントを元にRagasを使って合成データを生成し、Langfuseの Datasets に保存する。

では実装へ。

前々回、前回の流れの続きで、追加で $ uv add ragas==0.1.14 nest-asyncio==1.6.0 する。

ソースコードは以下の通り。以下の2点が書籍と異なる。

  • modelがLM StudioでLoadしているものになっていること
  • データセットの登録先がLangfuseになっていること
# create_datasets.py
# --------------------

# RAGの検索対象は引き続きLangChainの公式ドキュメントを利用する
from langchain_community.document_loaders import GitLoader

def file_filter(file_path: str) -> bool:
    return file_path.endswith(".md")

loader = GitLoader(
    clone_url="https://github.com/langchain-ai/langchain",
    repo_path="./langchain",
    branch="master",
    file_filter=file_filter,
)
documents = loader.load()
print(f"len(documents) = {len(documents)}")



# Ragasで合成テストデータ生成
import os
import nest_asyncio
from ragas.run_config import RunConfig
from ragas.testset.generator import TestsetGenerator
from ragas.testset.evolutions import simple, reasoning, multi_context
from langchain_openai import ChatOpenAI, OpenAIEmbeddings

for document in documents:
    document.metadata["filename"] = document.metadata["source"]

nest_asyncio.apply()

api_key = "dummy"
model = ChatOpenAI(
    base_url = "http://localhost:1234/v1",
    api_key = api_key,
    temperature = 0,
)
embeddings = OpenAIEmbeddings(
    base_url="http://localhost:1234/v1",
    api_key = api_key,
    model="text-embedding-nomic-embed-text-v1.5", # Embeddingに対応したモデル
    check_embedding_ctx_length=False,
)
generator = TestsetGenerator.from_langchain(
    generator_llm = model,
    critic_llm = model,
    embeddings = embeddings,
)
testset = generator.generate_with_langchain_docs(
    documents,
    test_size = 4, # 生成するデータを4つ
    distributions = {
        simple: 0.5, # 生成するデータの50%, つまり2つは単純
        reasoning: 0.25, # 1つは回答に推論が必要
        multi_context: 0.25, # 1つは回答に複数の情報源が必要
    },
)

print(testset.to_pandas())



# LangfuseのDatasetsに合成テストデータを保存
from langfuse import Langfuse
os.environ["LANGFUSE_SECRET_KEY"] = "sk-..." # langfuseから取得
os.environ["LANGFUSE_PUBLIC_KEY"] = "pk-..." # langfuseから取得
os.environ["LANGFUSE_BASE_URL"] = "http://localhost:3000"
langfuse = Langfuse()
dataset_name = "langchain docs"
langfuse.create_dataset(
    name=dataset_name,
    description="My first dataset",
    metadata={
        "author": "kariyayo",
        "date": "2026-07-01",
        "type": "benchmark"
    }
)
for testset_record in testset.test_data:
    langfuse.create_dataset_item(
        dataset_name = dataset_name,
        input = {"question": testset_record.question},
        expected_output = {"contexts": testset_record.contexts, "ground_truth": testset_record.ground_truth},
        metadata = {"source": testset_record.metadata[0]["source"], "evolution_type": testset_record.evolution_type},
    )

いざ実行。前回、前々回の続きなので、LM StudioのサーバーとLangfuseは起動している状態。

すると以下のメッセージが。そして最終的には例外を投げてエラー終了。

LangChainDeprecationWarning: As of langchain-core 0.3.0, LangChain uses pydantic v2 internally. The langchain_core.pydantic_v1 module was a compatibility shim for pydantic v1, and should no longer be used. Please update the code to import from Pydantic directly.

For example, replace imports like: `from langchain_core.pydantic_v1 import BaseModel`
with: `from pydantic import BaseModel`
or the v1 compatibility namespace if you are working in a code base that has not been fully upgraded to pydantic 2 yet.         from pydantic.v1 import BaseModel

  from ragas.metrics._answer_correctness import AnswerCorrectness, answer_correctness

ここまではLangChain v0.3系で進めてきた。書籍にも注釈として書いてあるのだけど、当時Ragasのバージョンは0.1系でLangChain v0.3に対応していなかったらしく、書籍ではRagasを使うところはLangChain v0.2系で進めている。

今ではRagas v0.2以降がリリースされていて、LangChain v0.3系に対応している。しかし、Ragas v0.2で破壊的変更があったようで、書籍の内容を読み進めることが難しくなる。なので、書籍の通りLangChainのバージョンを0.2系に下げて進めることにする。

$ uv add langchain-core==0.2.30 langchain-openai==0.1.21 langchain-community==0.2.12 "langchain-text-splitters<0.3.0" langchain-chroma==0.1.2 chromadb==0.5.3 ragas==0.1.14 nest-asyncio==1.6.0

これで実行できた!けど、すごく時間が掛かる。。PCのスペック、キツい。。

これが、、

% uv run regas_langfuse.py
len(documents) = 29
embedding nodes:  85%|█████████████████████████████████████████████████████████████████████████████████████████████████▌                 | 73/86 [15:19<05:18, 24.47s/it]

こうなって、、

% uv run regas_langfuse.py
len(documents) = 29
Generating:   0%|                                                                                                                                  | 0/4 [00:00<?, ?it/s]

こう!

% uv run regas_langfuse.py
len(documents) = 29
Generating: 100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 4/4 [19:25<00:00, 291.43s/it]
                                            question                                           contexts  ...                                           metadata episode_done
0  What are legacy chains in the LangChain ecosys...  [# 🦜️🔗 LangChain Classic\n\n[![PyPI - Version]...  ...  [{'source': 'libs/langchain/README.md', 'file_...         True
1  What is the purpose of the LangChain integrati...  [# langchain-openrouter\n\n[![PyPI - Version](...  ...  [{'source': 'libs/partners/openrouter/README.m...         True
2  How does LangChain‑OpenRouter let you tap mult...  [# langchain-openrouter\n\n[![PyPI - Version](...  ...  [{'source': 'libs/partners/openrouter/README.m...         True
3  How can devs contribute to LangChain while cho...  [# 🦜️🔗 LangChain Classic\n\n[![PyPI - Version]...  ...  [{'source': 'libs/langchain/README.md', 'file_...         True

[4 rows x 6 columns]

左メニューのDatasetsを選ぶと、登録されていることが分かる。

登録されている行をクリックするとこんな感じの画面。

データセットとは?

Langfuseのドキュメントにデータ構造の図があります。

Experiments Data Model - Langfuse

  • 評価のための処理を実行すると、DatasetRunができる
  • DatasetRunはn個のDatasetRunItemを持つ
  • DatasetRunItemは、1つのDataSetItemと関連し、1つのTraceと関連する

この関係が重要。このページを読まずになんとなく評価実行のソースコードを書いても上手くいかず。。では、次は評価実行のソースコードへ。

2. 評価対象の処理を実行する

データセットの登録ができたので、評価のステップに進む。

まずは評価対象のコードを用意。

# evaluation_langfuse.py
# ------------------------


# =====================================================================
# まずは評価対象となるRAGを使ったアプリケーションの処理
# =====================================================================

# RAGの検索対象は引き続きLangChainの公式ドキュメントを利用する
from langchain_community.document_loaders import GitLoader

def file_filter(file_path: str) -> bool:
    return file_path.endswith(".md")

loader = GitLoader(
    clone_url = "https://github.com/langchain-ai/langchain",
    repo_path = "./langchain",
    branch = "master",
    file_filter = file_filter,
)
documents = loader.load()
print(len(documents))



# RAG を用意
import os
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
api_key = "dummy"
model = ChatOpenAI(
    base_url = "http://localhost:1234/v1",
    api_key = api_key,
    temperature = 0.0,
    timeout = 600,
    max_retries = 1,
)
embeddings = OpenAIEmbeddings(
    base_url="http://localhost:1234/v1",
    api_key = api_key,
    model="text-embedding-nomic-embed-text-v1.5", # Embeddingに対応したモデル
    check_embedding_ctx_length=False,
)
db = Chroma.from_documents(documents, embeddings)


# 評価対象となるRAGを使ったChain
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableParallel, RunnablePassthrough

prompt = ChatPromptTemplate.from_template('''\
以下の文脈だけを踏まえて質問に回答してください。

文脈:"""
{context}
"""

質問:{question}
''')

retriever = db.as_retriever()

chain = RunnableParallel(
    {
        "question": RunnablePassthrough(),
        "context": retriever,
    }
).assign(answer=prompt | model | StrOutputParser())

ここまでは特に新しいことはなし。

続けて実行と評価の処理のソースコード。ここは先述したDataSetsのデータ構造を把握する前に書き始めたらなかなか上手くいかなかった部分。

# evaluation_langfuse.py の続き
# ------------------------


# =====================================================================
# ここから評価対象のChainを実行しつつ評価結果をLangfuseに登録する処理
# =====================================================================

# 1. Ragasの設定
from ragas.llms import LangchainLLMWrapper
from ragas.embeddings import LangchainEmbeddingsWrapper
from ragas.metrics import answer_relevancy, context_precision

ragas_llm = LangchainLLMWrapper(langchain_llm=model)
ragas_embeddings = LangchainEmbeddingsWrapper(embeddings)
context_precision.llm = ragas_llm
context_precision.embeddings = ragas_embeddings
answer_relevancy.llm = ragas_llm
answer_relevancy.embeddings = ragas_embeddings



# 2. 初期化とDatasets(テスト問題集)のロード
from langfuse import Langfuse
os.environ["LANGFUSE_SECRET_KEY"] = "sk-..." # langfuseから取得
os.environ["LANGFUSE_PUBLIC_KEY"] = "pk-..." # langfuseから取得
os.environ["LANGFUSE_BASE_URL"] = "http://localhost:3000"
langfuse = Langfuse()
DATASET_NAME = "langchain-docs" # Langfuseの画面で確認したデータセットの名前を指定
langfuse_dataset = langfuse.get_dataset(DATASET_NAME)



# 3. テストの実施(RAGアプリケーションの実行)
#    テスト問題集から1問ずつRAGアプリに入力し、解答を集める
from datasets import Dataset
from ragas import evaluate
from langfuse.langchain import CallbackHandler

evaluation_data = {
    "question": [],     # ユーザーからの質問
    "contexts": [],     # RAGでChromaから検索した結果
    "answer": [],       # RAGが最終生成した回答
    "ground_truth": [], # テストデータセットにある模範回答
}
traces_to_link = []
trace_ids = []
print("評価を開始します。RAGアプリケーションに問題を解かせています...")

RUN_NAME = "LM-Studio-RAG-Evaluation"  # Langfuseの画面に表示される実験名。データ構造のDatasetRun.nameに該当してるはず

for item in langfuse_dataset.items:
    question = item.input["question"]
    ground_truth = item.expected_output.get("ground_truth", "")
    langfuse_handler = CallbackHandler()
    # 評価対象のChainを実行
    response = chain.invoke(
        question,
        config={"callbacks": [langfuse_handler]},
    )
    answer = response["answer"]
    raw_context = response["context"]
    contexts = []
    for doc in raw_context:
        if hasattr(doc, "page_content"):
            contexts.append(doc.page_content)
        elif isinstance(doc, dict):
            contexts.append(doc.get("page_content", str(doc)))
        else:
            contexts.append(str(doc))
    
    if "ground_truths" not in evaluation_data:
        evaluation_data["ground_truths"] = []
    
    evaluation_data["question"].append(str(question))
    evaluation_data["contexts"].append(contexts)
    evaluation_data["answer"].append(str(answer))
    evaluation_data["ground_truth"].append(str(ground_truth))
    evaluation_data["ground_truths"].append([str(ground_truth)])
    trace_ids.append(langfuse_handler.last_trace_id)
    traces_to_link.append(item)

ragas_dataset = Dataset.from_dict(evaluation_data)



# 4. Ragasによる自動採点
print("スコア計算を開始します...")
from ragas.run_config import RunConfig
result = evaluate(
    dataset=ragas_dataset,
    metrics=[context_precision, answer_relevancy],
    run_config=RunConfig(max_workers=1, max_retries=3)
)
df = result.to_pandas()
print("\n=== 採点結果のプレビュー ===")
print(df)



# 5. Langfuseへの書き込み
print("\n 結果をLangfuseの Dataset Run に記録しています...")

def safe_score(val):
    import math
    return 0.0 if (val is None or (isinstance(val, float) and math.isnan(val))) else float(val)

for i, item in enumerate(traces_to_link):
    row = df.iloc[i] # この問題の採点結果の行を取得
    actual_trace_id = trace_ids[i]
    
    # トレースをDataset ItemおよびDataset Runに紐付ける
    try:
        langfuse.api.dataset_run_items.create(
            run_name=RUN_NAME,
            dataset_item_id=item.id,
            trace_id=actual_trace_id
        )
    except Exception as e:
        print(f"Dataset Run Itemへの紐付けに失敗しました (item_id: {item.id}): {e}")
    
    # 算出された点数を、項目ごとにLangfuseに送信する(0.0〜1.0の数値)
    if "answer_relevancy" in row:
        langfuse.create_score(
            name="answer_relevancy",
            value=safe_score(row["answer_relevancy"]),
            trace_id=actual_trace_id
        )

    if "context_precision" in row:
        langfuse.create_score(
            name="context_precision",
            value=safe_score(row["context_precision"]),
            trace_id=actual_trace_id
        )

print("評価が完了しました!")

いざ、実行!

$ uv run evaluation_langfuse.py
29
Failed to send telemetry event ClientStartEvent: capture() takes 1 positional argument but 3 were given
Failed to send telemetry event ClientCreateCollectionEvent: capture() takes 1 positional argument but 3 were given
評価を開始します。RAGアプリケーションに問題を解かせています...                                                                                                             Failed to send telemetry event CollectionQueryEvent: capture() takes 1 positional argument but 3 were given
スコア計算を開始します...
Evaluating: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 8/8 [11:32<00:00, 86.53s/it]

=== 採点結果のプレビュー ===
                                            question                                           contexts  ... context_precision answer_relevancy
0  How can devs contribute to LangChain while cho...  [# 🦜️🔗 LangChain Classic\n\n[![PyPI - Version]...  ...               0.0         0.808152
1  How does LangChain‑OpenRouter let you tap mult...  [# langchain-openrouter\n\n[![PyPI - Version](...  ...               1.0         0.917410
2  What is the purpose of the LangChain integrati...  [# langchain-openrouter\n\n[![PyPI - Version](...  ...               1.0         0.948827
3  What are legacy chains in the LangChain ecosys...  [# 🦜🍎️ LangChain Core\n\n[![PyPI - Version](ht...  ...               0.0         0.956593

[4 rows x 7 columns]

 結果をLangfuseの Dataset Run に記録しています...
評価が完了しました!

3. Langfuseの画面から評価結果を確認する

LangfuseのDatasets画面を見ると、「Experiments」列の値が 1 になっている。

データセットを選択して「Experiments」タブを選択する。

この画面に表示される行が、データ構造上のDatasetRunに対応している。

DatasetRunをクリック。DatasetRunItemの一覧画面へ。

「Trace」列のアイコンをクリック。Traceが見れる。ベクトル検索の結果、4つのmarkdownファイルの内容が返ってきてる。

それで、以下のプロンプトをOpenAI APIに投げてることが分かる。

以下の文脈だけを踏まえて質問に回答してください。

文脈:"""
... <- ここにベクトル検索結果の4つのmarkdownファイルの中身
"""

質問:What are legacy chains in the LangChain ecosystem, and why should most users prefer using the main langchain package instead?

OpenAI APIからのレスポンスも確認できる。レスポンスにはしっかりと説明が書かれている。

だけど、データセットの期待値としては、「回答できない」という内容である。したがって、

  • answer_relevancy: 0.96
    • LLMからのレスポンスがしっかり書かれている
  • context_precision: 0.00
    • 「以下の文脈だけを踏まえて」とプロンプトに書いたのに、データセットのExpected outputのContextからは得られないような情報まで出力されている

ということかな。そして、レスポンスの内容がハルシネーションではないと人間が見て判断できたなら、データセットが間違っていると。

おしまい

『LangChainとLangGraphによるRAG・AIエージェント』と、ローカルで動かすための試行錯誤のおかげで、知らない世界へ一歩踏み込めた気がします!