end0tknr's kipple - web写経開発

太宰府天満宮の狛犬って、妙にカワイイ

coacd for python を用い、STEPファイルを、矩形STLに分解&変換

MuJoCoを用いたロボットのシミュレーションを考えていますが、 MuJoCoが扱えるのはSTL形式で、また、当たり判定用に凹凸のあるソリッドは、 矩形に分解する必要があるらしい。

これを受け、coacd も用い、次のように実装

"""STEPファイルに含まれる部材を部材毎のSTLファイルに出力する。
ただし、MuJoCoでのシミュレーションを行う際の当たり判定用に
凹凸があるスラブは分割。"""
import argparse
import json
import re
from pathlib import Path

import coacd      # 凸分解 library (pip install coacd)
import gmsh       # STEP 読込みと、表面の三角形分割。内部で OpenCASCADEを使用
import numpy as np
import trimesh    # 三角形メッシュの操作 (法線の向きの修正、STL の読み書き)

# ---- 設定 ----
SCALE = 0.2       # 縮尺 (1/5 の縮小模型)
MESH_SIZE = 50.0  # 三角形の細かさ: 実寸での最大メッシュ辺長 [mm]
GROUND_TOL = 1.0  # 底面区分の判定 [mm]

# MuJoCoでのシミュレーションを行う際、長辺方向をY軸に沿わす為
R_WORLD = np.array([[0, -1, 0], [1, 0, 0], [0, 0, 1]], float)


def load_solids(step: Path, mesh_size: float):
    """STEP 内にある各solidを (名前, 頂点座標, 頂点番号) で返す """
    ret_solids = []
    try:
        gmsh.initialize()
        gmsh.option.setNumber("General.Terminal", 0)  # 進捗を非表示
        gmsh.option.setString("Geometry.OCCTargetUnit", "MM")  # 単位 mm
        
        gmsh.model.occ.importShapes(str(step))  # STEP の全solidを読み込む
        gmsh.model.occ.synchronize()            # 読込んだ形を gmsh型に反映
        # 曲面は円周を 24 分割。360/24=15度
        # 平らな面でも 1 辺が mesh_size [mm] を超えないようにする
        gmsh.option.setNumber("Mesh.MeshSizeFromCurvature", 24)
        
        gmsh.option.setNumber("Mesh.MeshSizeMax", mesh_size)
        
        # 表面だけを三角形に分割。
        # 例 generate(1):辺に分割、generate(2):面に分割、generate(3):立体に分割
        gmsh.model.mesh.generate(2)

        # 全頂点の番号と座標。ただし、[x0,y0,z0, x1,y1,z1, ...] の 1次元配列化
        node_tags, coords, _ = gmsh.model.mesh.getNodes()
        index = {t: i for i, t in enumerate(node_tags)}
        coords = coords.reshape(-1, 3)

        # gmsh.model.getEntities(3)で、全solidの次元数と番号を取得し、for-loop
        for dim, tag in gmsh.model.getEntities(3):
            surfs = [] # 表面番号を収集
            for _, s in gmsh.model.getBoundary([(dim, tag)], oriented=False):
                surfs.append( abs(s) )
                
            # 各表面の 3節点三角形を取出す
            nodes = []
            for s in surfs:
                _, node_tags = gmsh.model.mesh.getElementsByType(2, s)
                nodes.extend(node_tags)
            tris = np.array(nodes).reshape(-1, 3)

            # このsolidで使用する節点だけを取り、番号を 0 から振り直す
            used, inverse = np.unique(tris, return_inverse=True)
            verts = coords[[index[t] for t in used]]

            solid = (gmsh.model.getEntityName(dim, tag),
                     verts,
                     inverse.reshape(-1, 3) )
            ret_solids.append( solid )
    except Exception as e:
        print( e )
        
    gmsh.finalize()
    return ret_solids


def code_of(path_name: str) -> str:
    """名前から品番抽出 (stlのfile名に付けるため) """
    leaf = path_name.rsplit("/", 1)[-1]
    m = re.match(r"\(\d+\)\s+(\S+)", leaf)
    return m.group(1) if m else ""


def coacd_decompose(src: Path, out_dir: Path, threshold: float = 0.02) -> int:
    """当たり判定用に STL を凸分解し、2 つ以上なら out_dir に各STLを保存"""

    mesh = trimesh.load(src, force="mesh")
    coacd.set_log_level("error")  # 途中経過の表示を止める (エラーだけ出す)
    
    # 凸分解の本体。時間がかかるのはココ
    parts = coacd.run_coacd(coacd.Mesh(mesh.vertices, mesh.faces),
                            threshold=threshold)
    
    if out_dir.exists():
        for old in out_dir.glob("*.stl"):
            old.unlink()
        out_dir.rmdir()
    if len(parts) == 1:  # 分解されない場合
        return 1
    
    out_dir.mkdir(parents=True)
    for i, (v, f) in enumerate(parts):
        trimesh.Trimesh(v, f).export(out_dir / f"{src.stem}_c{i:03d}.stl")
    return len(parts)


def main():
    p = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("step",type=Path, help="変換元 STEP ファイル")
    p.add_argument("out", type=Path, help="出力先 STL フォルダ (なければ作成)")
    args = p.parse_args()
    step = args.step
    if not step.exists():
        p.error(f"STEP ファイルがありません: {step}")  # usageを表示し終了

    out = args.out
    out.mkdir(parents=True, exist_ok=True)
    
    solids = load_solids(step, MESH_SIZE)

    # 全部材の頂点から、中心座標を算出
    all_v = np.vstack([v for _, v, _ in solids])
    lo, hi = all_v.min(axis=0), all_v.max(axis=0)
    center = np.array([(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, 0.0])

    # 組立品のいちばん低い高さから区分の判定に使う
    ground_z = lo[2] * SCALE

    members = []
    for i, (path_name, verts, faces) in enumerate(solids):
        # 全solidの中心を原点にし、ワールドの向きに回して、縮小
        v = (verts - center) @ R_WORLD.T * SCALE
        vlo, vhi = v.min(axis=0), v.max(axis=0)  # この部材を囲む箱
        
        # 設置位置 (底面中心)
        pos = np.array([(vlo[0] + vhi[0]) / 2, (vlo[1] + vhi[1]) / 2, vlo[2]])
        size = vhi - vlo
        
        # 「土台」は、MuJoCoで固定部材として扱うため
        group = "土台" if vlo[2] - ground_z < GROUND_TOL else "その他"
        
        code = code_of(path_name)
        name = f"no{i:02d}_{code}" if code else f"no{i:02d}"

        # meshの原点を部材の底面中心に移す。
        # process=True で、重なった頂点をまとめる(整理をする)
        mesh = trimesh.Trimesh(v - pos, faces, process=True)
        # 三角形の表裏 (法線の向き) を外向きにそろえる
        #(向きがばらばらだと、体積や質量の計算がおかしくなる)
        trimesh.repair.fix_normals(mesh)
        # STLファイルとして保存
        mesh.export(out / f"{name}.stl")
        
        # 当たり判定用に 凸分解
        n_parts = coacd_decompose(out / f"{name}.stl", out / name)
        
        # MuJoCo 単位は mの為、mm → m に直して記録
        members.append(
            dict(name=name,
                 group=group,
                 pos=(pos / 1000).round(6).tolist(),  # 設置位置(底面中心)
                 size=(size / 1000).round(6).tolist(),# 寸法
                 long_axis="x" if size[0] >= size[1] else "y",  # 長手方向
                 parts=n_parts) )       # 当たり判定の凸の部品の数
        print(f"{name:20s} {group} 寸法 {size.round(1)} mm  凸パーツ {n_parts}")

    # 部材の一覧を JSON で書く。
    # ensure_ascii=False で日本語をそのまま書く (\uXXXX にしない)
    (out / "members.json").write_text(
        json.dumps(dict(source=str(step),
                        scale=SCALE,
                        members=members),
                   ensure_ascii=False,
                   indent=1),
        encoding="utf-8")
    print(f"{len(members)} 部材 → {out}")


if __name__ == "__main__":
    main()

再 - pywinauto for python で copilot デスクトップアプリへ入力後、生成回答を取得

pywinauto for python で copilot デスクトップアプリへ入力後、生成回答を取得 - end0tknr's kipple - web写経開発

先日、上記entryを記載しましたが、動作しないpcを発見。 どうやら、mscopilot.exe より新しい? m365copilot.exe が動作していることが 原因らしい。

そこで、m365copilot.exe 対応版として以下のように実装

# -*- coding: utf-8 -*-

import os
import sys
import time
import glob
import subprocess
import pyperclip
import traceback
from pywinauto import Desktop
from pywinauto.keyboard import send_keys

COPILOT_EXE_DIRS = [ r"C:\Program Files\WindowsApps"]
COPILOT_EXES = [ "m365copilot.exe"]

app_base_dir = os.path.dirname(os.path.abspath(__file__))

def main():
    # copilot window取得。なければ、copilot起動
    copilot_win = get_copilot_window()

    # 「新しいチャット」に切替え
    click_new_chat( copilot_win )
    copilot_win.set_focus()
    time.sleep( 1 )

    # 思考modeを切替え
    # set_mode(copilot_win, "Think Deeper")
    # copilot_win.set_focus()
    # time.sleep( 1 )

    # fileを添付
    attach_file_path = app_base_dir + "/question.txt"
    attach_file_path = os.path.abspath( attach_file_path )
    upload_attach_file( copilot_win, attach_file_path )
    copilot_win.set_focus()
    time.sleep( 5 )

    #input_txt = """東京都の人口は?"""
    input_txt = """添付ファイル内に記載された質問に答えてください"""
    print("INPUT TXT:", input_txt )
    input_txt_and_wait( copilot_win, input_txt )

    response = read_assistant_text( copilot_win )
    print("RESPONSE:", response )


def input_txt_and_wait( win, text ):
    edit = find_input_box( win )

    # 応答完了判定の為、送信前のcopyボタン数を記録
    baseline_copy = len( count_copy_buttons(win) )

    edit.click_input()
    
    # クリップボード経由でテキスト貼付け
    pyperclip.copy( text )
    time.sleep(0.5)
    send_keys("^v")
    time.sleep(0.5)
    send_keys("{ENTER}")

    return wait_for_response(win, baseline_copy)

        
def find_copilot_exe_path():
    for base in COPILOT_EXE_DIRS:
        for exe_name in COPILOT_EXES:
            path_hits = glob.glob( os.path.join(base,"**",exe_name),
                                   recursive=True )
        if path_hits:
            return path_hits[0]
    return None


def copilot_window_exists():
    """Copilotアプリのwindowが存在すれば True。"""
    try:
        spec = Desktop(backend="uia").window(
            title="Microsoft 365 Copilot",
            class_name="Microsoft 365 Copilot Host"
        )
        return spec.exists()
    except Exception:
        return False


def get_copilot_window():
    if not copilot_window_exists():
        launch_copilot()
        
    desk = Desktop(backend="uia")
    spec = desk.window(title="Microsoft 365 Copilot",
                       class_name="Microsoft 365 Copilot Host" )
    timeout = 15
    spec.wait("exists visible", timeout=timeout)
    win = spec.wrapper_object()
    win.set_focus()
    time.sleep( 2 )    # ChromiumのAccessibility(a11y)ツリー構築待ち
    #send_keys("{ESC}")  # 開いたままのメニュー/ポップオーバーを閉じる
    #time.sleep( 2 )
    return win


def launch_copilot():
    try:
        exe_path = find_copilot_exe_path()
        if exe_path:
            subprocess.Popen([exe_path])
        else:
            # win専用 python関数で、protocol/app起動
            os.startfile("ms-copilot:")

        time.sleep( 5 )
            
        timeout=40
        deadline = time.time() + timeout
        while time.time() < deadline:
            time.sleep(2.0)
            if copilot_window_exists():
                return True
            
    except Exception as e:
        print( e )
        print( traceback.format_exc() )
    return False


def find_input_box( win ):
    try:
        timeout=10
        deadline = time.time() + timeout
        while time.time() < deadline:
            for d in win.descendants():
                # print( d.element_info.automation_id,
                #        d.element_info.control_type )
                if d.element_info.automation_id == \
                   "m365-chat-editor-target-element" and \
                   d.element_info.control_type == "Edit":
                    return d
            time.sleep( 1 )
    except Exception as e:
        print( e )
        print( traceback.format_exc() )


def click_new_chat(win, timeout=10):
    """左ペインの「新しいチャット」をクリック """
    deadline = time.time() + timeout
    while time.time() < deadline:
        for d in win.descendants():
            # print( d.element_info.control_type, d.window_text() )
            
            try:
                if (d.element_info.control_type == "Hyperlink"
                        and d.window_text() == "新しいチャット"):
                    d.click_input()
                    time.sleep( 2 )  # 新規チャット画面の描画とa11y再構築を待つ
                    return True
            except Exception:
                continue
        time.sleep( 1 )
    return False


def set_mode(win, mode_name, timeout=10):
    """応答モードを切り替える。mode_name はメニュー項目名の先頭一致
    (例: 'Think Deeper', 'Smart')。"""
    deadline = time.time() + timeout
    # 1) 応答モードボタンを押してメニューを開く
    while time.time() < deadline:
        btn = None
        for d in win.descendants():
            try:
                if (d.element_info.control_type == "Button"
                        and d.window_text().startswith("応答モード")):
                    btn = d
                    break
            except Exception:
                continue
        if btn is not None:
            btn.click_input()
            time.sleep(1.0)
            break
        time.sleep(0.5)
    else:
        raise RuntimeError("応答モードボタンが見つかりません")

    # 2) 目的のモードの MenuItem をクリック
    deadline = time.time() + timeout
    while time.time() < deadline:
        for d in win.descendants():
            try:
                if (d.element_info.control_type == "MenuItem"
                        and d.window_text().startswith(mode_name)):
                    d.click_input()
                    print(f"応答モードを「{mode_name}」に切替え")
                    time.sleep(0.8)
                    return True
            except Exception:
                continue
        time.sleep(0.5)
    raise RuntimeError(f"モード「{mode_name}」のメニュー項目が見つかりません")


def count_copy_buttons(win):
    btns = []
    for d in win.descendants():
        # print( d.element_info.control_type,  d.window_text() )
        
        if d.element_info.control_type == "Button" and \
           "コピー" in d.window_text():
            btns.append(d)
    return btns


def read_assistant_text( win ):
    
    groups = []
    for d in win.descendants():
        if d.element_info.control_type == "Group" and \
           "Copilot said" in d.window_text():
            groups.append( d )
    if len(groups) == 0:
        return None
    
    parts = []
    for t in groups[-1].descendants():
        #print( t.element_info.control_type, t.window_text() )
        if t.element_info.control_type in ["Text","ListItem"]:
            res_txt = t.window_text().replace("Copilot said", "").strip()
            parts.append( res_txt )
    return "\n".join(parts)


def wait_for_response(win, baseline_copy_count, timeout=90):
    deadline = time.time() + timeout
    last_text = None
    stable = 0
    while time.time() < deadline:
        n = len( count_copy_buttons(win))
        text = read_assistant_text(win)
        
        # 応答のcopyボタン表示で、回答完了と判断
        if n > baseline_copy_count and text:
            if text == last_text:
                stable += 1
                if stable >= 2:  # 約2サイクル安定したら確定
                    return True
            else:
                stable = 0
        last_text = text
        time.sleep(1.0)
    return False


def find_copilot_ui_element( win, lambda_func ):
    try:
        for d in win.descendants():
            # print( d.element_info.automation_id,"&",
            #        d.element_info.control_type,"&",
            #        d.window_text() )
            
            if lambda_func( d ):
                return d
    except Exception as e:
        print( e )
        print( traceback.format_exc() )
        

def upload_attach_file(win, path, timeout=10):

    add_btn = find_copilot_ui_element(
        win,
        lambda d: d.element_info.control_type =="Image" and \
               d.window_text() == "ソースの追加と管理")
    print( add_btn )
    add_btn.click_input()
    time.sleep( 1 )
    send_keys("{DOWN}")
    send_keys("{RIGHT}")
    send_keys("{ENTER}")
    
    # common dialogの file名欄にpathを貼付け
    pyperclip.copy( path )
    send_keys("^v")
    send_keys("{ENTER}")

    # 添付tipの出現待ち
    fname = os.path.basename( path )
    deadline = time.time() + timeout
    while time.time() < deadline:
        for d in win.descendants():
            if d.element_info.control_type == "Text" and \
               d.window_text() == fname:
                return True
        time.sleep( 1 )
    return False


if __name__ == "__main__":
    sys.stdout.reconfigure(encoding="utf-8")
    main()

pywinauto for python で copilot デスクトップアプリへ入力後、生成回答を取得

# -*- coding: utf-8 -*-

import os
import sys
import time
import glob
import subprocess
import pyperclip
import traceback
from pywinauto import Desktop
from pywinauto.keyboard import send_keys

COPILOT_EXE_DIRS = [ r"C:\Program Files (x86)\Microsoft\Copilot",
                     r"C:\Program Files\Microsoft\Copilot" ]

app_base_dir = os.path.dirname(os.path.abspath(__file__))

def main():
    # copilot window取得。なければ、copilot起動
    copilot_win = get_copilot_window()

    # 「新しいチャット」に切替え
    click_new_chat( copilot_win )
    copilot_win.set_focus()
    time.sleep( 1 )

    # 思考modeを切替え
    # set_mode(copilot_win, "Think Deeper")
    # copilot_win.set_focus()
    # time.sleep( 1 )

    # fileを添付
    # attach_file_path = app_base_dir + "/question.txt"
    # attach_file_path = os.path.abspath( attach_file_path )
    # attach_file( copilot_win, attach_file_path )
    # copilot_win.set_focus()
    # time.sleep( 1 )

    input_txt = """東京都の人口は?
"""
    input_txt_and_wait( copilot_win, input_txt )

    response = get_response( copilot_win )
    print( response )


def input_txt_and_wait( win, text ):
    edit = find_input_box( win )

    # 応答完了判定の為、送信前のcopyボタン数を記録
    baseline_copy = len( count_copy_buttons(win) )

    edit.click_input()
    
    # クリップボード経由でテキスト貼付け
    pyperclip.copy( text )
    time.sleep(0.5)
    send_keys("^v")
    time.sleep(0.5)
    send_keys("{ENTER}")

    return wait_for_response(win, baseline_copy)

        
def find_copilot_exe_path():
    for base in COPILOT_EXE_DIRS:
        path_hits = glob.glob(os.path.join(base, "**", "mscopilot.exe"), recursive=True)
        if path_hits:
            return path_hits[0]
    return None


def copilot_window_exists():
    """Copilotアプリのwindowが存在すれば True。"""
    try:
        spec = Desktop(backend="uia").window( title="Copilot",
                                              class_name="Chrome_WidgetWin_1")
        return spec.exists()
    except Exception:
        return False


def get_copilot_window():
    if not copilot_window_exists():
        launch_copilot()
        
    desk = Desktop(backend="uia")
    spec = desk.window(title="Copilot", class_name="Chrome_WidgetWin_1")

    timeout = 15
    spec.wait("exists visible", timeout=timeout)
    win = spec.wrapper_object()
    win.set_focus()
    time.sleep( 2 )    # ChromiumのAccessibility(a11y)ツリー構築待ち
    #send_keys("{ESC}")  # 開いたままのメニュー/ポップオーバーを閉じる
    #time.sleep( 2 )
    return win


def launch_copilot():
    try:
        exe_path = find_copilot_exe_path()
        if exe_path:
            subprocess.Popen([exe_path])
        else:
            # win専用 python関数で、protocol/app起動
            os.startfile("ms-copilot:")

        timeout=40
        deadline = time.time() + timeout
        while time.time() < deadline:
            time.sleep(2.0)
            if copilot_window_exists():
                return True
            
    except Exception as e:
        print( e )
        print( traceback.format_exc() )
    return False


def find_input_box( win ):
    try:
        timeout=10
        deadline = time.time() + timeout
        while time.time() < deadline:
            for d in win.descendants():
                if d.element_info.automation_id == "userInput" and \
                   d.element_info.control_type == "Edit":
                    return d
            time.sleep( 1 )
    except Exception as e:
        print( e )
        print( traceback.format_exc() )


def click_new_chat(win, timeout=10):
    """左ペインの「新しいチャット」ボタンをクリックする。"""
    deadline = time.time() + timeout
    while time.time() < deadline:
        for d in win.descendants():
            try:
                if (d.element_info.control_type == "Button"
                        and d.window_text() == "新しいチャット"):
                    d.click_input()
                    print("「新しいチャット」をクリックしました。")
                    time.sleep(1.2)  # 新規チャット画面の描画とa11y再構築を待つ
                    return True
            except Exception:
                continue
        time.sleep(0.5)
    raise RuntimeError("「新しいチャット」ボタンが見つかりません")


def set_mode(win, mode_name, timeout=10):
    """応答モードを切り替える。mode_name はメニュー項目名の先頭一致
    (例: 'Think Deeper', 'Smart')。"""
    deadline = time.time() + timeout
    # 1) 応答モードボタンを押してメニューを開く
    while time.time() < deadline:
        btn = None
        for d in win.descendants():
            try:
                if (d.element_info.control_type == "Button"
                        and d.window_text().startswith("応答モード")):
                    btn = d
                    break
            except Exception:
                continue
        if btn is not None:
            btn.click_input()
            time.sleep(1.0)
            break
        time.sleep(0.5)
    else:
        raise RuntimeError("応答モードボタンが見つかりません")

    # 2) 目的のモードの MenuItem をクリック
    deadline = time.time() + timeout
    while time.time() < deadline:
        for d in win.descendants():
            try:
                if (d.element_info.control_type == "MenuItem"
                        and d.window_text().startswith(mode_name)):
                    d.click_input()
                    print(f"応答モードを「{mode_name}」に切り替えました。")
                    time.sleep(0.8)
                    return True
            except Exception:
                continue
        time.sleep(0.5)
    raise RuntimeError(f"モード「{mode_name}」のメニュー項目が見つかりません")


def count_copy_buttons(win):
    btns = []
    for d in win.descendants():
        if d.element_info.control_type == "Button" and \
           d.window_text() == "メッセージをコピー":
            btns.append(d)
    return btns


def read_assistant_text( win ):
    
    groups = []
    for d in win.descendants():
        if d.element_info.control_type == "Group" and \
           d.window_text() == "Copilot の発言":
            groups.append( d )

    if len(groups) == 0:
        return None
    
    parts = []
    for t in groups[-1].descendants():
        if t.element_info.control_type == "Text":
            parts.append(t.window_text())
            
    return "\n".join(parts)


def wait_for_response(win, baseline_copy_count, timeout=90):
    deadline = time.time() + timeout
    last_text = None
    stable = 0
    while time.time() < deadline:
        n = len( count_copy_buttons(win))
        text = read_assistant_text(win)
        
        # 応答のcopyボタンが出ていれば、回答完了
        if n >= baseline_copy_count + 2 and text:
            if text == last_text:
                stable += 1
                if stable >= 2:  # 約2サイクル安定したら確定
                    return True
            else:
                stable = 0
        last_text = text
        time.sleep(1.0)
    return False


def get_response( win ):
    """最新のCopilot応答本文を取得 """
    txt = read_assistant_text(win)
    if txt:
        # 先頭に付くグループ名ラベルを除去
        if txt.startswith("Copilot の発言"):
            txt = txt[len("Copilot の発言"):].lstrip("\n")
            
    return txt


def find_attach_file_button( win ):
    def find_ui_element( lambda_func ):
        try:
            for d in win.descendants():
                if lambda_func( d ):
                    return d
        except Exception as e:
            print( e )
            print( traceback.format_exc() )
        return None

    # menuを開く
    add_btn = None
    for _ in range(4):
        btn = find_ui_element(lambda d: d.element_info.automation_id == "composer-create-button")
        if not btn:
            time.sleep( 1 )
            continue
        btn.click_input()
        
        for _ in range(8):
            time.sleep( 1 )
            add_btn = find_ui_element(
                lambda d: d.element_info.control_type == "Button" \
                and d.window_text() == "画像またはファイルを追加" )
            
            if add_btn != None:
                break
        if add_btn:
            time.sleep( 1 )
            return add_btn
        

def attach_file(win, path, timeout=10):
    # file追加buttonをclick → common dialogにfocus移動
    add_button = find_attach_file_button( win )
    add_button.click_input()
    time.sleep( 2 )

    # common dialogの file名欄にpathを貼付け
    pyperclip.copy( path )
    time.sleep( 1 )
    send_keys("^v")
    time.sleep( 1 )
    send_keys("{ENTER}")

    # 添付tipの出現待ち
    fname = os.path.basename( path )
    deadline = time.time() + timeout
    while time.time() < deadline:
        for d in win.descendants():
            if d.element_info.control_type == "Text" and \
               d.window_text() == fname:
                return True
        time.sleep( 1 )
    return False


if __name__ == "__main__":
    sys.stdout.reconfigure(encoding="utf-8")
    main()

paddleocr for python による ai ocr

GitHub - PaddlePaddle/PaddleOCR: Turn any PDF or image document into structured data for your AI. A powerful, lightweight OCR toolkit that bridges the gap between images/PDFs and LLMs. Supports 100+ languages. · GitHub

MinerU、chandra に続く、ai ocr。

使いやすさや精度は、paddleocrが最も良い気がします

目次

pip install

OCRライブラリ本体(paddleocr)と、推論エンジン(paddlepaddle-gpu)をinstall

CMD> python3.12\python.exe -m pip install paddleocr
CMD> python3.12\python.exe -m pip install paddlepaddle-gpu==3.3.1 -i https://www.paddlepaddle.org.cn/packages/stable/cu118/
CMD> python3.12\python.exe -m pip install "paddlex[ocr]==3.7.2"

gpuの接続確認

import paddle
paddle.utils.run_check()               # "PaddlePaddle works well on N GPUs" と出ればOK
print(paddle.device.get_device())      # "gpu:0" のように表示される
print(paddle.is_compiled_with_cuda())  # True

↑こうかくと、↓こう表示

C:\Users\end0t\tmp\PADDLE_OCR>python3.12\python.exe chk_gpu.py
Running verify PaddlePaddle program ...
C:\Users\end0t\tmp\PADDLE_OCR\python3.12\Lib\site-packages\paddle\pir\math_op_patch.py:241:
UserWarning: Tensor do not have 'place' interface for pir graph mode, try not to use it.
None will be returned.

  warnings.warn(
I0829 15:04:05.538084 27788 pir_interpreter.cc:1529] New Executor is Running ...
WARNING: Logging before InitGoogleLogging() is written to STDERR
W0829 15:04:05.538084 27788 gpu_resources.cc:116] Please NOTE: device: 0,
  GPU Compute Capability: 8.9, Driver API Version: 13.0, Runtime API Version: 11.8
I0829 15:04:05.768106 27788 pir_interpreter.cc:1552]
  pir interpreter is running by multi-thread mode ...
  
PaddlePaddle works well on 1 GPU.
PaddlePaddle is installed successfully! Let's start deep learning with PaddlePaddle now.
gpu:0  ★
True   ★

PaddleOCR - text抽出のみ

import sys
import time
from paddleocr import PaddleOCR

# Windowsコンソール(cp932)の文字化け対策:標準出力をUTF-8にする
sys.stdout.reconfigure(encoding="utf-8")

# device="gpu:0" でGPU使用(PaddleOCR 3.x系)
ocr = PaddleOCR(lang="japan", device="gpu:0")

img = "sample.png"   # ← 手元の画像パスに変更

t0 = time.time()
result = ocr.predict(img)      # 3.x は predict()
print(f"処理時間: {time.time()-t0:.2f}秒")

# 結果表示(3.x の戻り値形式)
for res in result:
    # テキストとスコアだけを見やすく表示
    for text, score in zip(res["rec_texts"], res["rec_scores"]):
        print(f"{score:.2f}  {text}")

    # 可視化画像は「フォルダ」を指定(複数画像が出力される)
    res.save_to_img("output")
    # 認識結果はJSON(UTF-8)で保存
    res.save_to_json("output")

PPStructureV3 - 文章・図・表の混在文書

from paddleocr import PPStructureV3

pipeline = PPStructureV3(
    lang="japan",
    device="gpu:0",
    use_formula_recognition=False,       # 数式が無い文書なら切る
    use_table_recognition=True,          # 表は使う
    use_doc_orientation_classify=False,  # 傾き分類(不要なら切って高速化)
    use_doc_unwarping=False,             # 歪み補正(不要なら切って高速化)
)

results = pipeline.predict("sample.png")
for res in results:
    res.save_to_markdown("output_structure")  # 表はHTML、図は画像で埋込
    res.save_to_img("output_structure")       # レイアウト枠の可視化
    res.save_to_json("output_structure")      # 全構造データ

PPStructureV3が内部で使うmodel

predict()の初回実行時に以下が自動的にダウンロード

役割 モデル
レイアウト検出 PP-DocLayout_plus-L
テキスト検出/認識 PP-OCRv5_server_det / _rec
表の有線/無線判定 PP-LCNet_x1_0_table_cls
表構造復元 SLANeXt_wired / SLANet_plus
表セル検出 RT-DETR-L_wired/wireless_table_cell_det
数式認識 PP-FormulaNet_plus-L

上記により出力される結果file群

ファイル 内容
sample.md 本命の成果物。表は html table、図はimgs/へ切り出してで埋込
imgs/ 図・表の切り出し画像
sample_layout_det_res.png 領域分割の可視化(先ほど送った画像)
sample_layout_order_res.png 読み取り順序(reading order)
sample_table_cell_img.png 表セル検出の可視化
sample_res.json 全領域の種別・座標・テキスト・表HTML

表のhtmlのみ出力する場合

for res in results:
    for block in res["parsing_res_list"]:
        if block["block_label"] == "table":
            print(block["block_content"])   # 表のHTML

markdownのみ出力する場合

from paddleocr import PPStructureV3
from pathlib import Path
import glob

def main():
    # https://www.paddleocr.ai/main/en/version3.x/pipeline_usage/PP-StructureV3.html
    pipeline = PPStructureV3(
        lang  ="japan",
        device="gpu:0",
        use_formula_recognition     =False,     # 数式
        use_table_recognition       =True,      # 表
        use_doc_orientation_classify=False,     # 傾き分類
        use_doc_unwarping           =False,     # 歪み補正
        # 解析結果に含まれるblock内容を、markdownなどへ出力しやすい様、整形
        format_block_content=True,

        # markdownから除外する要素
        markdown_ignore_labels=[
            "image","figure","header_image","footer_image"],
    )

    Path("output_md").mkdir(exist_ok=True)

    for i, png_path in enumerate(glob.glob("input_trim_png/*.png")):

        basename = Path(png_path).stem
        out_path = Path("output_md") / f"{basename}.md"
        
        print(i, png_path, out_path)
        
        results = pipeline.predict(png_path) # layout解析実行
        markdown_list = []

        for res in results:
            # PPStructureV3が生成したMarkdownを取得
            md_info = res.markdown
            markdown_text = md_info.get("markdown_texts", "")

            markdown_list.append(markdown_text)

        out_path.write_text( "\n\n".join(markdown_list),
                             encoding="utf-8" )


if __name__ == "__main__":
    main()

PaddleOCRVL - 文章・図・表の混在文書 (その2)

PaddleOCRVLの方が高精度版らしいが、私はPPStructureV3の方が高精度に感じました

import sys
import time
from paddleocr import PaddleOCRVL

# Windowsコンソール(cp932)の文字化け対策
sys.stdout.reconfigure(encoding="utf-8")

# VLM(Vision-Languageモデル)ベースの文書パーサ
#   レイアウト検出 + PaddleOCR-VL による認識で、図表・数式込みのMarkdownを生成
pipeline = PaddleOCRVL()

img = "sample.png"

t0 = time.time()
results = pipeline.predict(img)
print(f"処理時間: {time.time()-t0:.2f}秒")

for res in results:
    res.save_to_markdown("output_vl")  # 図表・数式込みのMarkdown
    res.save_to_json("output_vl")      # 構造データ

windows os付属のocrを winsdk for pythonから利用

  • windows os付属のocrなので、お手軽。しかも処理が速い
  • yahooトップを画面キャプチャし、テスト
  • 画像サイズを拡大すると、精度UPしそう

pip install

CMD> pip install winsdk

python script

import sys
import os
import asyncio

from winsdk.windows.media.ocr        import OcrEngine
from winsdk.windows.globalization    import Language
from winsdk.windows.storage          import StorageFile
from winsdk.windows.graphics.imaging import BitmapDecoder


def main():
    img_path = sys.argv[1]
    asyncio.run( ocr_image( img_path ) )


async def ocr_image( image_path ):

    # Windowsのfile obj取得
    image_file = await StorageFile.get_file_from_path_async(
        os.path.abspath( image_path ) )

    # 画像ファイルを開く
    stream = await image_file.open_async(0)

    # 画像をSoftwareBitmapへ変換
    decoder = await BitmapDecoder.create_async(stream)
    bitmap = await decoder.get_software_bitmap_async()

    # 日本語ocr実行
    engine = OcrEngine.try_create_from_language( Language("ja-JP") )
    result = await engine.recognize_async(bitmap)

    print("=== OCR結果 ===")
    print(result.text)

    print("=== 行・単語・座標 ===")

    for line in result.lines:
        print(f"\nLINE: {line.text}")

        for word in line.words:
            rect = word.bounding_rect
            print(
                f"WORD: {word.text} "
                f"x={rect.x:.1f} "
                f"y={rect.y:.1f} "
                f"w={rect.width:.1f} "
                f"h={rect.height:.1f}"
            )

    stream.close()
    

if __name__ == "__main__":
    main()

実行結果

C:\Users\end0t\tmp\WIN_OCR>..\..\python3.12\python.exe win_ocr_2.py yahoo_2.png
=== OCR結果 ===
0 も し 明 日 か ら 働 か な く て い い な ら 、 何 す る ? 0 プ レ ミ ア ム オ ー ク シ ョ ン シ ョ ッ ピ ン ク ウ エ プ AI モ - ド 画 像 0 ( JAPI 動 画 知 恵 袋 地 図 リ ア ル タ イ 北 陸 で 大 雨 気 象 情 報 や 交 通 影 響 な ど 》 い つ も な ん だ か 変 な ポ - ス を し て い る 、 ビ ッ ク ア ッ プ 直 ー ト ラ ベ ル お ト ク な セ - ル 開 催 中 ・ 、 オ - ク シ ョ ン 週 末 限 定 ク - ホ ン 配 布 ッ ピ ン グ 経 済 工 ン タ メ ス ポ - ツ 国 内 国 際 8 / 29 ( 土 ) 14 : 10 更 新 北 陸 な ど 警 報 級 大 雨 恐 れ 災 害 警 戒 ・ 円 が 下 落 介 人 効 果 4 週 問 で 陰 り 海 で 不 明 の 中 高 生 か 2 人 死 亡 確 認 ・ 長 女 が 重 体 傷 害 の 疑 い で 両 親 逮 捕 「 皇 居 ラ ン 」 に 舌 情 区 の 担 当 者 は ・ 大 河 ド ラ マ 巡 り 2 市 が NHK に 文 書 主 要 66 747 68 855 D2311 255 IT 科 ワ イ ) 8 / 29 は 宮 崎 日
=== 行・単語・座標 ===

LINE: 0 も し 明 日 か ら 働 か な く て い い な ら 、 何 す る ?
WORD: 0 x=15.0 y=7.0 w=21.0 h=19.0
WORD: も x=46.0 y=6.0 w=16.0 h=15.0
WORD: し x=67.0 y=7.0 w=15.0 h=14.0
WORD: 明 x=84.0 y=6.0 w=16.0 h=16.0
WORD: 日 x=104.0 y=6.0 w=15.0 h=16.0
WORD: か x=122.0 y=6.0 w=18.0 h=15.0
WORD: ら x=143.0 y=6.0 w=15.0 h=15.0
WORD: 働 x=160.0 y=6.0 w=18.0 h=16.0
WORD: か x=180.0 y=6.0 w=18.0 h=15.0
WORD: な x=199.0 y=6.0 w=18.0 h=16.0
WORD: く x=221.0 y=6.0 w=12.0 h=16.0
WORD: て x=238.0 y=8.0 w=17.0 h=13.0
WORD: い x=257.0 y=8.0 w=17.0 h=13.0
WORD: い x=277.0 y=8.0 w=17.0 h=13.0
WORD: な x=295.0 y=6.0 w=18.0 h=16.0
WORD: ら x=316.0 y=6.0 w=15.0 h=15.0
WORD: 、 x=334.0 y=17.0 w=5.0 h=5.0
WORD: 何 x=352.0 y=5.0 w=19.0 h=17.0
WORD: す x=373.0 y=6.0 w=17.0 h=16.0
WORD: る x=392.0 y=7.0 w=16.0 h=15.0
WORD: ? x=415.0 y=7.0 w=9.0 h=14.0

LINE: 0
WORD: 0 x=165.0 y=61.0 w=55.0 h=55.0

LINE: プ レ ミ ア ム
WORD: プ x=141.0 y=123.0 w=20.0 h=20.0
WORD: レ x=164.0 y=126.0 w=17.0 h=17.0
WORD: ミ x=184.0 y=126.0 w=17.0 h=17.0
WORD: ア x=204.0 y=126.0 w=20.0 h=17.0
WORD: ム x=225.0 y=126.0 w=19.0 h=16.0

LINE: オ ー ク シ ョ ン シ ョ ッ ピ ン ク
WORD: オ x=290.0 y=125.0 w=20.0 h=18.0
WORD: ー x=312.0 y=134.0 w=18.0 h=2.0
WORD: ク x=332.0 y=126.0 w=19.0 h=17.0
WORD: シ x=354.0 y=126.0 w=19.0 h=17.0
WORD: ョ x=376.0 y=130.0 w=14.0 h=12.0
WORD: ン x=397.0 y=126.0 w=17.0 h=17.0
WORD: シ x=451.0 y=126.0 w=18.0 h=17.0
WORD: ョ x=473.0 y=130.0 w=14.0 h=12.0
WORD: ッ x=494.0 y=129.0 w=15.0 h=14.0
WORD: ピ x=515.0 y=123.0 w=17.0 h=20.0
WORD: ン x=535.0 y=126.0 w=18.0 h=17.0
WORD: ク x=554.0 y=125.0 w=21.0 h=18.0

LINE: ウ エ プ AI モ - ド 画 像
WORD: ウ x=392.0 y=188.0 w=20.0 h=20.0
WORD: エ x=415.0 y=193.0 w=19.0 h=15.0
WORD: プ x=436.0 y=187.0 w=23.0 h=21.0
WORD: AI x=480.0 y=190.0 w=24.0 h=17.0
WORD: モ x=506.0 y=190.0 w=21.0 h=17.0
WORD: - x=529.0 y=198.0 w=19.0 h=2.0
WORD: ド x=556.0 y=188.0 w=15.0 h=20.0
WORD: 画 x=594.0 y=189.0 w=22.0 h=19.0
WORD: 像 x=617.0 y=188.0 w=22.0 h=20.0

LINE: 0 (
WORD: 0 x=888.0 y=71.0 w=65.0 h=44.0
WORD: ( x=956.0 y=64.0 w=31.0 h=47.0

LINE: JAPI
WORD: JAPI x=897.0 y=120.0 w=90.0 h=35.0

LINE: 動 画 知 恵 袋 地 図 リ ア ル タ イ
WORD: 動 x=661.0 y=188.0 w=21.0 h=20.0
WORD: 画 x=684.0 y=189.0 w=21.0 h=19.0
WORD: 知 x=727.0 y=188.0 w=21.0 h=21.0
WORD: 恵 x=750.0 y=187.0 w=22.0 h=21.0
WORD: 袋 x=773.0 y=188.0 w=22.0 h=20.0
WORD: 地 x=816.0 y=188.0 w=22.0 h=20.0
WORD: 図 x=840.0 y=189.0 w=21.0 h=19.0
WORD: リ x=886.0 y=189.0 w=16.0 h=18.0
WORD: ア x=907.0 y=190.0 w=21.0 h=18.0
WORD: ル x=928.0 y=189.0 w=23.0 h=19.0
WORD: タ x=952.0 y=189.0 w=20.0 h=19.0
WORD: イ x=975.0 y=194.0 w=12.0 h=14.0

<略>

Document AIの chandra for python & docker で png画像を markdown化

先日のentry群に続き、Datalab社の chandraのお試し。

処理が重いことが示しているのでしょうか、 これまでに試した、yomitoku、mineruより精度がグッと向上した気がします

環境 - windows & wsl & docker

chandraの modelである datalab-to/chandra-ocr-2 は、huggingface(hf) または、vllm で動作しますが、hf でのocr処理が、余りに遅かった為、 wslや dockerも用意。

尚、wsl内にあるosは、OracleLinux_9_5で、このwslからeGPUである geforce rtc4090へ接続

C:\Users\end0t\tmp\CHANDRA>python3.12\Scripts\chandra.exe --help
Usage: chandra [OPTIONS] INPUT_PATH OUTPUT_PATH

Options:
  --method [hf|vllm]              Inference method: 'hf' for local model,
                                  'vllm' for vLLM server.
  --page-range TEXT               Page range for PDFs (e.g., '1-5,7,9-12').
                                  Only applicable to PDF files.
  --max-output-tokens INTEGER     Maximum number of output tokens per page.
  --max-workers INTEGER           Maximum number of parallel workers for vLLM
                                  inference.
  --max-retries INTEGER           Maximum number of retries for vLLM
                                  inference.
  --include-images / --no-images  Include images in output.
  --include-headers-footers / --no-headers-footers
                                  Include page headers and footers in output.
  --save-html / --no-html         Save HTML output files.
  --batch-size INTEGER            Number of pages to process in a batch.
  --paginate_output
  --help                          Show this message and exit.
C:\Users\end0t\tmp\CHANDRA>wsl --list
Linux 用 Windows サブシステム ディストリビューション:
OracleLinux_9_5 (既定値)

C:\Users\end0t\tmp\CHANDRA>wsl

[end0tknr@a64 CHANDRA]$ cat /etc/redhat-release
Red Hat Enterprise Linux release 9.5 (Plow)

install docker to wsl

docker for windowsも実は、wslに依存しているようですので、 wslへ インストール

sudo dnf -y install dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
sudo dnf -y install docker-ce docker-ce-cli containerd.io
sudo systemctl enable --now docker

install NVIDIA Container Toolkit to wsl

curl -s -L https://nvidia.github.io/libnvidia-container/stable/rpm/nvidia-container-toolkit.repo | sudo tee /etc/yum.repos.d/nvidia-container-toolkit.repo
sudo dnf -y install nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker

install vllm to wsl docker

vLLM イメージのコンテナを一時的に起動し、その中で nvidia-smi だけを実行して、 コンテナからGPUが見えるか確認。

※この実行時に Docker イメージ vllm/vllm-openai:v0.17.0がダウンロードされますが、 「--rm」オプションがある為、終了後、削除されます

※ vllm/vllm-openai:v0.27.1 のように新しいdocker imageもありますが、 chandra側が vllm/vllm-openai:v0.17.0 を指定している為、これを利用

[end0tknr@a64 end0t]$ sudo docker run --rm --runtime=nvidia --gpus all --entrypoint nvidia-smi vllm/vllm-openai:v0.17.0

+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 580.102.01             Driver Version: 581.57         CUDA Version: 13.0     |
+-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|                                         |                        |               MIG M. |
|=========================================+========================+======================|
|   0  NVIDIA GeForce RTX 4090        On  |   00000000:22:00.0 Off |                  Off |
|  0%   47C    P8             28W /  450W |       0MiB /  24564MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes:                                                                              |
|  GPU   GI   CI              PID   Type   Process name                        GPU Memory |
|        ID   ID                                                               Usage      |
|=========================================================================================|
|  No running processes found                                                             |
+-----------------------------------------------------------------------------------------+
引数 説明
--rm container終了時にcontainerを自動削除
--runtime=nvidia NVIDIA Container Toolkitをruntimeに使用
--gpus all hostの全GPUをcontainerに開示。1枚なら --gpus device=0 も可
--entrypoint nvidia-smi 元々のvllm serveでなく nvidia-smi を起動に利用
vllm/vllm-openai:v0.17.0 使うイメージ名とタグ(バージョン)

vLLM サーバ起動

初回起動時、 datalab-to/chandra-ocr-2 が huggingfaceから ダウンロードされます

sudo docker run --runtime nvidia --gpus device=0 \
  -v ~/.cache/huggingface:/root/.cache/huggingface \
  -p 8000:8000 --ipc=host \
  vllm/vllm-openai:v0.17.0 \
  --model datalab-to/chandra-ocr-2 \
  --no-enforce-eager \
  --max-num-seqs 16 \
  --dtype bfloat16 \
  --max-model-len 18000 \
  --max_num_batched_tokens 18000 \
  --gpu-memory-utilization .85 \
  --served-model-name chandra
引数 説明
--gpus device=0 nvidia-smiで表示された No.0のRTX4090をコンテナに割当て
-v ~/.cache/huggingface:/root/.cache/huggingface volume mount。host(WSL)の HuggingFace cacheを containerの /root/.cache/huggingface に共有
-p 8000:8000 port公開。左=ホスト側、右=コンテナ側
--ipc=host containerがhostのIPC空間(共有mem)使用。docker既定(64MB)ではpytorchに不足する為
--model datalab-to/chandra-ocr-2 Chandraのocr model本体。HuggingFaceの repo id
--no-enforce-eager CUDA graph / torch.compile 有効化。推論を高速化
--max-num-seqs 16 並列度の上限
--dtype bfloat16 modelの計算精度
--max-model-len 18000 1 reqで扱える最大token長
--max_num_batched_tokens 18000 1stepで処理するtokenの上限。max-model-lenと同値
--gpu-memory-utilization .85 GPU memの 85% をvLLMが使用。重み+KV cache用
--served-model-name chandra API公開するmodel名。VLLM_MODEL_NAME=chandraと一致

pip install chandra-ocr[hf]

python3.12\python.exe -m pip install chandra-ocr[hf]

python3.12\python.exe -m pip uninstall -y torch torchvision
python3.12\python.exe -m pip install torch torchvision --index-url https://download.pytorch.org/whl/cu124

python3.12\python.exe -c "import torch; print(torch.cuda.is_available(), torch.version.cuda, torch.cuda.get_device_name(0))"

config chandra

vi C:\Users\end0t\tmp\CHANDRA\python3.12\Lib\site-packages\chandra\settings.py

172.18.129.236とは、wsl側のip

from dotenv import find_dotenv
from pydantic_settings import BaseSettings
import os


class Settings(BaseSettings):
    # Paths
    BASE_DIR: str = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    IMAGE_DPI: int = 192
    MIN_PDF_IMAGE_DIM: int = 1024
    MIN_IMAGE_DIM: int = 1536
    MODEL_CHECKPOINT: str = "datalab-to/chandra-ocr-2"
    TORCH_DEVICE: str | None = None
    MAX_OUTPUT_TOKENS: int = 12384
    TORCH_ATTN: str | None = None
    BBOX_SCALE: int = 1000

    # vLLM server settings
    VLLM_API_KEY: str = "EMPTY"
    VLLM_API_BASE: str = "http://172.18.129.236:8000/v1"
    #VLLM_API_BASE: str = "http://localhost:8000/v1"
    VLLM_MODEL_NAME: str = "chandra"
    VLLM_GPUS: str = "0"
    MAX_VLLM_RETRIES: int = 6

    class Config:
        env_file = find_dotenv("local.env")
        extra = "ignore"


settings = Settings()

chandra実行

C:\Users\end0t\tmp\CHANDRA>python3.12\Scripts\chandra.exe ./input ./output --method vllm --include-images --no-html
Chandra CLI - Starting OCR processing
Input: input
Output: output
Method: vllm

Loading model with method 'vllm'...
Model loaded successfully.

Found 1 file(s) to process.

[1/1] Processing: 26A001-02-000_240.png
  Loaded 1 page(s)
  Processing pages 1-1...
  Saved: output\26A001-02-000_240\26A001-02-000_240.md (1 page(s))
  Completed: 26A001-02-000_240.png

Processing complete. Results saved to: output

RAG-Anything + python for win + ollama + 日本語

LightRAG を 開発した Data Intelligence Lab @ HKU による マルチモーダルRAG。

LightRAG の入力がテキストのみでしたが、 RAG-Anythingは、MinerU により、様々なデータを text化し、入力に利用。

LightRAGを内包しており、インデックス作成後、 LightRAGのweb uiを用い、検索できますが、 MinerUによるテキスト化がいまいちで、 また、インデックス化処理が、よくフリーズしましたので、 今後は、LightRAGを使用すると思います。

Document AIの MinerU for python で tiff画像を markdown化

Document AI yomitoku for python で png画像を html化 - end0tknr's kipple - web写経開発

以前、上記entryにある yomitoku を試しましたが、 似たライブラリである opendatalab/MinerU を見つけたので、お試し。

GitHub - opendatalab/MinerU: Transforms complex documents like PDFs and Office docs into LLM-ready markdown/JSON for your Agentic workflows. · GitHub

MinerU の README.md を読むと、MinerUの 多機能さに魅力を感じますが、 yomitokuの markdown化ファイルの方が、文字をより、抽出し、まとまっていましたので、 今後は、yomitokuを利用すると思います。

特徴

  • pdf, 画像, office(docx, pptx, xlsx ※1) を入力データとして対応
  • ヘッダー、フッター、脚注、ページ番号を削除
  • 画像、画像の説明、表、数式、脚注を抽出
  • スキャンされたpdfも ocr可能(109言語)

※1 別途、libreoffice要

install

CMD> python3.12\python.exe -m pip install torch torchvision --index-url https://download.pytorch.org/whl/cu124

CMD> python3.12\python.exe -m pip install "mineru[all]"

使用方法 - mineru.exe --help

C:\Users\end0t\tmp\MINERU>python3.12\Scripts\mineru.exe --help
Usage: mineru [OPTIONS]

Options:
  -v, --version                   display the version and exit
  -p, --path PATH                 local filepath or directory. support pdf,
                                  image, docx, pptx, xlsx files  [required]
  -o, --output PATH               output local directory  [required]
  --api-url TEXT                  MinerU FastAPI base URL. If omitted, mineru
                                  starts a temporary local mineru-api service.
  -m, --method [auto|txt|ocr]
     the method for parsing pdf:
       auto: Automatically determine the method based on the file type.
       txt : Use text extraction method.
       ocr : Use OCR method for image-based PDFs.
     Without method specified, 'auto' will be used by default.
     Adapted only for the case where the backend is set to 'pipeline' and 'hybrid-*'.
  -b, --backend [pipeline|vlm-engine|hybrid-engine|vlm-http-client|hybrid-http-client]
     the backend for parsing pdf:
       pipeline       : More general.
       vlm-engine     : High accuracy via local computing power.
       vlm-http-client: High accuracy via remote computing power
                       (client suitable for openai-compatible servers).
       hybrid-engine  : Next-generation high accuracy solution
                        via local computing power.
       hybrid-http-client:
                        High accuracy but requires a little local computing power
                        (client suitable for openai-compatible servers).
       Without backend specified, hybrid-engine will be used by default.
  --effort [medium|high]
      Hybrid parsing effort:
        medium: Faster parsing for most documents,
                balancing accuracy and efficiency.
                Image/chart analysis is disabled.
        high:   Higher-accuracy parsing with image/chart analysis support,
                which may take longer.
      Without effort specified, medium will be used by default.
      Adapted only for the case where the backend is set to 'hybrid-*'.
  -l, --lang [ch|ch_server|korean|ta|te|ka|th|el|arabic|east_slavic|cyrillic|devanagari]
      Input the languages in the pdf (if known) to
      improve OCR accuracy. Without languages
      specified, 'ch' will be used by default.
      Specify document language (improves OCR
      accuracy, pipeline backend only).
  -u, --url TEXT                  When the backend is `<vlm/hybrid>-http-
                                  client`, you need to specify the server_url,
                                  for example:`http://127.0.0.1:30000`
  -s, --start INTEGER             The starting page for PDF parsing, beginning
                                  from 0.
  -e, --end INTEGER               The ending page for PDF parsing, beginning
                                  from 0.
  -f, --formula BOOLEAN           Enable formula parsing. Default is True.
  -t, --table BOOLEAN             Enable table parsing. Default is True.
  --image-analysis BOOLEAN        Enable image/chart analysis for VLM and
                                  hybrid backends. Hybrid medium effort
                                  automatically disables image/chart analysis.
                                  Default is True.
  --client-side-output-generation BOOLEAN
                                  Generate markdown and content lists locally
                                  from server-returned middle json, images,
                                  and original files.
  --help                          Show this message and exit.

実行

CMD> python3.12\Scripts\mineru.exe -p .\tiff -o .\out1 -b pipeline

または

CMD> python3.12\Scripts\mineru.exe -p .\tiff -o .\out3 -b hybrid-engine --effort high --image-analysis=True --formula=True --table=True

実行結果ファイル群

file note
~.md 見出し、本文、表(HTML/Markdown表)、数式(LaTeX)
~_content_list.json 文書をブロック単位で並べたもの
~_content_list_v2.json 〃
~_middle.json 解析の中間ファイル
~_model.json layout,ocr,表,数式など、各modelの推論結果
~_origin.pdf 元ファイル
~_layout.pdf レイアウト
images/ 抽出した画像の保存用dir

lightrag + python for win + ollama + 日本語

graphrag + python for win + ollama + 日本語 - end0tknr's kipple - web写経開発

先日の上記entryに記載した graphragは、インデックス作成や検索が遅かった為、 lightragを構築し、試してみました。

尚、結果は以下のような印象でした

  • lightragと言うが、「サクサク」と言う程、軽くはない
  • web ui や apiがある為、扱いやすい
  • 検索精度は悪くはない

目次

install & config

CMD> ..\..\python3.12\python.exe -m pip install "lightrag-hku[api]"
CMD> vi .env

# -- LLM (Ollama)
LLM_BINDING=ollama
# thinkingモデルでは遅い為、やや古い qwen2.5:14b-instruct を使用
LLM_MODEL=qwen2.5:14b-instruct
LLM_BINDING_HOST=http://localhost:11434

# -- Embedding (Ollama)
EMBEDDING_BINDING=ollama
EMBEDDING_MODEL=bge-m3:latest
EMBEDDING_BINDING_HOST=http://localhost:11434
# bge-m3 の次元数
EMBEDDING_DIM=1024

# -- 入出力
INPUT_DIR=./input
WORKING_DIR=./rag_storage
SUMMARY_LANGUAGE=Japanese

# -- Ollama の既定 num_ctx は 2048 で少ない為
OLLAMA_NUM_CTX=32768
MAX_ASYNC=1
TIMEOUT=600

# -- クエリ高速化: context量を抑える
TOP_K=20
CHUNK_TOP_K=10
MAX_TOTAL_TOKENS=16000

サーバ起動

C:\Users\end0t\tmp\LightRag> ..\..\python3.12\Scripts\lightrag-server.exe

すると、以下

C:\Users\end0t\tmp\LightRag> ..\..\python3.12\Scripts\lightrag-server.exe
WARNING: TOKEN_SECRET not set and AUTH_ACCOUNTS is not configured.
  Falling back to the default guest-mode JWT secret.

LightRAG log file: C:\Users\end0t\tmp\LightRag\lightrag.log
    ╔══════════════════════════════════════════════════════════════╗
    ║                 LightRAG Server v1.5.6/0328                  ║
    ║         Fast, Lightweight RAG Server Implementation          ║
    ╚══════════════════════════════════════════════════════════════╝
📡 Server Configuration:
    ├─ Host: 0.0.0.0
    ├─ Port: 9621
    ├─ Workers: 1
    ├─ Timeout: 300
    ├─ CORS Origins: *
    ├─ SSL Enabled: False
    ├─ Ollama Emulating Model: lightrag:latest
    ├─ Log Level: INFO
    ├─ Verbose Debug: False
    ├─ API Key: Not Set
    ├─ JWT Auth: Disabled
    ├─ Max Request Body: 1048576 bytes
    ├─ Max Upload Size: 104857600 bytes
    └─ Max Pending Documents: Unlimited
📂 Directory Configuration:
    ├─ Working Directory: C:\Users\end0t\tmp\LightRag\rag_storage
    └─ Input Directory: C:\Users\end0t\tmp\LightRag\input
📊 Embedding Configuration:
    ├─ Binding: ollama
    ├─ Host: http://localhost:11434
    ├─ Model: bge-m3:latest
    ├─ Dimensions: 1024
    └─ Asymmetric: False
⚙️ RAG Configuration:
    ├─ Summary Language: Japanese
    ├─ Max Parallel Insert: 3
    ├─ Chunk Size: 1200
    ├─ Chunk Overlap Size: 100
    ├─ Cosine Threshold: 0.2
    ├─ Top-K: 40
    └─ Force LLM Summary on Merge: 8
💾 Storage Configuration:
    ├─ KV Storage: JsonKVStorage
    ├─ Vector Storage: NanoVectorDBStorage
    ├─ Graph Storage: NetworkXStorage
    ├─ Document Status Storage: JsonDocStatusStorage
    └─ Workspace: -
✨ Server starting up...

🌐 Server Access Information:
    ├─ WebUI (local): http://localhost:9621
    ├─ Remote Access: http://<your-ip-address>:9621
    ├─ API Documentation (local): http://localhost:9621/docs
    └─ Alternative Documentation (local): http://localhost:9621/redoc
📝 Note:
    Since the server is running on 0.0.0.0:
    - Use 'localhost' or '127.0.0.1' for local access
    - Use your machine's IP address for remote access
    - To find your IP address:
      • Windows: Run 'ipconfig' in terminal
      • Linux/Mac: Run 'ifconfig' or 'ip addr' in terminal
🔴 SECURITY ALERT:
    The server is listening on '0.0.0.0' WITHOUT any authentication.
    Every endpoint (document upload, query, knowledge graph, deletion) is
    publicly accessible to anyone who can reach this address.

    Secure the server before exposing it to a network by setting at least one of:
      - LIGHTRAG_API_KEY=<a-strong-secret>   (X-API-Key header authentication)
      - AUTH_ACCOUNTS=user:password together with TOKEN_SECRET=<a-strong-secret>
                                        (JWT login authentication; AUTH_ACCOUNTS
                                         without TOKEN_SECRET fails to start)
    Or restrict access by binding to loopback only: HOST=127.0.0.1

INFO: Ollama LLM Options: {}
INFO: Ollama Embedding Options: {}
INFO: Embedding config: binding=ollama model=bge-m3:latest embedding_dim=1024 max_token_size=8192
INFO: Send embedding dimension: False by env var (dimensions=1024, has_param=True, binding=ollama)
INFO: Embedding max_token_size: 8192 (from ollama provider default)
INFO: Reranking is disabled
INFO: [] Created new empty graph file: C:\Users\end0t\tmp\LightRag\rag_storage\graph_chunk_entity_relation.graphml
INFO: Role LLM Configuration (initialized):
INFO:  - extract: ollama/qwen3:8b, host=http://localhost:11434, max_async=4, timeout=240
INFO:  - keyword: ollama/qwen3:8b, host=http://localhost:11434, max_async=4, timeout=240
INFO:  - query: ollama/qwen3:8b, host=http://localhost:11434, max_async=4, timeout=240
INFO:  - vlm: ollama/qwen3:8b, host=http://localhost:11434, max_async=4, timeout=240
INFO: Role LLM Option:
INFO:  - extract: Ollama {}
INFO:  - keyword: Ollama {}
INFO:  - query: Ollama {}
INFO:  - vlm: Ollama {}
INFO: WebUI assets mounted at /webui
Starting Uvicorn server in single-process mode on 0.0.0.0:9621
INFO: Started server process [23652]
INFO: Waiting for application startup.
INFO: [] Process 23652 KV load full_docs with 0 records
INFO: [] Process 23652 KV load text_chunks with 0 records
INFO: [] Process 23652 KV load full_entities with 0 records
INFO: [] Process 23652 KV load full_relations with 0 records
INFO: [] Process 23652 KV load entity_chunks with 0 records
INFO: [] Process 23652 KV load relation_chunks with 0 records
INFO: [] Process 23652 KV load llm_response_cache with 0 records
INFO: [] Process 23652 doc status load doc_status with 0 records

Server is ready to accept connections! 🚀

INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:9621 (Press CTRL+C to quit)

入力データの準備

light ragのインストールdirの直下に、input dirが作成されますので、 マークダウンファイルなど、入力用のテキストデータをここへ保存してください

webuiからインデックス作成

http://localhost:9621/webui へ アクセスし、 画面右上にある「Scan」をクリニックすると、インデックス作成が開始されます

インデックスが完成すると、以下のようなナレッジグラフを確認できます

作成される rag_storage 内の各file

Key-Value store — 生データ・中間データ

file note
kv_store_full_docs.json 元document
kv_store_text_chunks.json 上記docの chunk
kv_store_full_entities.json doc毎の entity
kv_store_full_relations.json doc毎の relation
kv_store_entity_chunks.json entity→出典chunk
kv_store_relation_chunks.json 関係 → 出典chunk

Vector DB — 意味検索(embedding)用

file note
vdb_chunks.json chunk本文のvector
vdb_entities.json entity(+説明)のvector
vdb_relationships.json 関係(+説明)のvector

ナレッジグラフ本体 — GraphML形式

graph_chunk_entity_relation.graphml

固有名詞 (固有表現)を事前に登録する場合

https://github.com/HKUDS/LightRAG/blob/main/docs/LightRAG-API-Server.md

によれば、

.env ファイルにおいて

ENTITY_TYPE_PROMPT_FILE=construction.yml

のように yaml ファイルを指定し、次のようにyamlファイルを作れば、OKらしい

entity_types_guidance: |
  Classify each entity using one of the following types. If no type fits, use `Other`.

  - 工法: 施工方法・手順・作業(例: 捨コン打設, アンカー強化)
  - 部材: 物理的な部品・資材・器具(例: アンカーボルト, 基礎パッキン, 型枠)
  - 基礎構造: 基礎に関する構造物(例: 蓄電池VtoH基礎, 建物本体用基礎)
  - 検査項目: 確認・検査・チェックの対象(例: 基礎完了チェック, 天端レベル確認)
  - 設備: 設置される機器・システム(例: 蓄電池VtoHシステム)
  - Location: 場所・位置(例: 立上り部, スラブ箱抜きスリーブ部)
  - Concept: 抽象的な概念・基準(例: アンカー通り芯)
  - Organization: 会社・団体
  - Person: 人物

※ 上記にある| は、複数行の文字列を改行込みでそのまま値にするという YAML の記法

webuiから検索テスト

回答の元となった出典ファイル名も表示されます

web api

powershell(ps1)実行時「スクリプトの実行が無効になっているため」エラー

PS > powershell.exe .\backup_robocopy_log.ps1
.\backup_robocopy_log.ps1 : このシステムではスクリプトの実行が無効になっているため、ファイル
C:\Users\srch-admin\rpa\fess\backup_robocopy_log.ps1 を読み込むことができません。詳細について
は、「about_Execution_Policies」(https://go.microsoft.com/fwlink/?LinkID=135170) を参照してく
ださい。
発生場所 行:1 文字:1
+ .\backup_robocopy_log.ps1
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : セキュリティ エラー: (: ) []、PSSecurityException
    + FullyQualifiedErrorId : UnauthorizedAccess

のエラーとなった場合、以下のように 「-ExecutionPolicy Bypass」オプションを追加すれば、OK

PS > powershell.exe -ExecutionPolicy Bypass .\backup_robocopy_log.ps1

install ColPali(ColQwen 2.5) for python3.12 on windows11

https://github.com/illuin-tech/colpali

ColPaliという "画像のまま検索する" RAGの存在をしったので、その qwen2.5版をお試し。

利用環境

項目 内容
PC think pad x1 carbon mem:64gbyte
GPU geforce rtx 4909
OS windows 11
PYTHON v.3.12 embeddable package 64bit

pip install

2026/8時点で、colpali-engine の対応modelには qwen3.5-4b がありますが、 これは、コミュニケーション版でしたので、 qwen2.5 vl 3b 対応の vidore/colqwen2.5-v0.2 を使用します。

colpali-engineが依存する transformersは ver.5系がありますが、 vidore/colqwen2.5-v0.2が対応していない為、transformers==4.51.3 を pip installしています。

CMD> python3.12\python.exe -m pip install torch==2.6.0+cu124 --extra-index-url https://download.pytorch.org/whl/cu124

CMD> python3.12\python.exe -m pip install torchvision==0.21.0+cu124 --extra-index-url https://download.pytorch.org/whl/cu124

CMD> python3.12\python.exe -m pip install colpali-engine==0.3.10
CMD> python3.12\python.exe -m pip install transformers==4.51.3
CMD> python3.12\python.exe -m pip install peft==0.15.2
CMD> python3.12\python.exe -m pip install accelerate==1.14.0
CMD> python3.12\python.exe -m pip install pillow>=10

学習用 画像tiff

1,300個の tiffファイルを用意しました

index作成

# ColQwen2.5 で dir内のtiff画像をembeddingし index.保存

import argparse
import glob
import os
import time
import torch
from PIL import Image

from colpali_engine.models import ColQwen2_5, ColQwen2_5_Processor

MODEL_NAME = "vidore/colqwen2.5-v0.2"
BATCH_SIZE = 4
MAX_SIDE = 3700      # 画像長辺の上限px

def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--tiff-dir", default="tiff")
    ap.add_argument("--out", default="index.pt")
    args = ap.parse_args()

    files = sorted(glob.glob(os.path.join(args.tiff_dir, "*.tif*")))
    print(f"対象画像: {len(files)} 件")

    print(f"loading model: {MODEL_NAME} ...")
    model = ColQwen2_5.from_pretrained(
        MODEL_NAME, torch_dtype=torch.bfloat16, device_map="cuda:0"
    ).eval()
    processor = ColQwen2_5_Processor.from_pretrained(MODEL_NAME)

    all_emb: list[torch.Tensor] = []
    tok_counts: list[int] = []
    t0 = time.time()
    for i in range(0, len(files), BATCH_SIZE):
        batch_files = files[i : i + BATCH_SIZE]
        imgs = [load_image(f, MAX_SIDE) for f in batch_files]
        batch = processor.process_images(imgs).to(model.device)
        with torch.no_grad():
            emb = model(**batch)  # [B, seq, dim](padding含む)
        mask = batch["attention_mask"].bool()
        for j in range(len(batch_files)):
            e = emb[j][mask[j]].to(torch.float16).cpu()  # padding除去し保存
            all_emb.append(e)
            tok_counts.append(e.shape[0])
        done = i + len(batch_files)
        elapsed = time.time() - t0
        rate = done / elapsed
        eta = (len(files) - done) / rate if rate else 0
        print(
            f"  {done}/{len(files)}  {elapsed:6.1f}s  "
            f"{rate:4.2f} img/s  ETA {eta/60:4.1f}min  "
            f"vram={torch.cuda.max_memory_allocated()/1e9:.1f}GB",
            end="\r",
        )
    print()
    avg_tok = sum(tok_counts) / len(tok_counts)
    print(f"平均token数/画像: {avg_tok:.0f}  次元: {all_emb[0].shape[1]}")

    torch.save({"model": MODEL_NAME, "files": files, "embeddings": all_emb},
               args.out)
    size_mb = os.path.getsize(args.out) / 1e6
    print(f"保存: {args.out}  ({size_mb:.1f} MB)")

    
def load_image(path: str, max_side: int) -> Image.Image:
    """1bit二値/gray scale等を RGB に変換し、長辺を max_side 以下に縮小 """
    im = Image.open(path).convert("RGB")
    w, h = im.size
    if max(w, h) > max_side:
        scale = max_side / max(w, h)
        im = im.resize((round(w * scale), round(h * scale)), Image.LANCZOS)
    return im

if __name__ == "__main__":
    main()
C:\Users\end0t\tmp\ColPali>python3.12\python.exe index.py

対象画像: 1343 件
loading model: vidore/colqwen2.5-v0.2 ...
Fetching 2 files: 100%|█████████████████████████| 2/2 [00:00<00:00, 680.89it/s]
Loading checkpoint shards: 100%|█████████████████| 2/2 [00:07<00:00,  3.87s/it]

Using a slow image processor as `use_fast` is unset and
a slow processor was saved with this model.
`use_fast=True` will be the default behavior in v4.52,
even if the model was saved with a slow processor.
This will result in minor differences in outputs.
You'll still be able to use a slow processor with `use_fast=False`.
  1343/1343  1553.3s  0.86 img/s  ETA  0.0min  vram=8.5GB
平均token数/画像: 759  次元: 128
保存: index.pt  (261.3 MB)```

test検索

import argparse
import sys

import torch

from colpali_engine.models import ColQwen2_5, ColQwen2_5_Processor

INDEX_PATH = "index.pt"   # 使用するインデックスファイル
TOPK = 10                 # 表示する上位件数

# Windows コンソールの文字化け対策:標準入出力を UTF-8 に固定
for _s in (sys.stdin, sys.stdout, sys.stderr):
    try:
        _s.reconfigure(encoding="utf-8")
    except Exception:
        pass


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("query", help="検索クエリ")
    args = ap.parse_args()

    data = torch.load(INDEX_PATH, weights_only=False)
    model_name = data["model"]
    files = data["files"]

    embs = [e.float() for e in data["embeddings"]]
    print(f"インデックス: {len(files)} 件  モデル: {model_name}")

    print("モデル読み込み中 ...")
    model = ColQwen2_5.from_pretrained(
        model_name, torch_dtype=torch.bfloat16, device_map="cuda:0"
    ).eval()
    processor = ColQwen2_5_Processor.from_pretrained(model_name)

    batch = processor.process_queries([args.query]).to(model.device)
    with torch.no_grad():
        q = model(**batch)
    q_emb = [q[0].float().cpu()]
    scores = processor.score_multi_vector(q_emb, embs)[0]
    top = scores.topk(min(TOPK, len(files)))
    print(f"\n[検索] {args.query}")
    for rank, (s, idx) in enumerate(zip(top.values, top.indices), 1):
        print(f"  {rank:2d}. {files[idx]}   score={s.item():.3f}")


if __name__ == "__main__":
    main()
C:\Users\end0t\tmp\ColPali>python3.12\python.exe search.py "アンカーボルトの設置"
インデックス: 1343 件  モデル: vidore/colqwen2.5-v0.2
モデル読み込み中 ...
Fetching 2 files: 100%|██████████████████████████████| 2/2 [00:00<?, ?it/s]
Loading checkpoint shards: 100%|█████████████| 2/2 [00:04<00:00,  2.37s/it]
Using a slow image processor as `use_fast` is unset and a slow processor was saved with this model. `use_fast=True` will be the default behavior in v4.52, even if the model was saved with a slow processor. This will result in minor differences in outputs. You'll still be able to use a slow processor with `use_fast=False`.

[検索] アンカーボルトの設置
   1. tiff\H3A17-05.tiff   score=15.994
   2. tiff\H3A37-01.tiff   score=15.926
   3. tiff\V3A17-01.tiff   score=15.850
   4. tiff\S3A43-01.tiff   score=15.775
   5. tiff\H3A14-01.tiff   score=15.769

huggingfaceの modelを localにダウンロード

proxy配下で SentenceTransformer(model_name, device="cpu") したところ、 ssl errorとなった為、事前ダウンロードする為。

import httpx
from huggingface_hub import snapshot_download
from huggingface_hub import set_client_factory

def client_factory():
    return httpx.Client(verify=False)

set_client_factory(client_factory)

snapshot_download(
    repo_id="hotchpotch/static-embedding-japanese",
    local_dir="hf_models/static-embedding-japanese",
)

snapshot_download(
    repo_id="hotchpotch/japanese-reranker-cross-encoder-small-v1",
    local_dir="hf_models/japanese-reranker-cross-encoder-small-v1",
)

sudachi for python の ユーザ辞書, 同義語辞書 を 1コの tsvから作成

sudachi for python の ユーザ辞書, 同義語辞書は、通常、 sudachipy.exe ubuild コマンドにより user_lex.csv , synonyms.txt から user.dic , synonyms.json を作成します。

今回、固有名詞のみを対象にした以下のような1コのTSVファイルから、これらを作成。

代表語    同義語1  同義語2  同義語3  同義語4  同義語5
2×4    ツーバイフォー   204         
32ユニット  32U             
1421Ba                  
# -*- coding: utf-8 -*-
"""
tsv から Sudachi ユーザ辞書(user.dic)と同義語辞書(synonyms.json)を生成

入力:
  tsvファイル
    タブ区切り / UTF-8 / 1 行目はヘッダ。
    1 列目 = 代表語、2 列目以降 = 同義語(空欄可)。固有名詞を想定

出力:
  dict/user_lex.csv  : Sudachi ユーザ辞書ソース
                        (全表記を固有名詞 名詞,固有名詞,一般 で登録)
  dict/user.dic      : 上記を sudachipy ubuild でコンパイルしたユーザ辞書
  dict/synonyms.json : {"グループID": [代表語, 同義語, ...]} (代表語が先頭)

  ※ synonyms.txt / user_lex.csv(旧ソース)の代わりに本 TSV を使用
"""
import csv
import json
import re
import subprocess
import sys
import unicodedata
from pathlib import Path

import jaconv

BASE = Path(__file__).resolve().parent
DICT = BASE / "dict"
SRC = DICT / "user_dic_src.tsv"
SYSTEM_DIC = DICT / "system.dic" # pip install sudachidict_core によるfile
USER_LEX = DICT / "user_lex.csv"
USER_DIC = DICT / "user.dic"
SYNONYMS_JSON = DICT / "synonyms.json"

# 名詞,固有名詞,一般 の左/右連接ID とコスト。コストが低いほど優先。
# 確実に 1 トークンとして採用されるよう十分低い値にする
LEFT_ID = RIGHT_ID = 4786
COST = -20000


def normalize_word(word: str) -> str:
    """半 角/全角・大文字/小文字を統一 """
    if not word:
        return ""
    word = re.sub(r"[\s\n ]+", "", word)
    word = unicodedata.normalize("NFKC", word)
    word = jaconv.z2h(word, kana=False, ascii=True, digit=True)
    word = jaconv.h2z(word, kana=True, ascii=False, digit=False)
    return word.upper().replace(",", "")


def load_groups():
    """TSV を読み、各行を [代表語, 同義語, ...] のlistにして返す"""
    with open(SRC, encoding="utf-8") as f:
        rows = list(csv.reader(f, delimiter="\t"))
    groups = []
    for r in rows[1:]:          # 1 行目はヘッダ
        words = [c.strip() for c in r if c.strip()]
        if words:
            groups.append(words)
    return groups


def build_user_lex(groups):
    """全表記(代表語+同義語)を固有名詞として user_lex.csv に書き出す。"""
    seen = set()
    with open(USER_LEX, "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        for words in groups:
            for word in words:
                word = normalize_word(word)   # 検索側と同じ正規化で登録
                if not word or word in seen:
                    continue
                seen.add(word)
                w.writerow([
                    word, LEFT_ID, RIGHT_ID, COST, word,
                    "名詞", "固有名詞", "一般", "*", "*", "*",
                    word,   # 読み (プレースホルダ)
                    word,   # 正規化表記
                    "*", "*", "*", "*", "*",
                ])
    return len(seen)


def build_synonyms(groups):
    """同義語が 2 語以上ある行を {gid:[語,...]} とし synonyms.json に書き出す """
    data = {str(i): words for i, words in enumerate(groups) if len(words) >= 2}
    SYNONYMS_JSON.write_text(
        json.dumps(data, ensure_ascii=False, separators=(",", ":")),
        encoding="utf-8",
    )
    return len(data)


def build_user_dic():
    """sudachipy ubuild で user_lex.csv を user.dic にコンパイルする。"""
    if USER_DIC.exists():
        USER_DIC.unlink()
    # sudachipy には __main__ が無く `-m sudachipy` 不可。
    # CLI エントリポイント(sudachipy.command_line:main)を呼ぶ
    subprocess.run(
        [sys.executable, "-c", "from sudachipy.command_line import main; main()",
         "ubuild", "-s", str(SYSTEM_DIC), "-o", str(USER_DIC), str(USER_LEX)],
        check=True,
    )


def main():
    if not SRC.exists():
        sys.exit(f"入力がありません: {SRC}")
    groups = load_groups()
    n_lex = build_user_lex(groups)
    n_syn = build_synonyms(groups)
    print(f"読み込み: {len(groups)} グループ")
    print(f"user_lex.csv : {n_lex} 表記 -> {USER_LEX}")
    print(f"synonyms.json: {n_syn} グループ -> {SYNONYMS_JSON}")
    build_user_dic()
    print(f"user.dic 生成完了 -> {USER_DIC}")


if __name__ == "__main__":
    main()

全文+vector ハイブリッド検索 + cross encoder re-rank

構成

  • SQLite : FTS5(全文検索)、sqlite_vec(ベクトル検索)
  • 融合 : weighted(正規化スコアの加重和)
  • リランク : hotchpotch/japanese-reranker-cross-encoder-small-v1
  • 形態素解析 : sudachi.rs、同義語辞書で代表語へ正規化
  • 埋込みmodel : hotchpotch/static-embedding-japanese (1024 次元)

python script

"""
hybrid検索system by SQLite, 加重和fusion, cross-encoder rerank

- FTS(bm25) と vector(cosine) で検索後、正規化スコアの加重和で統合
- 上位候補を日本語 cross-encoder で再スコアし最終順位を決定

構成:
- SQLite      : FTS5(全文検索)、sqlite_vec(ベクトル検索)
- 融合        : weighted(正規化スコアの加重和)
- リランク    : hotchpotch/japanese-reranker-cross-encoder-small-v1
- 形態素解析  : sudachi.rs、同義語辞書で代表語へ正規化
- 埋込みmodel : hotchpotch/static-embedding-japanese  (1024 次元)

入力データ:
- Shift-JIS / DOS 改行 の Q&A レコードを対象
- 相談No を業務上の主キー
- hybird検索の対象は、「表題 + 相談内容」を連結

テーブル:
  documents     : QA レコード (id, 相談No ほか)
  documents_fts : FTS5 仮想テーブル (rowid = documents.id, 分かち書き済みtext)
  documents_vec : sqlite-vec 仮想テーブル (rowid = documents.id, embedding)
"""
from datetime  import datetime
from pathlib   import Path
from sudachipy import Dictionary, SplitMode
from urllib.parse import quote
import csv
import html
import jaconv
import json
import logging
import logging.config
import numpy as np
import os
import re
import sqlite3
import sqlite_vec
import sys
import traceback
import unicodedata

# cache済みモデルを使用。sentence_transformersのimport前で環境変数を設定。
os.environ.setdefault("HF_HUB_OFFLINE", "1")
from sentence_transformers import SentenceTransformer

MODEL_NAME = "hotchpotch/static-embedding-japanese"
DB_PATH   = "hybrid_search.db"
EMBED_DIM = 1024 # モデルの埋め込み次元

# 相談番号の詳細画面 URL (c_ccode に相談番号を渡す)。HTML の相談番号リンク先。
SOUDAN_URL = ("http://~/genDB2/sodan_hnweb/dtl4sntk?c_ccode={}")

# --- 入力データ

CSV_ENCODING = "cp932"

CSV_COL = {
    "SOUDAN_NO": 0,                      # 相談No (pkey)
    "UKETSUKE" : 1,                      # 初回受付日
    "HONSHA"   : 7,                      # 販社本社名
    "TITLE"    : 11,                     # 表題     ※ 検索対象
    "SOUDAN"   : 12,                     # 相談内容 ※ 検索対象
    "TEITYPE"  : 14,                     # 邸タイプ
    "BUSHO": (18,20,22,24,26,28,30,32),  # 回答部所1~8 (空白区切りで集約)
    "KAITO"    : 35,                     # 回答内容
    "HM_OR_TU" : 50,                     # 
    "KONKYO"   : 54,                     # 根拠となる基準
}

# --- Sudachi 設定

SUDACHI_DICT_DIR = Path(__file__).resolve().parent.parent / "SUDACHI" / "dict"
SUDACHI_SYSTEM_DIC = SUDACHI_DICT_DIR / "system_core.dic"
SUDACHI_USER_DIC   = SUDACHI_DICT_DIR / "user.dic"
SUDACHI_SYNONYMS_JSON = SUDACHI_DICT_DIR / "synonyms.json"
# 分割単位: A(最短) / B(中間) / C(最長)。全文検索は再現率重視の為、Aを既定
SUDACHI_SPLIT_MODE = SplitMode.A

# 検索クエリに含める品詞。前方一致パターンで指定
FTS_INCLUDE_POS = (("名詞", "固有名詞"), ("名詞", "普通名詞"), ("接尾辞",))

# FTS 全文検索で「特徴語」に絞るための文書頻度(df)しきい値。
FTS_DF_MAX_RATIO = 0.30

# hybird検索の既定動作
HYBRID_SEARCH_1ST_CANDIDATES = 50  # 1 段目で絞り込む候補件数
# vector(意味)側の重み (0..1)。1-α が bm25側。
HYBRID_SEARCH_VEC_WEIGHT = 0.5

# --- Cross-encoder リランカー
# 1 段目(加重和)で集めた候補を、query×文書のペアで再スコア
RERANKER_MODEL = "hotchpotch/japanese-reranker-cross-encoder-small-v1"
RERANK_ENABLE  = True       # 最終段で cross-encoder リランクを行うか
RERANK_CANDIDATES = 30      # リランク対象に渡す 1 段目の上位件数
# rerank スコア(sigmoid 0..1)の下限。これ未満の文書は足切り
RERANK_MIN_SCORE = 0.5

# --- ログ出力

LOG_DIR = Path(__file__).resolve().parent / "log"
LOG_DIR.mkdir(exist_ok=True)
LOG_FILE = LOG_DIR / f"{Path(__file__).stem}_{datetime.now():%m%d}.log"

LOG_CONF = {
    "version": 1,
    "disable_existing_loggers": False,
    "loggers": {
        "mainLogger": {
            "level": "DEBUG",
            "handlers": ["fileHandler", "consoleHandler"],
            "propagate": False },
    },
    "handlers": {
        "fileHandler": {
            "level": "DEBUG",
            "formatter": "mainFormatter",
            "class": "logging.handlers.RotatingFileHandler",
            "filename": str(LOG_FILE),
            "encoding": "utf-8",
            "maxBytes": 1024 * 1024 * 10,   # 10 MB
            "backupCount": 30,              # ローテーション世代数
        },
        "consoleHandler": {
            "level": "INFO",
            "formatter": "consoleFormatter",
            "class": "logging.StreamHandler",
            "stream": "ext://sys.stdout",   # 結果表示(print)と同じ stdout に揃える
        },
    },
    "formatters": {
        "mainFormatter": {
            "format": "%(asctime)s\t%(levelname)s\t%(filename)s"
                      "\tL%(lineno)d\t%(funcName)s\t%(message)s",
            "datefmt": "%Y/%m/%d %H:%M:%S" },
        "consoleFormatter": {
            "format": "%(message)s" },
    },
}
logging.config.dictConfig(LOG_CONF)
logger = logging.getLogger("mainLogger")


def main(argv=None):
    argv = sys.argv[1:] if argv is None else argv
    cmd = argv[0] if argv else None
    csv_arg = argv[1] if len(argv) > 1 else None

    def usage():
        print("USAGE: %s <CMD> <CSV>" % (os.path.basename(__file__)) )
        print(" CMD:makedb     <CSV>: 指定CSV から %s 作成" % (DB_PATH))
        print(" CMD:searchtest <CSV>: 指定CSV を検索し htmlに出力")

    if cmd == "makedb":
        if not csv_arg:
            usage()
            return
        db = HybridDb()
        try:
            db.make_db(Path(csv_arg))
        finally:
            db.close()
    elif cmd == "searchtest":
        if not csv_arg:
            usage()
            return
        if not Path(DB_PATH).exists():
            logger.warning( "%s がありません", DB_PATH )
            return
        db = HybridDb()
        try:
            db.search_test(Path(csv_arg))
        finally:
            db.close()
    else:
        usage()


class Embedder:
    """static-embedding-japanese のラッパー """
    def __init__(self, model_name: str = MODEL_NAME):
        self.model = SentenceTransformer(model_name, device="cpu")
        # sentence-transformers のバージョン差を吸収
        get_dim = getattr(self.model, "get_embedding_dimension", None) \
            or self.model.get_sentence_embedding_dimension
        self.dim = get_dim()

    def encode(self, texts) -> np.ndarray:
        single = isinstance(texts, str)
        if single:
            texts = [texts]
        vecs = self.model.encode(
            texts,
            normalize_embeddings=True,   # cosine 距離のため L2正規化
            convert_to_numpy=True,
        ).astype(np.float32)
        return vecs[0] if single else vecs


class Reranker:
    """日本語 cross-encoder リランカーのラッパー """

    def __init__(self, model_name: str = RERANKER_MODEL):
        from sentence_transformers import CrossEncoder
        self.model = CrossEncoder(model_name, device="cpu", max_length=512)

    def rank(self, query, docs):
        """docs=[(doc_id, text), ...] を (query, text) で採点し降順で返す。
        返り値: [(doc_id, score), ...] score は高いほど関連 (sigmoid 出力)。
        """
        if not docs:
            return []
        scores = self.model.predict([(query, text) for _, text in docs])
        ranked = [(doc_id, float(s)) for (doc_id, _), s in zip(docs, scores)]
        ranked.sort(key=lambda x: x[1], reverse=True)
        return ranked


class HybridDb:
    """hybrid.db への接続・索引作成・検索をまとめたクラス """

    def __init__(self, db_path: str = DB_PATH):
        self.db_path = db_path
        self.conn = self._connect(db_path)
        self._tokenizer = None    # Sudachi 辞書
        self._embedder = None     # 埋込みmodel
        self._synonyms = None     # 同義語辞書
        self._reranker = None     # cross-encoder リランカー
        self._vocab_ready = False # documents_fts_vocab を作成済みか

    @staticmethod
    def _connect(db_path: str) -> sqlite3.Connection:
        conn = sqlite3.connect(db_path)
        conn.enable_load_extension(True)
        sqlite_vec.load(conn)
        conn.enable_load_extension(False)
        return conn

    @staticmethod
    def _build_tokenizer():
        """SudachiPy のトークナイザ生成。system_core.dic + user.dicを使用 """
        try:
            from sudachipy import Config
            users = [str(SUDACHI_USER_DIC)] if SUDACHI_USER_DIC.exists() else []
            config = Config(system=str(SUDACHI_SYSTEM_DIC), user=users)
            return Dictionary(config=config).create()
        except Exception as e:   # 辞書バージョン不整合など
            logger.warning("Sudachi 辞書の読込に失敗 (%s). ", e)

    @property
    def tokenizer(self):
        if self._tokenizer is None:
            self._tokenizer = self._build_tokenizer()
        return self._tokenizer

    @property
    def embedder(self) -> Embedder:
        if self._embedder is None:
            logger.info("埋込みmodelをload中: %s", MODEL_NAME)
            self._embedder = Embedder()
            logger.debug("埋込み次元: %d", self._embedder.dim)
        return self._embedder

    @property
    def synonyms(self) -> dict:
        if self._synonyms is None:
            self._synonyms = self._load_synonyms()
            logger.info("同義語entry数: %d", len(self._synonyms))
        return self._synonyms

    @property
    def reranker(self) -> Reranker:
        if self._reranker is None:
            logger.info("re-ranker load中: %s", RERANKER_MODEL)
            self._reranker = Reranker()
        return self._reranker

    @staticmethod
    def _load_synonyms() -> dict:
        """ 同義語 JSON から {語: 代表語} 辞書を作成 """
        if not SUDACHI_SYNONYMS_JSON.exists():
            logger.warning("同義語辞書がない為、同義語正規化を無効化: %s",
                           SUDACHI_SYNONYMS_JSON)
            return {}
        with open(SUDACHI_SYNONYMS_JSON, encoding="utf-8") as f:
            groups = json.load(f)
        logger.debug("同義語辞書を読込: %s", SUDACHI_SYNONYMS_JSON)
        mapping: dict[str, str] = {}
        for words in groups.values():
            # キー・代表語とも本文と同じ正規化を通し、表記ゆれを吸収
            words = [HybridDb.normalize_word(w) for w in words if w]
            if not words:
                continue
            rep = words[0]                  # グループ先頭が代表語
            for w in words:
                mapping.setdefault(w, rep)  # 先勝ち
        return mapping

    def close(self):
        self.conn.close()

    def count(self) -> int:
        """documents の登録件数 """
        try:
            return self.conn.execute(
                "SELECT COUNT(*) FROM documents").fetchone()[0]
        except sqlite3.OperationalError:
            return 0

    # -- 形態素解析 / 同義語正規化
    @staticmethod
    def normalize_word(word: str) -> str:
        """半角/全角・大文字/小文字などの表記を統一 (形態素解析の前処理)。

        - 空白(全角空白・改行含む)を除去
        - NFKC 正規化 (全角英数→半角、半角カナ→全角 など)
        - 英数字は半角へ、カナは全角へ寄せる
        - 英字は大文字に統一、カンマは除去
        """
        try:
            if word is None:
                return ""
            word = re.sub(r"[\s\n ]+", "", word)
            word = unicodedata.normalize("NFKC", word)
            word = jaconv.z2h(word, kana=False, ascii=True, digit=True)
            word = jaconv.h2z(word, kana=True, ascii=False, digit=False)
            word = word.upper().replace(",", "")
        except Exception as e:
            logger.error("%s %s", e, word)
            logger.error(traceback.format_exc())
        return word

    def morphemes(self, text: str):
        """text を Sudachi で形態素に分割 """
        return self.tokenizer.tokenize(text, SUDACHI_SPLIT_MODE)

    # 同義語の最長一致で連結を試す最大形態素数
    _SYNONYM_MAX_SPAN = 8

    @staticmethod
    def _expandable(surface: str) -> bool:
        """同義語展開してよいかの判定。
        「なし」(無し⇔梨)のような同音異義の誤った正規化を防ぐため
        """
        return any(not ("ぁ" <= ch <= "ゟ") for ch in surface)

    def analyzed(self, text: str):
        """text を形態素解析し、[(代表語, 品詞タプル), ...] を返す

        品詞タプルは Sudachi の part_of_speech() 6 項目
        (大分類, 中分類, 小分類, 細分類, 活用型, 活用形)。

        分割単位 A では複数形態素になる同義語 (例 不明確=不+明確) も拾えるよう
        連続する形態素の表層形を連結して最長一致で同義語辞書を引く
        """
        text = self.normalize_word(text)
        ms = list(self.morphemes(text))
        surfaces = [m.surface() for m in ms]
        syn = self.synonyms
        out = []
        i, n = 0, len(ms)
        while i < n:
            hit = None
            # 長い連結から順に試す (最長一致)。純ひらがな語は展開しない
            for j in range(min(n, i + self._SYNONYM_MAX_SPAN), i, -1):
                key = "".join(surfaces[i:j])
                rep = syn.get(key)
                if rep is not None and self._expandable(key):
                    hit = (rep, tuple(ms[i].part_of_speech()), j)
                    break
            if hit:
                out.append((hit[0], hit[1]))
                i = hit[2]
            else:
                out.append((surfaces[i], tuple(ms[i].part_of_speech())))
                i += 1
        return out

    def tokenize(self, text: str) -> str:
        """代表語へ正規化した形態素を空白区切りで返す (FTS5 索引用)。"""
        return " ".join(w for w, _pos in self.analyzed(text))

    def normalize_text(self, text: str) -> str:
        """同義語を代表語へ置換した文字列を返す (埋め込み入力用)。"""
        return "".join(w for w, _pos in self.analyzed(text)) or " "

    def _ensure_vocab(self):
        """fts5vocab (documents_fts_vocab) を用意 """
        if not self._vocab_ready:
            self.conn.execute(
                "CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts_vocab "
                "USING fts5vocab('documents_fts', 'row')")
            self._vocab_ready = True

    def doc_freq(self, term: str) -> int:
        """term を含む文書数 (df)。未知語は 0。"""
        self._ensure_vocab()
        row = self.conn.execute(
            "SELECT doc FROM documents_fts_vocab WHERE term = ?", (term,)
        ).fetchone()
        return row[0] if row else 0

    def build_fts_query(self, text: str) -> str:
        """検索クエリ文字列を FTS5 の MATCH 式に変換 (特徴語のみ)。

        内容語(品詞が FTS_INCLUDE_POS のもの)を代表語へ正規化し、
        文書頻度(df)が FTS_DF_MAX_RATIO を超える汎用語(例: 対応/可能/設置)を除外、
        識別力のある「特徴語」だけを OR 連結
        """

        # FTS_INCLUDE_POS に前方一致する内容語を抽出
        def _included(pos):
            return any(tuple(pos[:len(pat)]) == pat for pat in FTS_INCLUDE_POS)

        seen, terms, pos_of = set(), [], {}
        for w, pos in self.analyzed(text):
            w = w.strip()
            if w and _included(pos) and w not in seen:
                seen.add(w)
                terms.append(w)
                pos_of[w] = pos

        total = self.count() or 1
        max_df = FTS_DF_MAX_RATIO * total
        with_df = [(w, self.doc_freq(w)) for w in terms]

        feature = [w for w, df in with_df if df <= max_df]
        if not feature:   # 全部汎用語 → df 昇順で数語だけ残す
            feature = [w for w, _ in sorted(with_df, key=lambda x: x[1])[:3]]

        dropped = [w for w, df in with_df if w not in feature]
        # 語ごとに 詳細品詞と df を併記してlog出力
        df_of = dict(with_df)

        def _pos_str(w):
            return "-".join(p for p in pos_of.get(w, ()) if p and p != "*")

        def _fmt(words):
            return [f"{w}({_pos_str(w)},df={df_of[w]})" for w in words]
        logger.debug("build_fts_query: 特徴語=%s / 除外(汎用語)=%s",
                     _fmt(feature), _fmt(dropped))

        # 「"」をescapeし tokenを引用符で囲む
        quoted = ['"' + t.replace('"', '""') + '"' for t in feature]
        return " OR ".join(quoted)

    # -- スキーマ
    def create_schema(self):
        self.conn.executescript(
            f"""
            CREATE TABLE IF NOT EXISTS documents (
                id         INTEGER PRIMARY KEY,   -- FTS5/sqlite-vec 用の rowid
                soudan_no  TEXT UNIQUE NOT NULL,  -- 相談No (業務上の主キー)
                uketsuke   TEXT,                  -- 初回受付日
                honsha     TEXT,                  -- 販社本社名
                title      TEXT,                  -- 表題
                soudan     TEXT,                  -- 相談内容
                tei_type   TEXT,                  -- 邸タイプ
                busho      TEXT,                  -- 回答部所1~8 (空白区切り)
                kaito      TEXT,                  -- 回答内容
                hm_or_tu   TEXT,                  -- 
                konkyo     TEXT                   -- 根拠となる基準
            );

            -- FTS5: 分かち書き済み検索テキストを索引。rowid で documents と対応
            CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
                text,
                tokenize = 'unicode61 remove_diacritics 2'
            );

            -- sqlite-vec: 1024 次元ベクトルを cosine 距離で近傍検索。
            CREATE VIRTUAL TABLE IF NOT EXISTS documents_vec USING vec0(
                embedding FLOAT[{EMBED_DIM}] distance_metric=cosine
            );

            -- fts5vocab: 語ごとの文書頻度(df)を参照し、特徴語抽出に使う。
            CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts_vocab
                USING fts5vocab('documents_fts', 'row');
            """
        )
        self.conn.commit()
        self._vocab_ready = True

    # -- CSV 読み込み
    def load_qa_csv(self, path: Path):
        """CSV を読み込み、dict リストを返す

        読み込み時に次の整形を行う:
          - 相談内容・回答内容の空白行(連続改行)を削除
          - 表題・相談内容に含まれる氏名(人名)を「※氏名※」に置換
        """
        def cell(row, idx):   # 列不足に備える
            return row[idx].strip() if idx < len(row) else ""

        docs = []
        with open(path, encoding=CSV_ENCODING, newline="") as f:
            reader = csv.reader(f)
            next(reader, None)                      # ヘッダ行を読み飛ばす
            for row in reader:
                soudan_no = cell(row, CSV_COL["SOUDAN_NO"])
                if not soudan_no:
                    continue
                
                # 回答部所1~8 を空白区切りで 1 カラムに集約
                busho = " ".join(
                    cell(row, i) for i in CSV_COL["BUSHO"] if cell(row, i)
                )

                # 相談内容・回答内容: 空白行を削除
                soudan = self._collapse_blank_lines(cell(row, CSV_COL["SOUDAN"]))
                kaito  = self._collapse_blank_lines(cell(row, CSV_COL["KAITO"]))
                # 表題・相談内容: 氏名(人名)を「※氏名※」に置換
                title  = self._mask_names(cell(row, CSV_COL["TITLE"]))
                soudan = self._mask_names(soudan)

                docs.append({
                    "soudan_no": soudan_no,
                    "uketsuke" : cell(row, CSV_COL["UKETSUKE"]),
                    "honsha"   : cell(row, CSV_COL["HONSHA"]),
                    "title"    : title,
                    "soudan"   : soudan,
                    "tei_type" : cell(row, CSV_COL["TEITYPE"]),
                    "busho"    : busho,
                    "kaito"    : kaito,
                    "hm_or_tu" : cell(row, CSV_COL["HM_OR_TU"]),
                    "konkyo"   : cell(row, CSV_COL["KONKYO"]),
                })
        return docs

    # 氏名(人名)判定に使う Sudachi 品詞: 名詞-固有名詞-人名-*
    _NAME_POS = ("名詞", "固有名詞", "人名")

    def _mask_names(self, text: str) -> str:
        """text 中の氏名(人名)形態素を「※氏名※」に置換

        Sudachi で形態素解析し、品詞が 名詞-固有名詞-人名-* の形態素の
        文字位置を集める。連続する人名(姓+名 等)は 1 つの「※氏名※」に
        """
        if not text:
            return text

        spans = []   # 人名形態素の (開始, 終了) 文字位置
        for m in self.morphemes(text):
            pos = tuple(m.part_of_speech())
            if pos[:3] == self._NAME_POS:
                spans.append((m.begin(), m.end()))
        if not spans:
            return text

        # 隣接する人名 span を統合 (姓+名 → 1 つの ※氏名※)
        merged = [list(spans[0])]
        for b, e in spans[1:]:
            if b <= merged[-1][1]:
                merged[-1][1] = max(merged[-1][1], e)
            else:
                merged.append([b, e])

        # 後方から置換し、前方の文字位置を保つ
        out = text
        for b, e in reversed(merged):
            out = out[:b] + "※氏名※" + out[e:]
        return out

    @staticmethod
    def search_text(doc: dict) -> str:
        """hybird検索の対象テキスト = 表題 + 相談内容。"""
        parts = [doc["title"], doc["soudan"]]
        text = "\n".join(p for p in parts if p)
        return text or " "   # 空だと埋め込みが 0 ベクトルになるのを回避

    # -- 登録 (索引作成)
    def index_documents(self, docs, batch_size: int = 256):
        """QA レコードの dict リストを 3 tableへ登録
        埋め込みは batch_size 件ずまとめて計算
        """
        logger.info("索引作成を開始: %d 件 (batch=%d)", len(docs), batch_size)
        cur = self.conn.cursor()
        texts = [self.search_text(d) for d in docs]
        total = len(docs)

        for start in range(0, total, batch_size):
            chunk = docs[start:start + batch_size]
            chunk_texts = texts[start:start + batch_size]

            # 形態素解析 + 同義語正規化は 1 文書につき 1 回だけ行い、
            # FTS 用 (空白区切り) と 埋め込み用 (連結) の両方を得る。
            analyzed = [self.analyzed(t) for t in chunk_texts]
            fts_texts = [" ".join(w for w, _ in a) for a in analyzed]
            emb_texts = ["".join(w for w, _ in a) or " " for a in analyzed]
            embeddings = self.embedder.encode(emb_texts)

            for doc, fts_text, emb in zip(chunk, fts_texts, embeddings):
                cur.execute(
                    """INSERT INTO documents
                       (soudan_no, uketsuke, honsha, title, soudan, tei_type,
                        busho, kaito, hm_or_tu, konkyo)
                       VALUES (?,?,?,?,?,?,?,?,?,?)""",
                    (doc["soudan_no"], doc["uketsuke"], doc["honsha"],
                     doc["title"], doc["soudan"], doc["tei_type"], doc["busho"],
                     doc["kaito"], doc["hm_or_tu"], doc["konkyo"]),
                )
                doc_id = cur.lastrowid
                # FTS5 には代表語へ正規化した検索テキストを rowid 揃えて登録
                cur.execute(
                    "INSERT INTO documents_fts(rowid, text) VALUES (?,?)",
                    (doc_id, fts_text),
                )
                # ベクトルは float32 をシリアライズして登録
                cur.execute(
                    "INSERT INTO documents_vec(rowid, embedding) VALUES (?,?)",
                    (doc_id, sqlite_vec.serialize_float32(emb)),
                )
            self.conn.commit()
            logger.info("登録 %d/%d", min(start + batch_size, total), total)

    # -- 検索
    def fts_search(self, query: str, top_k: int = 10):
        """全文検索。返り値: [(doc_id, bm25_score), ...] スコア昇順(良い順)。"""
        match = self.build_fts_query(query)
        logger.debug("fts_search: query=%r match=%r", query, match)
        if not match:
            return []
        rows = self.conn.execute(
            """
            SELECT rowid, bm25(documents_fts) AS score
            FROM documents_fts
            WHERE documents_fts MATCH ?
            ORDER BY score
            LIMIT ?
            """,
            (match, top_k),
        ).fetchall()
        logger.debug("fts_search: %d 件ヒット", len(rows))
        return [(r[0], r[1]) for r in rows]

    def vector_search(self, query: str, top_k: int = 10):
        """vector近傍検索。返り値: [(doc_id, distance), ...] """
        # 索引側と同じく同義語を代表語へ正規化してから
        normalized = self.normalize_text(query)
        # logger.debug("vector_search: query=%r normalized=%r", query, normalized)
        q = self.embedder.encode(normalized)
        rows = self.conn.execute(
            """
            SELECT rowid, distance
            FROM documents_vec
            WHERE embedding MATCH ? AND k = ?
            ORDER BY distance
            """,
            (sqlite_vec.serialize_float32(q), top_k),
        ).fetchall()
        return [(r[0], r[1]) for r in rows]

    def _fetch_rows(self, doc_ids, detail):
        """doc_id 順に documents の各列を取得、detail をマージし返す """
        cols = ("soudan_no", "uketsuke", "honsha", "title", "soudan",
                "tei_type", "busho", "kaito", "hm_or_tu", "konkyo")
        results = []
        for doc_id in doc_ids:
            row = self.conn.execute(
                f"SELECT {', '.join(cols)} FROM documents WHERE id = ?",
                (doc_id,),
            ).fetchone()
            results.append({
                "id": doc_id,
                **dict(zip(cols, row)),
                **detail.get(doc_id, {}),
            })
        return results

    @staticmethod
    def _weighted_fuse(fts, vec, vec_weight, top_k):
        """FTS(bm25) と ベクトル(距離) のスコアを正規化して加重和で統合

        bm25 は -bm25(大きいほど良い)、距離は 1-距離(=類似度) に直し、
        各々 min-max 正規化 [0,1] してから
            score = vec_weight*類似度 + (1-vec_weight)*bm25
        で合成する。片方にしか出ない文書はその成分を 0 とする (OR 的)。
        返り値: [(doc_id, fused_score), ...] 降順。
        """
        fts_rel = {d: -s for d, s in fts}             # bm25 → 大きいほど良い
        vec_rel = {d: 1.0 - dist for d, dist in vec}  # 距離 → 類似度

        def norm(m):
            if not m:
                return {}
            lo, hi = min(m.values()), max(m.values())
            if hi <= lo:
                return {k: 1.0 for k in m}
            return {k: (v - lo) / (hi - lo) for k, v in m.items()}

        fn, vn = norm(fts_rel), norm(vec_rel)
        scores = {}
        for d in set(fn) | set(vn):
            scores[d] = \
                vec_weight * vn.get(d, 0.0) + (1 - vec_weight) * fn.get(d, 0.0)
        return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k]

    def _doc_text(self, doc_id, limit: int = 1500) -> str:
        """re-ranker入力用テキスト (表題+相談内容, 生テキスト) """
        row = self.conn.execute(
            "SELECT title, soudan FROM documents WHERE id = ?", (doc_id,)
        ).fetchone()
        return "\n".join(p for p in row if p)[:limit]

    def hybrid_search(self, query: str, top_k: int = 5,
                      candidates: int = None, rerank: bool = None):
        """hybrid検索 (加重和 fusion + cross-encoder re-rank)。

        FTS(bm25) と ベクトル(cosine) を独立検索し、正規化スコアの加重和で
        統合する。candidates / rerank を省略 (None) した場合は global 変数
        HYBRID_SEARCH_1ST_CANDIDATES / RERANK_ENABLE を使う

        rerank=True の場合、加重和で集めた上位候補を日本語 cross-encoder で
        再スコアし、最終順位を決める。
        """
        if candidates is None:
            candidates = HYBRID_SEARCH_1ST_CANDIDATES
        if rerank is None:
            rerank = RERANK_ENABLE

        # リランクする場合は 1 段目で広めに集める
        fusion_k = max(top_k, RERANK_CANDIDATES) if rerank else top_k

        # FTS と ベクトルを独立検索し、正規化スコアの加重和で統合する。
        fts = self.fts_search(query, candidates)      # [(id, bm25)] スコア良い順
        vec = self.vector_search(query, candidates)   # [(id, dist)] 距離近い順
        fts_rank = {d: r for r, (d, _) in enumerate(fts, start=1)}
        vec_rank = {d: r for r, (d, _) in enumerate(vec, start=1)}
        bm25_of = dict(fts)
        dist_of = dict(vec)

        fused = self._weighted_fuse(fts, vec, HYBRID_SEARCH_VEC_WEIGHT, fusion_k)
        fused_of = dict(fused)
        ordered_ids = [d for d, _ in fused]

        # 各指標を付与 (相手側の候補外なら rank は None)
        detail = {
            d: {
                "distance": dist_of.get(d),
                "bm25": bm25_of.get(d),
                "fts_rank": fts_rank.get(d),
                "vec_rank": vec_rank.get(d),
                "fused": fused_of.get(d),
            }
            for d in ordered_ids
        }

        # --- Cross-encoder リランキング。また、rerank スコアで足切り
        if rerank and ordered_ids:
            pairs = [(d, self._doc_text(d)) for d in ordered_ids]
            ranked = self.reranker.rank(query, pairs)   # [(id, score)] 降順
            for rank, (d, s) in enumerate(ranked, start=1):
                detail.setdefault(d, {}).update(rerank_score=s, rerank_rank=rank)
            # rerank スコアが RERANK_MIN_SCORE 未満の文書を足切り
            kept = [d for d, s in ranked if s >= RERANK_MIN_SCORE]
            if len(kept) < len(ranked):
                logger.info("rerank 足切り: %d/%d 件が閾値 %.2f 未満で除外",
                            len(ranked) - len(kept), len(ranked), RERANK_MIN_SCORE)
            ordered_ids = kept[:top_k]
        else:
            ordered_ids = ordered_ids[:top_k]

        return self._fetch_rows(ordered_ids, detail)

    # -- サブコマンド
    def make_db(self, csv_path: Path):
        """指定 CSV から hybrid.db を作成する。"""
        self.create_schema()

        n = self.count()
        if n > 0:
            logger.info("既に %d 件登録。%s を削除し再実行", n, self.db_path)
            return

        docs = self.load_qa_csv(csv_path)
        logger.info("%s から %d 件を読み込み", Path(csv_path).name, len(docs))
        self.index_documents(docs)
        logger.info("完了: %s に %d 件を登録", self.db_path, len(docs))

    @staticmethod
    def _fmt(v, spec=""):
        """None は '-'、数値は書式付きで返す (表示用)。"""
        return "-" if v is None else format(v, spec)

    @staticmethod
    def _collapse_blank_lines(text: str) -> str:
        """空白行(連続改行)を改行 1 個に集約し、前後の空白を除く。"""
        if not text:
            return ""
        return re.sub(r"[ \t]*\r?\n[ \t\r\n]*", "\n", text).strip()

    def print_results(self, heading, results):
        print(f"\n=== {heading} ===")
        for i, r in enumerate(results, 1):
            if isinstance(r, dict):   # hybrid_search の結果
                # 1 行目: 順位・相談No・各メトリクス
                metrics = (f"rerank={self._fmt(r.get('rerank_score'), '.4f')}, "
                           f"fused={self._fmt(r.get('fused'), '.4f')}, "
                           f"dist={self._fmt(r.get('distance'), '.4f')}, "
                           f"bm25={self._fmt(r.get('bm25'), '.4f')}, "
                           f"fts_rank={self._fmt(r.get('fts_rank'))}, "
                           f"vec_rank={self._fmt(r.get('vec_rank'))}")
                print(f"{i}. [{r['soudan_no']}] {metrics}")
                print("表題:", r.get("title") or "")
                print("相談内容:\n", self._collapse_blank_lines(r.get("soudan")))
                print()   # レコード区切り
            else:   # fts_search / vector_search の (id, score)
                doc_id, score = r
                row = self.conn.execute(
                    "SELECT soudan_no, title FROM documents WHERE id = ?",
                    (doc_id,),
                ).fetchone()
                print(f"{i}. [{row[0]}] {row[1]}  (score={score:.4f})")

    # -- HTML 出力
    _HTML_STYLE = """
      body{color:#222;padding:0 1rem}
      h1{font-size:1.4rem}
      h2{font-size:1.1rem;border-bottom:2px solid #ccc;}
      .query{background:#f2f6fc; border-left:4px solid #4477cc;
             white-space:pre-wrap}
      .hit{border:1px solid #ddd;}
      .head{font-weight:bold}
      .no{color:#4477cc}
      .metrics{font-size:.8rem; color:#666; font-weight:normal}
      .title{font-weight:bold;}
      .label{display:inline-block; color:#fff;background:#88a;}
      .body{white-space:pre-wrap; background:#fafafa;}
    """

    @staticmethod
    def _soudan_link(soudan_no: str) -> str:
        """相談番号を詳細画面 URL へのリンク付き HTML ([番号]) にする。"""
        no = html.escape(soudan_no or "")
        url = html.escape(SOUDAN_URL.format(quote(soudan_no or "")))
        return (f'[<a href="{url}" target="_blank" rel="noopener">{no}</a>]')

    def _result_html(self, results) -> str:
        """1 件分の検索結果リストを HTML 断片にする。"""
        def field(label, text):
            body = html.escape(self._collapse_blank_lines(text)).replace("\n", "<br>")
            return (f'<div class="field"><span class="label">{label}</span>'
                    f'<div class="body">{body}</div></div>')

        blocks = []
        for i, r in enumerate(results, 1):
            metrics = (f"rerank={self._fmt(r.get('rerank_score'), '.4f')}, "
                       f"fused={self._fmt(r.get('fused'), '.4f')}, "
                       f"dist={self._fmt(r.get('distance'), '.4f')}, "
                       f"bm25={self._fmt(r.get('bm25'), '.4f')}, "
                       f"fts_rank={self._fmt(r.get('fts_rank'))}, "
                       f"vec_rank={self._fmt(r.get('vec_rank'))}")
            blocks.append(
                f'<div class="hit">'
                f'<div class="head">{i}. '
                f'<span class="no">{self._soudan_link(r["soudan_no"])}</span> '
                f'<span class="title">{html.escape(r.get("title") or "")}</span></div>'
                f'<div class="metrics">{html.escape(metrics)}</div>'
                f'{field("相談内容", r.get("soudan") or "")}'
                f'{field("回答内容", r.get("kaito") or "")}'
                f'</div>'
            )
        return "\n".join(blocks)

    def write_html(self, path, sections, source=None):
        """sections=[(heading, query, results), ...] を 1 つの HTMLに出力。

        source を渡すと <h1> と <title> に検索元の 相談番号・表題 を出す。
        """
        parts = []
        for heading, query, results in sections:
            parts.append(
                f'<div class="query">{html.escape(query.strip())}</div>')
            parts.append(f"<div>{html.escape(heading)}</div>")
            parts.append(self._result_html(results))

        no, ttl = source.get("soudan_no") or "", source.get("title") or ""
        # <title> はプレーンテキスト、<h1> は相談番号をリンク化
        title_txt = html.escape(f"[{no}] {ttl}".strip())
        h1_html = f"{self._soudan_link(no)} {html.escape(ttl)}".strip() if no \
            else html.escape(ttl)

        doc = (
            '<!doctype html>\n<html lang="ja"><head><meta charset="utf-8">'
            f"<title>{title_txt}</title>"
            f"<style>{self._HTML_STYLE}</style></head><body>"
            f"<h1>{h1_html}</h1> "
            + "\n".join(parts) +
            "</body></html>"
        )
        Path(path).write_text(doc, encoding="utf-8")
        logger.info("HTML 出力: %s", path)

    def search_test(self, csv_path: Path):
        """クエリ CSV の各行(表題+相談内容)で検索し、相談番号.html に出力 """
        n = self.count()
        if n == 0:
            logger.warning( "%s が空", self.db_path)
            return

        cases = self.load_qa_csv(csv_path)
        logger.info("%s から %d 件のクエリを読み込みました。",
                    Path(csv_path).name, len(cases))

        out_dir = Path(__file__).resolve().parent / "html"
        out_dir.mkdir(exist_ok=True)   # html dir が無ければ作成

        # global 変数 HYBRID_SEARCH_1ST_CANDIDATES / RERANK_ENABLE に従う
        heading = f"AI類似検索結果 (keyword + vector)"
        for i, case in enumerate(cases):
            query = self.search_text(case)        # 表題 + 相談内容
            logger.info("\n【%d/%d】相談No %s",
                        i + 1,
                        len(cases),
                        case["soudan_no"])
            results = self.hybrid_search(query, 20)
            self.print_results(heading, results)  # consoleにも表示

            # クエリ欄は相談内容のみ (表題は <h1> に出す)。回答内容があれば併記
            query_html = case.get("soudan") or ""

            # 相談番号.html に出力 (ファイル名に使えない文字は _ に置換)
            safe = re.sub(r'[\\/:*?"<>|]', "_", case["soudan_no"])
            out = out_dir / f"{safe}.html"
            self.write_html(out, [(heading, query_html, results)], source=case)


if __name__ == "__main__":
    main()

sudachi.rs を wasm版へ build

https://end0tknr.hateblo.jp/entry/20240918/1726660692

上記entryに記載しているように、 これまで形態素解析には、サーバ側?に sudachi.rs for pythonを使用し、 ブラウザ側に kuromoji.js を使用していました。

しかし、この構成では、ユーザ辞書をサーバ側とブラウザ側で、 異なった形式にする必要がありましたので、sudachi.rs を wasm版へ build。

ただし、WorksApplications/sudachi.rs の状態からの wasm版作成は、 かなり手間だと思いましたので、f3liz-casa/sudachi-wasm に対し、 ユーザ辞書と、同義語辞書を対応した上で、wasm化。

目次

前提tool - git, python, gcc

C:\Users\end0t\tmp\SUDACHI_WASM> where git
  C:\Program Files\Git\cmd\git.exe
C:\Users\end0t\tmp\SUDACHI_WASM> git --version
  git version 2.51.0.windows.2
  
C:\Users\end0t\tmp\SUDACHI_WASM> ..\..\python3.12\python.exe --version
Python 3.12.10

C:\Users\end0t\tmp\SUDACHI_WASM> where gcc
C:\mingw64\bin\gcc.exe

C:\Users\end0t\tmp\SUDACHI_WASM> gcc --version
gcc (x86_64-posix-seh-rev0, Built by MinGW-W64 project) 8.1.0

Rust導入

Rust本体

https://rust-lang.org/ja/tools/install/ にある

https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe を ダウンロードし、実行。

Rustは Visual C++ を必要としますが、PCにこれがない場合、 以下の1にある Visual Studio Community インストールを選択。 すると、Visual Studio Communityのインストーラが自動ダウンロードされ、 インストールが行われます。

warn: C:\Users\end0t\.rustup\settings.toml
warn: Rustup will install the default toolchain as specified in the settings file,
warn: instead of the one inferred from the default host triple.

Rust Visual C++ prerequisites

Rust requires a linker and Windows API libraries but they don't seem to be
available.
These components can be acquired through a Visual Studio installer.

1) Quick install via the Visual Studio Community installer
   (free for individuals, academic uses, and open source).
2) Manually install the prerequisites
   (for enterprise and advanced users).
3) Don't install the prerequisites
   (if you're targeting the GNU ABI).

>1

info: downloading Visual Studio installer
info: running the Visual Studio install
info: rustup will continue once Visual Studio installation is complete

Visual Studioのインストール完了後、続けて、Rustがインストール。(以下)

Welcome to Rust!

This will download and install the official compiler for the Rust
programming language, and its package manager, Cargo.

Rustup metadata and toolchains will be installed into the Rustup
home directory, located at:

  C:\Users\end0t\.rustup

This can be modified with the RUSTUP_HOME environment variable.

The Cargo home directory is located at:

  C:\Users\end0t\.cargo

This can be modified with the CARGO_HOME environment variable.

The cargo, rustc, rustup and other commands will be added to
Cargo's bin directory, located at:

  C:\Users\end0t\.cargo\bin

This path will then be added to your PATH environment variable by
modifying the PATH registry key at HKEY_CURRENT_USER\Environment.

You can uninstall at any time with rustup self uninstall and
these changes will be reverted.

Current installation options:


   default host triple: x86_64-pc-windows-msvc
     default toolchain: stable (default)
               profile: default
  modify PATH variable: yes

1) Proceed with standard installation (default - just press enter)
2) Customize installation
3) Cancel installation
>
info: profile set to default
info: default host triple is x86_64-pc-windows-msvc
info: syncing channel updates for stable-x86_64-pc-windows-msvc
info: latest update on 2026-07-16 for version 1.97.1 (8bab26f4f 2026-07-14)
info: downloading 6 components
        cargo installed                        9.71 MiB
       clippy installed                        4.04 MiB
    rust-docs installed                       22.76 MiB
     rust-std installed                       21.56 MiB
        rustc installed                       69.04 MiB
      rustfmt installed                        2.46 MiB
info: default toolchain set to stable-x86_64-pc-windows-msvc
  stable-x86_64-pc-windows-msvc installed - rustc 1.97.1 (8bab26f4f 2026-07-14)

Rust is installed now. Great!

To get started you may need to restart your current shell.
This would reload its PATH environment variable to include
Cargo's bin directory (%USERPROFILE%\.cargo\bin).

Press the Enter key to continue.
C:\Users\end0t> where rustup
C:\Users\end0t\.cargo\bin\rustup.exe

C:\Users\end0t>rustup --version
rustup 1.29.0 (28d1352db 2026-03-05)
info: This is the version for the rustup toolchain manager, not the rustc compiler.
info: the currently active `rustc` version is `rustc 1.97.1 (8bab26f4f 2026-07-14)`

WASM ターゲット追加

rustインストール直後の状態では、 wasmをbuildするターゲットが含まれていない為、追加します。

2026/7現在、64bit版の wasm64-unknown-unknown があるように見えますが、 正式版ではないようですので、wasm32-unknown-unknown をインストール。

C:\Users\end0t>rustc --print target-list
  :
wasm32-unknown-emscripten
wasm32-unknown-unknown
  :
wasm64-unknown-unknown
  :
C:\Users\end0t>rustup target add wasm64-unknown-unknown
error: toolchain 'stable-x86_64-pc-windows-gnu' has no prebuilt artifacts available for target 'wasm64-unknown-unknown'
note: this may happen to a low-tier target as per https://doc.rust-lang.org/nightly/rustc/platform-support.html
note: you can find instructions on that page to build the target support from source
C:\Users\end0t>rustup target add wasm32-unknown-unknown
info: downloading component rust-std
     rust-std installed                       20.93 MiB
     
C:\Users\end0t>rustup show
Default host: x86_64-pc-windows-gnu
rustup home:  C:\Users\end0t\.rustup

installed toolchains
--------------------
stable-x86_64-pc-windows-gnu (active, default)

active toolchain
----------------
name: stable-x86_64-pc-windows-gnu
active because: it's the default toolchain
installed targets:
  wasm32-unknown-unknown
  x86_64-pc-windows-gnu

wasm-pack を導入

https://wasm-bindgen.github.io/wasm-pack/installer/ より wasm-pack-init.exe をダウンロード & インストール。

https://github.com/wasm-bindgen/wasm-pack/releases/download/v0.15.0/wasm-pack-init.exe

C:\Users\end0t>where wasm-pack
C:\Users\end0t\.cargo\bin\wasm-pack.exe

C:\Users\end0t>wasm-pack --version
wasm-pack 0.15.0

f3liz-casa/sudachi-wasm の改造 - ユーザ辞書・同義語対応

C:\Users\end0t\tmp\SUDACHI_WASM> git clone --recursive https://github.com/f3liz-casa/sudachi-wasm.git

sudachi-wasm/sudachi.rs/sudachi/src/dic/dictionary.rs

L.73 from_system_bytes(...) の直後に、システム+ユーザ辞書を受取る関数を追加

    /// system辞書に加え、0個以上のuser辞書をbyte列から読む為
    pub fn from_system_and_user_bytes(
        system_dict: Vec<u8>,
        user_dicts: Vec<Vec<u8>>,
    ) -> SudachiResult<JapaneseDictionary> {
        let cfg = Config::new_embedded()?;
        let mut storage = SudachiDicData::new(Storage::Owned(system_dict));
        for user in user_dicts {
            storage.add_user(Storage::Owned(user));
        }
        Self::from_cfg_storage_with_embedded_chardef(&cfg, storage)
    }

sudachi-wasm/Cargo.toml

[dependencies] に js-sys を追加

[dependencies]
sudachi = { path = "./sudachi.rs/sudachi", default-features = false, features = ["marisa-trie"] }
rsmarisa = { path = "./sudachi.rs/rsmarisa", default-features = false, features = ["wasm"] }
wasm-bindgen = "0.2"
js-sys = "0.3" ## 追加
serde = { version = "1.0", features = ["derive"] }
serde-wasm-bindgen = "0.6"
console_error_panic_hook = "0.1"

sudachi-wasm/src/lib.rs`

MorphemeData 構造体に同義語グループIDのフィールドを追加

struct MorphemeData {
    /// Surface form as it appears in the input text.
    surface: String,
    /// Dictionary (base) form.
    dictionary_form: String,
    /// Reading in katakana.
    reading_form: String,
    /// Normalized form.
    normalized_form: String,
    /// Part-of-speech components (up to 6 fields, e.g. `["名詞","普通名詞","一般","*","*","*"]`).
    part_of_speech: Vec<String>,
    /// `true` when the word was not found in any dictionary (out-of-vocabulary).
    is_oov: bool,
    synonym_group_ids: Vec<u32>, /// 同義語group id
    /// Start character offset in the original string (Unicode code-point index).
    begin: usize,
    /// End character offset in the original string (Unicode code-point index, exclusive).
    end: usize,
}

tokenize 内の MorphemeData { ... } 生成箇所に 1 行追加

        let morphemes: Vec<MorphemeData> = result
            .iter()
            .map(|m| MorphemeData {
                surface: m.surface().to_string(),
                dictionary_form: m.dictionary_form().to_string(),
                reading_form: m.reading_form().to_string(),
                normalized_form: m.normalized_form().to_string(),
                part_of_speech: m.part_of_speech().to_vec(),
                is_oov: m.is_oov(),
                synonym_group_ids: m.synonym_group_ids().to_vec(), // 追加
                begin: m.begin_c(),
                end: m.end_c(),
            })
            .collect();

[wasm_bindgen(constructor)] pub fn new(...) の直後に、ユーザ辞書対応の コンストラクタを追加

    #[wasm_bindgen(js_name = withUserDicts)]
    pub fn with_user_dicts(
        dict_bytes: &[u8],
        user_dicts: js_sys::Array,
    ) -> Result<Tokenizer, JsError> {
        let users: Vec<Vec<u8>> = user_dicts
            .iter()
            .map(|v| js_sys::Uint8Array::new(&v).to_vec())
            .collect();
        let dict = Arc::new(
            JapaneseDictionary::from_system_and_user_bytes(dict_bytes.to_vec(), users)
                .map_err(|e| JsError::new(&e.to_string()))?,
        );
        Ok(Tokenizer {
            inner: StatelessTokenizer::new(dict),
        })
    }

sudachi-wasm/src/bin/dic_converter.rs

SudachiPy 製ユーザ辞書を変換する際、トライ探索が無限再帰しクラッシュするようですので、 extract_yada_entries と yada_dfs を visited ガード付きに置き換え

fn extract_yada_entries(array: &[u32]) -> Vec<(Vec<u8>, u32)> {
    let mut results = Vec::new();
    if array.is_empty() {
        return results;
    }
    let root_offset = yada_offset(array[0]) as usize;
    let mut path = Vec::new();
    let mut visited = vec![false; array.len()];
    yada_dfs(array, root_offset, &mut path, &mut results, &mut visited);
    results
}

fn yada_dfs(
    array: &[u32],
    node_pos: usize,
    path: &mut Vec<u8>,
    results: &mut Vec<(Vec<u8>, u32)>,
    visited: &mut [bool],
) {
    if node_pos >= array.len() || visited[node_pos] {
        return;
    }
    visited[node_pos] = true;
    for k in 0u16..=255 {
        let child_pos = node_pos ^ (k as usize);
        if child_pos >= array.len() {
            continue;
        }
        let unit = array[child_pos];
        if yada_label(unit) != k as u32 {
            continue;
        }
        path.push(k as u8);
        let new_node_pos = child_pos ^ yada_offset(unit) as usize;
        if yada_has_leaf(unit) && new_node_pos < array.len() {
            results.push((path.clone(), yada_value(array[new_node_pos])));
        }
        yada_dfs(array, new_node_pos, path, results, visited);
        path.pop();
    }
    visited[node_pos] = false;
}

build

sudachi-wasm

GitがHTTPS接続時に実施する証明書失効確認(CRL/OCSP)を無効化

C:\Users\end0t\tmp\SUDACHI_WASM\sudachi-wasm> git config --global http.schannelCheckRevoke false

Cargoが crates.io にアクセスする際の証明書失効確認を無効化し、Cargoから通信テスト

C:\Users\end0t\tmp\SUDACHI_WASM\sudachi-wasm> set CARGO_HTTP_CHECK_REVOKE=false
C:\Users\end0t\tmp\SUDACHI_WASM\sudachi-wasm> cargo search serde
    Updating crates.io index
serde = "1.0.229"                 # A generic serialization/deserialization framework
serde-saphyr = "1.0.0-rc.1"       # YAML (de)serializer for Serde, emphasizing panic-free parsing, fast builds and good error rep…
clickhouse = "0.15.1"             # Official Rust client for ClickHouse DB
serde_yml = "0.0.13"              # DEPRECATED — `serde_yml` is unmaintained. This release is a thin compatibility shim that forw…
serde_valid = "2.0.3"             # JSON Schema based validation tool using serde.
serde_json = "1.0.151"            # A JSON serialization file format
yaml_serde = "0.10.4"             # serde_yaml maintained by The YAML Organization
serde_json_borrow = "0.9.0"       # Provides JSON deserialization into a borrowed DOM
rust_decimal = "1.42.1"           # Decimal number implementation written in pure Rust suitable for financial and fixed-precision…
rust_decimal_macros = "1.40.0"    # Shorthand macros to assist creating Decimal types.
... and 18987 crates more (use --limit N to see more)
note: to learn more about a package, run `cargo info <name>`
C:\Users\end0t\tmp\SUDACHI_WASM\sudachi-wasm> wasm-pack build --target web --release --out-dir pkg
Error: `cargo metadata` exited with an error:     Updating crates.io index
    Updating git repository `https://github.com/KillingSpark/zstd-rs.git`
warning: spurious network error (3 tries remaining): [35] SSL connect error (schannel:
next InitializeSecurityContext failed: CRYPT_E_NO_REVOCATION_CHECK (0x80092012) - 失効の関数は証明書の失効を確認できませんでした。)
warning: spurious network error (2 tries remaining): [35] SSL connect error (schannel:
next InitializeSecurityContext failed: CRYPT_E_NO_REVOCATION_CHECK (0x80092012) - 失効の関数は証明書の失効を確認できませんでした。)
warning: spurious network error (1 try remaining): [35] SSL connect error (schannel:
next InitializeSecurityContext failed: CRYPT_E_NO_REVOCATION_CHECK (0x80092012) - 失効の関数は証明書の失効を確認できませんでした。)
warning: spurious network error (3 tries remaining): [35] SSL connect error (schannel:
next InitializeSecurityContext failed: CRYPT_E_NO_REVOCATION_CHECK (0x80092012) - 失効の関数は証明書の失効を確認できませんでした。)

【省略】

Caused by:
  failed to load source for dependency `console_error_panic_hook`

Caused by:
  unable to update registry `crates-io`

Caused by:
  download of config.json failed

Caused by:
  [35] SSL connect error (schannel: next InitializeSecurityContext failed:
  CRYPT_E_NO_REVOCATION_CHECK (0x80092012) - 失効の関数は証明書の失効を確認できませんでした。)
C:\Users\end0t\tmp\SUDACHI_WASM\sudachi-wasm> wasm-pack build --target web --release --out-dir pkg
[INFO]: Checking for the Wasm target...
[INFO]: Compiling to Wasm...
   Compiling unicode-ident v1.0.24
   Compiling proc-macro2 v1.0.106
   Compiling quote v1.0.45
   Compiling wasm-bindgen-shared v0.2.114
【省略】
   Compiling wasm-bindgen-macro v0.2.114
【省略】
[========================>  ] 84/88: sudachi, js-sys
   Compiling serde-wasm-bindgen v0.6.5
   Compiling sudachi-wasm v0.6.11-a1 (C:\Users\end0t\tmp\SUDACHI_WASM\sudachi-wasm)
    Finished `release` profile [optimized] target(s) in 36.76s
[INFO]: Installing wasm-bindgen...
【省略】
  Downloaded adler2 v2.0.1
  Downloaded alloc-stdlib v0.2.4
【省略】
   Compiling wasm-bindgen-cli v0.2.114
    Finished `release` profile [optimized] target(s) in 1m 43s
warning: the following packages contain code that will be rejected by a future version of Rust: buf_redux v0.8.4, multipart v0.18.0
note: to see what the problems were, use the option `--future-incompat-report`, or run `cargo report future-incompatibilities --id 1`
  Installing C:\Users\end0t\AppData\Local\.wasm-pack\.wasm-bindgen-cargo-install-0.2.114\bin\wasm-bindgen-test-runner.exe
  Installing C:\Users\end0t\AppData\Local\.wasm-pack\.wasm-bindgen-cargo-install-0.2.114\bin\wasm-bindgen.exe
  Installing C:\Users\end0t\AppData\Local\.wasm-pack\.wasm-bindgen-cargo-install-0.2.114\bin\wasm2es6js.exe
   Installed package `wasm-bindgen-cli v0.2.114` (executables `wasm-bindgen-test-runner.exe`, `wasm-bindgen.exe`, `wasm2es6js.exe`)
warning: be sure to add `C:\Users\end0t\AppData\Local\.wasm-pack\.wasm-bindgen-cargo-install-0.2.114\bin` to your PATH to be able to run the installed binaries
[INFO]: License key is set in Cargo.toml but no LICENSE file(s) were found; Please add the LICENSE file(s) to your project directory
[INFO]: :-) Done in 2m 23s
[INFO]: :-) Your wasm pkg is ready to publish at C:\Users\end0t\tmp\SUDACHI_WASM\sudachi-wasm\pkg.
C:\Users\end0t\tmp\SUDACHI_WASM\sudachi-wasm>tree /F C:\Users\end0t\tmp\SUDACHI_WASM\sudachi-wasm\pkg
フォルダー パスの一覧:  ボリューム Windows
ボリューム シリアル番号は 00000226 EEF5:5D0A です
C:\USERS\END0T\TMP\SUDACHI_WASM\SUDACHI-WASM\PKG
    .gitignore
    package.json
    README.md
    sudachi_wasm.d.ts
    sudachi_wasm.js
    sudachi_wasm_bg.wasm
    sudachi_wasm_bg.wasm.d.ts

辞書変換ツール - dic_converter.exe

C:\Users\end0t\tmp\SUDACHI_WASM\sudachi-wasm> cargo build --release --bin dic_converter

C:\Users\end0t\tmp\SUDACHI_WASM\sudachi-wasm>dir "c:/Users/end0t/tmp/SUDACHI_WASM/sudachi-wasm/target/release"

2026/07/22  19:32    <DIR>          .
2026/07/22  18:10    <DIR>          ..
2026/07/22  18:09                 0 .cargo-artifact-lock
2026/07/22  18:09                 0 .cargo-build-lock
2026/07/22  18:09                 0 .cargo-lock
2026/07/22  19:31    <DIR>          .fingerprint
2026/07/22  19:31    <DIR>          build
2026/07/22  19:32    <DIR>          deps
2026/07/22  19:35             9,585 dic_converter.d
2026/07/22  19:32           931,840 dic_converter.exe
2026/07/22  19:32         1,798,144 dic_converter.pdb
2026/07/22  18:09    <DIR>          examples
2026/07/22  18:09    <DIR>          incremental

辞書を用意

システム辞書

SudachiPy 本体と core 辞書を導入

DOS> ..\..\python3.12\python.exe -m pip install "sudachipy>=0.6.11" sudachidict_core

installされた system.dic の場所を取得し コピー

import importlib.util, os, shutil
s = importlib.util.find_spec("sudachidict_core")
src = os.path.join(os.path.dirname(s.origin), "resources", "system.dic")
print(src)
C:\Users\end0t\tmp\SUDACHI_WASM>..\..\python3.12\python.exe foo.py
C:\Users\end0t\python3.12\Lib\site-packages\sudachidict_core\resources\system.dic

上記の system.dic を system_core.dic として、コピー

ユーザ辞書

user_lex.csv をで作成(18 列)。固有名詞は左右ID=4786 が目安:

形態素解析器,4786,4786,5000,形態素解析器,名詞,普通名詞,一般,*,*,*,ケイタイソカイセキキ,形態素解析器,*,*,*,*,*
すだちくん,4786,4786,3000,すだちくん,名詞,固有名詞,一般,*,*,*,スダチクン,すだちくん,*,*,*,*,*

各列は 見出し, 左ID, 右ID, コスト, 表層, 品詞1-4, 活用型, 活用形, 読み, 正規化, 辞書形ID, 分割種別, A分割, B分割, 語構造

sudachipy.exe で compile

C:\Users\end0t\tmp\SUDACHI_WASM>..\..\python3.12\Scripts\sudachipy.exe ubuild -s system_core.dic -o user.dic user_lex.csv
C:\Users\end0t\tmp\SUDACHI_WASM\user_lex.csv -> 2 in 0.00 sec
validate -> 2 in 0.00 sec
pos_table -> 2 in 0.00 sec
conn_matrix -> 6 in 0.00 sec
trie -> 1028 in 0.00 sec
word_id table -> 14 in 0.00 sec
word_params -> 16 in 0.00 sec
wordinfo_offsets -> 8 in 0.00 sec
wordinfos (copy only) -> 80 in 0.00 sec

システム辞書、ユーザ辞書をdic_converter.exeで MARISA形式へ変換

C:\Users\end0t\tmp\SUDACHI_WASM>.\sudachi-wasm\target\release\dic_converter.exe system_core.dic system_core.marisa.dic
Reading system_core.dic...
  Header version: 0xce9f011a92394434, has_grammar: true
    Trained connection matrix dictionary: 262144 bytes
  Connection matrix: 68.2 MB → 17.1 MB (4.0x ratio, zstd)
  YADA trie: 6886912 units (27547648 bytes)
  Extracted 1360888 trie entries
  MARISA trie: 10059568 bytes (2.7x compression)
    Trained word_infos dictionary: 262144 bytes
  Word infos: 96.1 MB → 26.0 MB (3.7x ratio, zstd, 128-record blocks)

Building reading trie (RTRI) for candidate lookup...
  Reading trie: 923554 unique readings
  Reading trie section: 14513078 bytes (923554 keys)

  Total: 207.3 MB → 83.2 MB (40.1% of original)
Done: system_core.marisa.dic
C:\Users\end0t\tmp\SUDACHI_WASM>.\sudachi-wasm\target\release\dic_converter.exe user.dic user.marisa.dic
Reading user.dic...
  Header version: 0xca9811756ff64fb0, has_grammar: true
    Skipping connection matrix dictionary training: Src size is incorrect
  Connection matrix: 0.0 MB → 0.0 MB (0.0x ratio, zstd)
  YADA trie: 256 units (1024 bytes)
  Extracted 2 trie entries
  MARISA trie: 4136 bytes (0.2x compression)
    Skipping word_infos dictionary training: Src size is incorrect
  Word infos: 0.0 MB → 0.0 MB (0.7x ratio, zstd, 128-record blocks)

Building reading trie (RTRI) for candidate lookup...
  Reading trie: 2 unique readings
  Reading trie section: 4162 bytes (2 keys)

  Total: 0.0 MB → 0.0 MB (614.1% of original)
Done: user.marisa.dic

同義語辞書作成

https://raw.githubusercontent.com/WorksApplications/SudachiDict/develop/src/main/text/synonyms.txt

をダウンロードし、以下のpython scriptを実行

import csv, json
groups = {}
with open("synonyms.txt", encoding="utf-8") as f:
    for row in csv.reader(f):
        if not row or not row[0].strip():
            continue
        gid, ctrl, notation = int(row[0]), row[2].strip(), row[8].strip()
        if not notation or ctrl == "2":   # ノイズ表記は除外
            continue
        groups.setdefault(gid, [])
        if notation not in groups[gid]:
            groups[gid].append(notation)
groups = {g: w for g, w in groups.items() if len(w) >= 2}
json.dump(groups, open("synonyms.json", "w", encoding="utf-8"),
          ensure_ascii=False, separators=(",", ":"))
print("groups:", len(groups))

dict.zip として圧縮

system_core.marisa.dic , user.marisa.dic , synonyms.json の 3ファイルを dict.zip として圧縮。

html , js , wasmを配備し、ブラウザでテスト

dir構成

  -rw-rw-rw-  1 Administrators Domain Users     8041 07-25 05:40 index.html
  -rw-rw-rw-  1 Administrators Domain Users 61097385 07-23 18:16 dict.zip
  -rw-rw-rw-  1 Administrators Domain Users  2289806 07-22 18:12 sudachi_wasm_bg.wasm
  -rw-rw-rw-  1 Administrators Domain Users    16904 07-22 18:12 sudachi_wasm.js

html + js

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sudachi.rs WASM</title>
<style>
  textarea { width: 600px; }
  table { border-collapse: collapse; width: 600px; }
  th, td { border: 1px solid #666; }
</style>
</head>
<body>
  
  <div class="card">
    <div class="row">
      <label>分割モード:
        <select id="mode">
          <option value="C" selected>C(最長・固有表現)</option>
          <option value="B">B(中間)</option>
          <option value="A">A(最短)</option>
        </select>
      </label>
      <label><input type="checkbox" id="syn" checked> 同義語を展開</label>
      <button id="run" disabled>解析する</button>
      <div id="status">WASM 初期化中…</div>
    </div>
    <p><textarea id="input">閉店した書店で形態素解析の本を購入する。
すだちくんは沢山商店街にいる。</textarea></p>
  </div>

  <div class="cols">
    <div class="card">
      <h2 style="font-size:1rem">Tokenizer.withUserDicts(sys, [user])</h2>
      <div id="out-user"></div>
    </div>
  </div>

<script type="module">
  import init, { Tokenizer } from "./sudachi_wasm.js";

  const statusEl = document.getElementById("status");
  const runBtn   = document.getElementById("run");
  let tkSys  = null; // system辞書
  let tkUser = null; // user辞書
  let synMap = null; // 同義語辞書

  // ZIP取得後、標準api DecompressionStream で解凍し
  // { ファイル名: Uint8Array } を返す
  async function loadZip(url, onProgress) {
      const res = await fetch(url);
      if (!res.ok) throw new Error(`${url}: ${res.status}`);
      // 進捗表示付きでダウンロード
      const total = +res.headers.get("content-length") || 0;
      const reader = res.body.getReader();
      const chunks = [];
      let received = 0;
      for (;;) {
          const { done, value } = await reader.read();
          if (done) break;
          chunks.push(value);
          received += value.length;
          if (onProgress) onProgress(received, total);
      }
      const buf = new Uint8Array(received);
      let p = 0; for (const c of chunks) { buf.set(c, p); p += c.length; }

      const dv = new DataView(buf.buffer);
      // End Of Central Directory (0x06054b50) を末尾から探す
      let eocd = -1;
      for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65535); i--) {
          if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
      }
      if (eocd < 0) throw new Error("ZIP: EOCD が見つかりません");
      const count = dv.getUint16(eocd + 10, true);
      let cd = dv.getUint32(eocd + 16, true);
      
      const out = {};
      for (let n = 0; n < count; n++) {
    if (dv.getUint32(cd, true) !== 0x02014b50) throw new Error("ZIP: 中央dir破損");
          const method   = dv.getUint16(cd + 10, true);
          const compSize = dv.getUint32(cd + 20, true);
          const nameLen  = dv.getUint16(cd + 28, true);
          const extraLen = dv.getUint16(cd + 30, true);
          const cmtLen   = dv.getUint16(cd + 32, true);
          const lho      = dv.getUint32(cd + 42, true);
          const name = new TextDecoder().decode(buf.subarray(cd + 46, cd + 46 + nameLen));
          // local headerからデータ開始位置を求める
          const lNameLen  = dv.getUint16(lho + 26, true);
          const lExtraLen = dv.getUint16(lho + 28, true);
          const dataStart = lho + 30 + lNameLen + lExtraLen;
          const comp = buf.subarray(dataStart, dataStart + compSize);
          if (method === 0) {           // 無圧縮
              out[name] = comp.slice();
          } else if (method === 8) {    // deflate → deflate-raw で解凍
              const stream = new Blob([comp]).stream()
                    .pipeThrough(new DecompressionStream("deflate-raw"));
              out[name] = new Uint8Array(await new Response(stream).arrayBuffer());
          } else {
              throw new Error(`ZIP: 未対応の圧縮方式 ${method}`);
          }
          cd += 46 + nameLen + extraLen + cmtLen;
      }
      return out;
  }

  // 同義語を展開
  function expandSynonyms(m) {
      if (!synMap || !m.synonymGroupIds || m.synonymGroupIds.length === 0) return [];
      
      const out = new Set();
      for (const gid of m.synonymGroupIds) {
          for (const w of (synMap[gid] || [])) {
              if (w !== m.surface && w !== m.normalizedForm) out.add(w);
          }
      }
      return [...out];
  }

  function render(el, morphemes, withSyn) {
      const rows = morphemes.map(m => {
          const syn = withSyn ? expandSynonyms(m) : [];
          const synCell = withSyn
                ? `<td>${syn.length ? syn.join("、 ") : '<span style="opacity:.4">—</span>'}</td>` : "";
          return `<tr>
      <td class="surface">${m.surface}</td>
      <td>${m.partOfSpeech.slice(0,4).join(",")}</td>
      <td>${m.readingForm}</td>
      ${synCell}
    </tr>`;
      }).join("");
      const synHead = withSyn ? "<th>同義語(展開)</th>" : "";
      el.innerHTML = `<table>
    <thead><tr><th>表層</th><th>品詞</th><th>読み</th>${synHead}</tr></thead>
    <tbody>${rows}</tbody></table>`;
  }
  
  function analyze() {
      const text = document.getElementById("input").value;
      const mode = document.getElementById("mode").value;
      const withSyn = document.getElementById("syn").checked;
      render(document.getElementById("out-user"), tkUser.tokenize(text, mode), withSyn);
}

(async () => {
  try {
      await init();  // sudachi_wasm_bg.wasm をロード
      
      // 辞書一式(system/user/synonyms)を 1つの zipで取得
      const files = await loadZip("./dict.zip", (r, t) => {
          const mb = (r / 1048576).toFixed(1);
          const pct = t ? ` (${(r / t * 100).toFixed(0)}%)` : "";
          statusEl.textContent = `辞書ダウンロード中… ${mb}MB${pct}`;
      });
      statusEl.textContent = "解凍・辞書構築中…";
      const sys  = files["system_core.marisa.dic"];
      const user = files["user.marisa.dic"];
      synMap = JSON.parse(new TextDecoder().decode(files["synonyms.json"]));
      tkSys  = new Tokenizer(sys);                    // system辞書のみ
      tkUser = Tokenizer.withUserDicts(sys, [user]);  // +user辞書
      statusEl.textContent =
          `準備完了(同義語 ${Object.keys(synMap).length.toLocaleString()} 件)`;
      runBtn.disabled = false;
      runBtn.addEventListener("click", analyze);
      document.getElementById("syn").addEventListener("change", analyze);
      analyze();
  } catch (e) {
      statusEl.textContent = "エラー: " + e.message;
      console.error(e);
  }
})();
</script>
</body>
</html>