0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Ubuntu 24.04 + RTX 4060 TiのローカルAI/RAG環境を運用向けに見直す r005 ― PDF再indexを世代管理する

0
Posted at

Ubuntu 24.04 + RTX 4060 TiのローカルAI/RAG環境を運用向けに見直す r005 ― PDF再indexを世代管理する

はじめに

Ubuntu 24.04のPCをローカルAIサーバーとして使い、Windows 11からOpen WebUIへ接続する環境を少しずつ作っています。

これまでに、コード開発支援はSerena、PDFナレッジ検索はDocling + FastAPI + Qdrantという形まで組みました。Ollamaを共通の推論基盤として使い、Open WebUIを入口にする構成です。

r004では、PDFの受付と重い解析をFastAPIのHTTP処理から分離し、Redis + Celery Workerへ渡すところまで整理しました。Embeddingも固定次元ではなくOllamaの実応答から取得し、PDF原本、抽出Markdown / JSON、DuckDBの処理台帳、Qdrantの検索データを分けています。

ここまで来ると、一見すると十分に動きそうです。

ただ、実装をもう一度追ってみると、実運用前に直しておきたい箇所が残っていました。

それがPDFの再indexです。

PDF解析の設定を変えたり、Embeddingモデルを変更したりすると、同じPDFをもう一度処理したくなります。r004では古いQdrant Pointを削除してから新しいPointを登録する流れだったため、新しいDocling解析やEmbeddingが途中で失敗すると、直前まで正常に検索できていたデータまで失う可能性がありました。

少数の検証PDFなら再登録すれば済みますが、社内資料を継続的に登録するようになると、この挙動は少し怖いです。

そこでr005では、サービス構成を大きく変えるのではなく、文書の取り込みを世代管理し、新しい世代が最後まで完成してから検索対象を切り替える方式へ変更しました。

この記事では、r005で追加した世代管理、r004からの移行、Celery / Redisの見直し、Qdrantのfilter index、テスト方法までまとめます。

プロジェクト名は次のとおりです。

local_ai_platform_r005

参考のために、ZIPファイルを置いておきますので、ダウンロードして活用してください。

2026年8月18日時点の構成です。Ollama、Open WebUI、Serena、Qdrantなどは更新が速いため、実際に導入するときは各公式ドキュメントとリリースノートも確認してください。

全体の操作イメージです。

20260819_b01.png


今回使う環境

検証対象はこれまでと同じです。

OS      : Ubuntu 24.04 Desktop
CPU     : Intel Core i5-11400F
Memory  : 64GB
Storage : 4TB
GPU     : NVIDIA GeForce RTX 4060 Ti 16GB
Client  : Windows 11

Windows 11側にはAI環境を構築せず、ブラウザからUbuntu上のOpen WebUIへ接続します。

今回のr005で使うサービスは次の7つです。

ollama
open-webui
serena
qdrant
redis
rag-api
rag-worker

Docker Compose上では、Ollama、Qdrant、Redis、Serena、RAG APIを基本的にDocker networkの内側へ置き、通常時にホストへ公開するのはOpen WebUIだけにしています。


r005の全体構成

全体の流れは次のようになります。

20260819_b02.png

Open WebUIには簡単に試せるKnowledge機能がありますが、独自RAG経路も残しています。

理由は、実務資料では検索結果だけでなく、PDF原本、SHA-256、処理状態、取り込み世代、使用したEmbeddingモデル、ページ番号、見出し、年度、発注者、分野、機密区分、エラー内容まで後から追いたいためです。

Qdrantは検索、DuckDBは処理台帳という役割分担です。


r004で残っていた再indexの問題

r004の処理を単純化すると、次のような流れでした。

現在のQdrant Point
        |
        v
既存Pointを削除
        |
        v
Doclingで再解析
        |
        v
Embedding
        |
        v
新Pointを登録

この方式では、途中で処理が止まった場合に問題があります。

例えば、100ページを超えるPDFを再処理していて、途中でOllamaへの接続が失敗したとします。

新しいPointは一部しか完成していません。一方、古いPointはすでに削除されています。

つまり、再処理を始める前には正常に検索できていた文書が、再処理の失敗によって検索できなくなる可能性があります。

そこでr005では、「古いものを消してから新しく作る」のをやめました。


document_idingestion_idを分ける

r005では、文書そのものと、その文書を解析した世代を別に扱います。

PDF原本はSHA-256から作るdocument_idで識別します。

DOC_1234567890ABCDEF

同じPDFを再解析するときは、新しいingestion_idを発行します。

Document
  document_id = DOC_1234567890ABCDEF

  Ingestion 1
    ingestion_id = ING_aaa...

  Ingestion 2
    ingestion_id = ING_bbb...

QdrantのPoint IDにもingestion_idを含めます。

def point_id(document_id: str, ingestion_id: str, chunk_index: int) -> str:
    return str(
        uuid.uuid5(
            uuid.NAMESPACE_URL,
            f"local-ai-rag:{document_id}:{ingestion_id}:{chunk_index}",
        )
    )

これで、古い正常世代と新しい処理中世代を同時に保持できます。


新しい世代はactive=falseで作る

r005の再indexは次の流れです。

20260819_b02.png

新しいPointは、すべて次の状態でQdrantへ登録します。

{
  "document_id": "DOC_...",
  "ingestion_id": "ING_...",
  "active": false
}

Docling、chunking、Embedding、Qdrantへの全バッチ登録が終わるまで、active=trueにはしません。

そのため、処理途中のデータを検索対象から外せます。


QdrantとDuckDBの切替をどう扱うか

QdrantとDuckDBは別のデータベースなので、1つのDB transactionとしてまとめることはできません。

r005では、短い世代切替区間だけFileLockを使います。

rag_generation_cutover.lock

切替順は次です。

1. 新世代をQdrantで active=true
2. DuckDBの active_ingestion_id を新世代へ変更
3. 旧世代をQdrantで active=false
4. FileLockを解除
5. 旧世代を物理削除

検索API側も同じFileLockを取ります。

さらに、Qdrantからactive=trueのPointが返ってきても、そのまま信用しません。

Qdrant result.ingestion_id
        ==
DuckDB active_ingestion_id

まで確認して検索結果として返します。

厳密な分散transactionではありませんが、今回の1台構成で世代を切り替える方法としては扱いやすい形になりました。


再indexに失敗しても前の正常世代を残す

例えば現在、

ING_A active=true

だったとします。

再indexでING_Bを作っている途中にDoclingやOllamaが失敗すると、DuckDBは次のようになります。

status=error
active_ingestion_id=ING_A
last_ingestion_id=ING_B

つまり、処理状態はerrorでも、直前の正常世代は残っています。

現在設定しているEmbeddingモデルとING_Aのモデルが一致していれば、APIのsearchableはtrueのままにできます。

「最新の再処理は失敗した」と「検索可能な正常データがある」を分けて扱います。


Markdown / JSONも世代ごとに残す

検索ベクトルだけ世代管理しても、抽出したMarkdownやJSONを毎回上書きすると、後から比較できません。

r005では、Doclingの抽出成果もingestion_id単位で保存します。

data/
└── extracted/
    ├── markdown/
    │   └── DOC_XXXXXXXX/
    │       ├── ING_aaa.md
    │       └── ING_bbb.md
    └── json/
        └── DOC_XXXXXXXX/
            ├── ING_aaa.json
            └── ING_bbb.json

保存時も一時ファイルを書いてからrenameします。

ING_xxx.md.tmp
    ↓
ING_xxx.md

途中まで書かれたファイルを完成成果として残さないためです。


Doclingのchunkは文書構造を残す

r005でも固定文字数だけで単純分割する方式には戻していません。

既定はHybridChunkerです。

from docling.chunking import HierarchicalChunker, HybridChunker

Doclingのchunkから本文と一緒に、

page_start
page_end
section

を取り出します。

DoclingのHybridChunkerは、文書構造を使うhierarchical chunkingにtokenizationを考慮した調整を加える仕組みです。

検索結果から、

○○業務報告書
p.42
4.3 計画高水流量の設定

まで原本へ戻れる形を目指します。

HybridChunkerを利用できない場合は、HierarchicalChunkerへフォールバックする実装にしました。


Embeddingモデルを変えた場合も世代を切り替える

Embeddingモデルを変更すると、ベクトル次元が変わる可能性があります。

r005では次元数をコードへ固定していません。

Ollamaの/api/embedへ実際に問い合わせ、返ってきたEmbeddingの長さを使います。

POST /api/embed
model : qwen3-embedding:0.6b
input : ["...", "..."]

Ollamaの現在のEmbedding APIも、inputへ文字列または文字列配列を渡し、embeddingsを返す形式です。

Qdrant collection名はEmbeddingモデル名から分けます。

reports_qwen3_embedding_0_6b

モデル変更後の再indexでも、新モデル側の世代が完成するまで旧モデル側の正常世代には触れません。

切替成功後に、旧collectionから対象文書・旧世代に一致するPointだけを削除します。

旧collection自体は削除しません。他の文書がまだ旧Embeddingモデルを使っている可能性があるためです。


Qdrantのpayload indexを先に作る

検索では、ベクトルだけでなく次の条件で絞り込みます。

fiscal_year
category
client
confidentiality

世代管理用には、

document_id
ingestion_id
active

も使います。

r005ではcollection作成時に、次のpayload indexをPoint投入前に作成します。

document_id      keyword
ingestion_id     keyword
active           bool
fiscal_year      integer
category         keyword
client           keyword
confidentiality  keyword

Qdrantもfilterに使うfieldへpayload indexを作り、データ投入前に準備する方法を案内しています。


r004のDuckDBは捨てずにmigrationする

r005へ上げるために、r004のDuckDBを削除して作り直す方式にはしませんでした。

起動時にschemaを確認し、不足列だけ追加します。

ただし、r004でcompletedだった文書にはingestion_idがありません。

この既存データを推測でr005のactive世代へ変換することはしません。

r004
status=completed
active_ingestion_id=NULL
        |
        v
r005 migration
status=migration_required

一覧で確認できます。

GET /api/v1/documents?status=migration_required

原本PDFが残っていれば、

POST /api/v1/documents/{document_id}/reindex

でr005形式の世代を新しく作れます。

そのため、r004から移行するときはdata/を消さず、先にバックアップを取ってからr005を起動します。


Celery / Redisも長時間PDF向けに見直す

PDF処理は引き続きFastAPIから分離しています。

FastAPI
   |
   v
Redis broker
   |
   v
Celery Worker
   |
   +-- Docling
   +-- Ollama
   +-- Qdrant
   +-- DuckDB

r005では時間設定を明示しました。

TASK_SOFT_TIME_LIMIT_SECONDS=10800
TASK_TIME_LIMIT_SECONDS=11400
REDIS_VISIBILITY_TIMEOUT_SECONDS=14400

関係は、

soft time limit
    < hard time limit
    < Redis visibility timeout

とします。

CeleryのRedis brokerでは、visibility timeoutを超えてackされていないtaskは再配送されるため、長いPDF処理ではtaskのhard limitより長く設定しています。

Workerは次の設定です。

concurrency=1
prefetch=1
max-tasks-per-child=10
acks_late=true
result backendなし

処理状態はCelery result backendではなくDuckDBで管理します。

queued
processing
completed
error
migration_required

1台の検証機でDocling処理とDuckDB書込みを行う段階なので、最初は1件ずつ確実に処理する方を選びました。


RAG APIも少し増やした

r005のAPIは次です。

GET  /health
GET  /ready
POST /api/v1/documents
GET  /api/v1/documents
GET  /api/v1/documents/{document_id}
POST /api/v1/documents/{document_id}/retry
POST /api/v1/documents/{document_id}/reindex
GET  /api/v1/search

/healthはプロセスの生存確認だけです。

/readyでは、

DuckDB
Redis
Ollama
Qdrant

まで接続確認します。

APIはLAN内でも無認証にはせず、X-API-Keyを要求します。


PDF登録

RAG APIをWindows側から直接確認するときだけ、LAN公開用overrideを重ねます。

docker compose \
  -f compose.yml \
  -f compose.lan-api.yml \
  up -d

Swagger UIは次です。

http://<UbuntuのIP>:8000/docs

PDF登録例です。

curl -X POST \
  -H "X-API-Key: <API_KEY>" \
  -F "file=@sample_report.pdf;type=application/pdf" \
  -F "title=サンプル業務報告書" \
  -F "fiscal_year=2026" \
  -F "client=○○事務所" \
  -F "category=河川" \
  -F "confidentiality=internal" \
  http://<UbuntuのIP>:8000/api/v1/documents

受付時には、

PDF拡張子
%PDF- シグネチャ
ファイルサイズ
SHA-256
重複登録

を確認します。

アップロードされた元ファイル名は保存pathへ直接使いません。

原本はSHA-256から作ったdocument_idで保存します。

data/documents/original/DOC_XXXXXXXXXXXXXXX.pdf

元ファイル名は監査用メタデータとしてDuckDBへ残します。


状態確認と再処理

文書一覧は次です。

GET /api/v1/documents
GET /api/v1/documents?status=completed
GET /api/v1/documents?status=migration_required

1件の状態確認は、

GET /api/v1/documents/{document_id}

です。

応答では、単純なstatusだけでなく、

searchable
active_ingestion_id
last_ingestion_id
attempt_count
embedding_model
embedding_dimension
needs_reindex
error_message

も確認できます。

失敗した処理をもう一度実行する場合は、

POST /api/v1/documents/{document_id}/retry

です。

Docling設定やEmbeddingモデルを変えて正常文書を作り直す場合は、

POST /api/v1/documents/{document_id}/reindex

を使います。

retry / reindexはDuckDBのFileLock内で現在状態を確認してからqueuedへ変え、同時要求による二重enqueueを抑えています。


検索

検索APIは次です。

GET /api/v1/search

例えば2026年度の河川分野へ絞る場合は、

curl --get \
  -H "X-API-Key: <API_KEY>" \
  --data-urlencode "q=設計降雨量はいくつか" \
  --data-urlencode "fiscal_year=2026" \
  --data-urlencode "category=河川" \
  http://<UbuntuのIP>:8000/api/v1/search

とします。

検索結果には、

document_id
ingestion_id
filename
title
fiscal_year
client
category
confidentiality
page_start
page_end
section
text
score

を返します。

最終的な回答生成へつなぐ場合でも、まず検索結果が正しいページを返しているかを確認します。


Serenaは引き続きStreamable HTTP

コード開発側はSerenaを使います。

Composeでは、

serena start-mcp-server
  --transport streamable-http
  --port 9121

で起動します。

Open WebUIからの接続先は、

http://serena:9121/mcp

です。

SerenaはStreamable HTTPでMCPサーバーを起動でき、Open WebUIもMCPのStreamable HTTPをネイティブに扱えます。

Serenaへ読ませたいprojectは、

workspace/projects/

へ置きます。

RAG用のPDF原本と、Serenaが扱うGit projectはディレクトリを分けています。


Docker Composeの公開範囲

r005の通常Composeでは、ホストへ公開するのはOpen WebUIだけです。

open-webui:
  ports:
    - "${OPEN_WEBUI_BIND:-127.0.0.1}:${OPEN_WEBUI_PORT:-3000}:8080"

その他はexposeです。

Ollama    11434
Qdrant    6333 / 6334
Redis     6379
Serena    9121
RAG API   8000

Windows 11からOpen WebUIを使う場合は、.envOPEN_WEBUI_BINDをUbuntuのLAN IPへ変更します。

OPEN_WEBUI_BIND=192.168.1.50

最初から0.0.0.0へ広げず、使うinterfaceだけを指定する形にしています。


初回設定

展開後、最初に次を実行します。

cd ~/local_ai_platform_r005
./scripts/init_env.sh

init_env.shでは、

LOCAL_UID
LOCAL_GID
API_KEY
WEBUI_SECRET_KEY

を設定し、.env0600へ変更します。

bind mountする./dataへRAG APIとWorkerが書き込むため、コンテナ側もUbuntuユーザーと同じUID/GIDで動かします。

続いて、

./scripts/check_env.sh
./scripts/preflight.sh
./scripts/host_test.sh

を実行します。


起動

docker compose build
docker compose up -d

Embeddingモデルを取得します。

docker compose exec ollama \
  ollama pull qwen3-embedding:0.6b

回答生成用モデルの例です。

docker compose exec ollama \
  ollama pull qwen3:8b

GPU利用は、

docker compose exec ollama ollama ps

で確認します。


テストは「Dockerなし」と「実機E2E」を分ける

今回も、実行できなかったテストをPASS扱いにはしていません。

Docker不要のpreflight

./scripts/preflight.sh

確認結果は次です。

PYTHON_COMPILE=PASS
COMPOSE_TEXT_CONTRACT=PASS
FORBIDDEN_COMPOSE_PATTERN=PASS
OLLAMA_EMBED_CONTRACT=PASS
GENERATION_CONTRACT=PASS
ENV_INIT_CONTRACT=PASS
AGENT_TEST_CONTRACT=PASS
HOST_PREFLIGHT=PASS
SHELL_SYNTAX=PASS
PREFLIGHT=PASS

ホスト側回帰テスト

./scripts/host_test.sh

r005作成時は、

COMPOSE_REQUIRED_SERVICES=PASS
COMPOSE_PORT_EXPOSURE=PASS
SERENA_TRANSPORT=PASS
RUNTIME_ENV_INSTALL=PASS
HOST_UID_MAPPING=PASS
WORKER_API_COUPLING=PASS
CELERY_RESULT_BACKEND=PASS
OPEN_WEBUI_RAG_OLLAMA_URL=PASS
STATIC_COMPOSE_VALIDATION=PASS

63 passed
HOST_TEST=PASS

まで確認しました。

主な追加テストは、

再index世代切替
失敗staging世代のcleanup
r004 schema migration契約
migration_required
retry / reindexの状態guard
Embeddingモデル変更時の旧collection cleanup
Qdrant payload index
DuckDB active世代との照合

です。


ZIPも再展開して確認する

作業中のディレクトリだけでテストが通っても、ZIP作成時にファイルが漏れることがあります。

そのため配布ZIPを別ディレクトリへ再展開し、そこからもう一度、

./scripts/preflight.sh
./scripts/host_test.sh

を実行しました。

結果は、

63 passed
HOST_TEST=PASS
MANIFEST_VERIFY=PASS
ZIP_REEXTRACT_TEST=PASS

です。


Docker / GPUのチェック

Ubuntu 24.04 + RTX 4060 Ti実機では、

./scripts/docker_qa.sh

を実行します。

このスクリプトでは、次を順番に確認します。

20260819_b02.png

最後に、

DOCKER_QA=PASS

まで出れば、実PDF検証へ進みます。


AGENTS.mdもr005へ合わせる

このprojectをCodexなどのコーディングエージェントで修正する場合、r005で一番壊してほしくないのは世代切替です。

そのためAGENTS.mdには、例えば次の条件を入れています。

## Qdrantの世代管理

- point_idにはdocument_id + ingestion_id + chunk_indexを含める。
- 新しいPointはactive=falseで全件stagingする。
- 途中失敗した新世代を検索対象にしない。
- 完了時だけ新世代をactiveへ切り替える。
- 検索ではDuckDBのactive_ingestion_idも照合する。
- 再index失敗時に直前の正常世代を削除しない。

さらに、

PDF原本を削除しない
Embedding次元を固定しない
page / sectionを落とさない
r004 migrationを壊さない
Redis visibility timeoutをhard limitより長くする
未実行のdocker_qa.shをPASSと報告しない

といった条件も書いています。

Codexはproject内のAGENTS.mdを作業前の指示として読み込めるため、READMEとは別に「変更時に守る境界」を残しておく使い方ができます。


r005でまだやっていないこと

r005で再indexはかなり安全側へ寄せましたが、これでRAGの検索精度が確認できたわけではありません。

次に見るのは、実際の報告書です。

最初は3~4件程度にします。

1. 通常のテキストPDF
2. 表が多いPDF
3. スキャンPDF
4. 100ページを超えるPDF

確認順も、LLMの文章からではなく前段から見ます。

PDFを正しく読めたか
        ↓
ページ・見出しが残ったか
        ↓
正しいページが検索上位へ来たか
        ↓
資料名・ページ番号まで戻れるか
        ↓
回答が検索結果の範囲を越えていないか

そのあとに、vector検索だけで十分か、keyword検索やhybrid search、rerankerが必要かを比較する予定です。


まとめ

r005では、目立つ新機能を追加するよりも、再処理の途中で壊れないことを優先しました。

今回の変更をまとめると、次のようになります。

document_idとingestion_idを分離
        ↓
新世代をactive=falseでstaging
        ↓
全処理成功後だけcutover
        ↓
検索ではDuckDBのactive世代も照合
        ↓
失敗時は直前の正常世代を保持
        ↓
Markdown / JSONも世代保存
        ↓
Embeddingモデル変更も世代切替
        ↓
r004はmigration_requiredから安全に再index

ローカルRAGを実務へ近づけると、モデルの性能だけでなく、「失敗したときに何が残るか」が気になってきます。

PDFを再解析しただけで昨日まで検索できていた資料が消える構成より、新しいデータが完成するまでは古い正常データを使い続けられる方が扱いやすいです。

次はUbuntu 24.04 + RTX 4060 Tiの実機でdocker_qa.shを最後まで通し、その後に実際の報告書を少数登録して、Doclingの抽出結果と検索ページを確認します。


参考資料


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?