社内ナレッジ検索(RAG)を作るとき、「AIが探せるように、文書をどこに保存するか」は必ず決めることになります。前回の第1回では、社内文書を検索しやすい断片(チャンク)に分けました。第2回では、その断片をPostgreSQLに保存する土台を、Docker Composeで用意します。
「社内文書の検索AI用に、専用のデータベースを別に契約しないといけないの?」
先に結論をお伝えします。社内文書の検索に、専用の新しいデータベースは必須ではありません。業務システムで広く使われているPostgreSQLに、拡張機能「pgvector」を足すだけで、意味の近さによる検索(ベクトル検索)を始められます。この回では、pgvector入りのPostgreSQLをDocker Composeで起動し、第1回で作った断片を保存して、同じ文書を取り込み直しても重複しないことをテストで確かめます。
前回の記事:【第1回】社内文書を読み込んで、検索しやすい断片に分ける
社内ナレッジ検索のデータベースにPostgreSQL+pgvectorを選ぶ理由
RAG(=検索で見つけた情報を材料に、AIに答えを作らせる方式)では、「質問に意味が近い断片」を素早く探す必要があります。文章を数百〜数千個の数値の並び(ベクトル)に変換しておき、数値の並びが近いものを探す、というのがベクトル検索の考え方です。
pgvectorは、PostgreSQLにこのベクトルの保存と検索を追加するオープンソースの拡張機能です。
READMEでは、CREATE EXTENSION vector; で拡張を有効にし、vector(3) のような型でベクトルを持つ列を作り、<-> などの演算子で距離を測って近い順に取り出す使い方が説明されています。Docker、APT、Homebrewなど複数の入れ方や、各社のマネージドサービスでの提供も案内されています(執筆時点:2026年9月)。
発注者にとっての利点は、次の3つです。
- 既存の業務DBと同じ仕組みで運用できる: バックアップ、権限管理、監視といったPostgreSQLの運用ノウハウをそのまま使えます。
- 権限の絞り込みをSQLで書ける: 第6回で入れる「部署ごとに見られる文書を分ける」処理を、検索と同じSQLの中に書けます。
- 発注先を選びにくくならない: PostgreSQLは多くの開発会社が扱えるため、特定の専用製品に依存しません。
一方で、文書が数百万件を超えるような大規模な用途では、専用のベクトル検索サービスを比べる価値があります。この連載の規模(社内の規程・マニュアル数百〜数千ページ程度)であれば、PostgreSQLで十分に始められる、というのが筆者の判断です。
この回で作るもの
| 部品 | ファイル | 役割 |
|---|---|---|
| DBの起動 | docker-compose.yml | pgvector入りのPostgreSQLを1コマンドで起動する |
| テーブル定義 | db/init/001_schema.sql | 拡張の有効化と、断片を入れる表(chunks)を作る |
| 保存処理 | app/db/store.py | 断片の保存、取り込み直し、件数確認、pgvectorの動作確認 |
| 実行入口 | app/db/__main__.py | サンプル文書を読み込み、分割して、DBへ保存する |
| 設定 | app/config.py | DBの接続先を環境変数から読む |
実装
PostgreSQLをDocker Composeで起動する(docker-compose.yml)
services:
db:
# pgvector 入りの PostgreSQL 公式派生イメージ
image: pgvector/pgvector:pg17
environment:
POSTGRES_USER: rag
POSTGRES_PASSWORD: rag_local_only
POSTGRES_DB: rag
ports:
- "5432:5432"
volumes:
- rag_pgdata:/var/lib/postgresql/data
# 空のデータ領域で初回起動した時だけ、このフォルダのSQLが実行される
- ./db/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rag -d rag"]
interval: 5s
timeout: 3s
retries: 10
volumes:
rag_pgdata:
pgvector/pgvector:pg17 は、pgvectorの開発元が公開しているDockerイメージで、PostgreSQL 17にpgvectorが最初から入っています。
rag_pgdata は、データを保存しておく領域(ボリューム)です。コンテナを作り直しても、保存した断片は消えません。docker-entrypoint-initdb.d に置いたSQLは、データ領域が空の初回起動のときだけ実行されます。テーブル定義を変えたいときは、後述する「つまずきやすい点」を参照してください。
パスワード rag_local_only は、自分のパソコンで試すためだけの値です。本番の環境では、必ず別の強いパスワードを使い、コードやGitには書きません。
テーブルを作る(db/init/001_schema.sql)
-- ベクトル検索の拡張(pgvector)を有効にする
CREATE EXTENSION IF NOT EXISTS vector;
-- 文書の断片(チャンク)を1行1断片で保存する。
-- 「どの文書の・どの見出しの・何番目か」を持たせ、出典表示と再取り込みに使う。
CREATE TABLE IF NOT EXISTS chunks (
id BIGSERIAL PRIMARY KEY,
source TEXT NOT NULL,
heading TEXT NOT NULL DEFAULT '',
chunk_index INTEGER NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (source, chunk_index)
);
表の列は、第1回の Chunk(文書名・見出し・番号・本文)と1対1で対応させています。UNIQUE (source, chunk_index) は「同じ文書の同じ番号の断片は1つだけ」という制約で、二重登録を防ぎます。
ベクトルを入れる列(embedding)は、この回では作りません。列の大きさ(次元数)は、使う埋め込みモデル(文章を数値の並びに変える仕組み)で決まります。モデルを選ぶ第3回で、列を追加します。
DBの接続先を設定に加える(app/config.py)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "社内ナレッジ検索"
llm_api_key: str | None = None
# ローカル確認用の接続先(docker-compose.yml と同じ値)。本番は環境変数で必ず上書きする。
database_url: str = "postgresql://rag:rag_local_only@localhost:5432/rag"
接続先は環境変数 DATABASE_URL で上書きできます。第0回で決めた「秘密情報はコードに直書きしない」方針のままです。初期値は、ローカルのComposeと同じ値にしてあり、本番のパスワードではありません。
断片を保存する(app/db/store.py)
import psycopg
from app.ingest.models import Chunk
def connect(database_url: str) -> psycopg.Connection:
return psycopg.connect(database_url)
def save_chunks(conn: psycopg.Connection, chunks: list[Chunk]) -> int:
"""断片を保存する。同じ文書を取り込み直したら、その文書の古い断片を消して入れ替える。"""
sources = sorted({c.source for c in chunks})
with conn.transaction():
conn.execute("DELETE FROM chunks WHERE source = ANY(%s)", (sources,))
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO chunks (source, heading, chunk_index, body) VALUES (%s, %s, %s, %s)",
[(c.source, c.heading, c.index, c.text) for c in chunks],
)
return len(chunks)
def vector_smoke_test(conn: psycopg.Connection) -> float:
"""pgvector が使えるかの確認。2点間の距離(L2)を返す。[0,0,0] と [3,4,0] なら 5.0。"""
row = conn.execute("SELECT '[0,0,0]'::vector <-> '[3,4,0]'::vector").fetchone()
return row[0]
ポイントは3つあります。
- 入れ替え保存: 文書を更新して取り込み直すとき、その文書の古い断片を先に消してから入れ直します。これを1つの処理(トランザクション)にまとめているので、途中で失敗しても「古い断片が消えただけ」の状態にはなりません。規程を改訂したのに古い内容が検索に残る、という事故を防ぐ土台です。
- 値は必ずプレースホルダで渡す: SQLに文字列を直接つなげず、
%sで渡します。文書の中身に含まれる記号でSQLが壊れたり、不正なSQLが実行されたりすること(SQLインジェクション)を避けるためです。 - 動作確認用の関数:
vector_smoke_testは、原点(0,0,0)と点(3,4,0)の距離を計算します。三平方の定理どおり5.0になれば、pgvectorが有効です。
ドライバはpsycopg 3です。公式ドキュメントでは、with conn.transaction(): でトランザクションの範囲を明示する書き方が案内されています。
📰 出典:psycopg 3 公式ドキュメント(Transactions management)
読み込みから保存までを1コマンドにする(app/db/__main__.py)
def main() -> None:
directory = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("sample_docs")
chunks = [c for d in load_documents(directory) for c in chunk_document(d)]
with connect(get_settings().database_url) as conn:
print(f"pgvector 距離テスト: {vector_smoke_test(conn)}")
saved = save_chunks(conn, chunks)
print(f"保存 {saved}件 / テーブル内の合計 {count_chunks(conn)}件")
第1回で作った読み込み(load_documents)と分割(chunk_document)を、そのままつなげただけです。部品を分けておいたおかげで、新しく書いたのは保存の部分だけです。
動作確認の方法
手順
cd blogs/it_hacchu/series/rag-naibu/code
docker compose up -d # pgvector入りPostgreSQLを起動
pip install -e ".[dev]"
python -m app.db # 読み込み→分割→保存
実行結果(筆者の確認)
pgvector 距離テスト: 5.0
保存 5件 / テーブル内の合計 5件
同じコマンドを2回続けて実行しても、合計は5件のままでした。取り込み直しても重複しないことが確認できます。テーブルの中身をSQLで見ると、次のように、出典に使う見出しの階層まで保存されています。
id | source | heading | chunk_index
----+-------------+------------------------------------------------+-------------
7 | vacation.md | 休暇規程(サンプル) | 0
8 | vacation.md | 休暇規程(サンプル) > 年次有給休暇 > 申請方法 | 1
9 | vacation.md | 休暇規程(サンプル) > 年次有給休暇 > 半日休暇 | 2
筆者が確認できた範囲・できていない範囲
正直にお伝えしておくと、筆者の作業環境にはDockerが動く状態がありませんでした。そのため、次のように確認範囲を分けています。
| 項目 | 確認状況 |
|---|---|
| ruff(コードの静的チェック)とpytest(DBを使わないテスト8件) | 確認済み |
| 上記のSQL・保存処理・テスト(DBを使うテスト2件を含む計10件) | Ubuntu標準のPostgreSQL 16とpgvector 0.6.0を直接起動して確認済み |
| docker-compose.yml と pgvector/pgvector:pg17 イメージでの起動 | 筆者の環境では未確認(記述はDocker公式の書式に沿って作成) |
pgvectorは新しい版ほど機能が増えていきますが、この回で使う機能(拡張の有効化、vector型、距離の演算子)は、上記の版でも動作しました。お手元でDockerを使う場合は、起動できたかを docker compose ps で確認してください。
自動テスト(tests/test_store.py)
DBにつながる環境変数 TEST_DATABASE_URL が設定されているときだけ実行するテストを用意しました。設定されていなければ自動でスキップされるので、DBが無い環境でも検証コマンドは通ります。
def test_vector_distance(conn: psycopg.Connection):
assert vector_smoke_test(conn) == 5.0
def test_save_and_reingest_replaces(conn: psycopg.Connection):
first = [Chunk("a.md", "A", 0, "いち"), Chunk("a.md", "B", 1, "に")]
assert save_chunks(conn, first) == 2
assert count_chunks(conn) == 2
# 同じ文書を取り込み直しても重複しない
save_chunks(conn, [Chunk("a.md", "A", 0, "いち改")])
assert count_chunks(conn) == 1
TEST_DATABASE_URL=postgresql://rag:rag_local_only@localhost:5432/rag pytest -q
つまずきやすい点・セキュリティ上の注意
- テーブル定義を変えても反映されない:
db/initのSQLは、データ領域が空の初回起動のときしか実行されません。作り直したいときは、docker compose down -vでボリュームごと消してから起動し直します(保存済みのデータも消えるので、ローカルの試験用だけで使ってください)。実運用では、変更履歴を管理する仕組み(マイグレーション)を使います。 - ポート5432を公開しているのはローカル確認用: DBを外部からつなげる状態にしたまま、インターネットに公開しないでください。本番では、DBを社内ネットワークやクラウドの閉じた領域に置きます。
- パスワードをGitに入れない: 本番用のパスワードや接続URLは、
.envや実行基盤のシークレット管理に置きます(第9回で扱います)。 - 社内文書はDBにそのまま入る: 取り込んだ文書の本文は、DBにそのまま保存されます。DBのバックアップや、アクセスできる人の範囲も、社内文書と同じ扱いにする必要があります。
- この段階ではまだ検索できません: 保存できたのは断片の本文だけで、意味による検索は第3回から始まります。
発注者向けメモ:ベクトルDBまわりを頼むときの確認点
社内ナレッジ検索の見積もりでは、AIの部分より前に「文書をどこに、どう守って置くか」が費用と安全性を左右します。開発会社に依頼するときは、次の点を確認しておくと安心です。
- ☐ 文書を保存する場所(自社のクラウド・開発会社のクラウド)と、契約の名義を決めた
- ☐ 保存する文書に、個人情報・取引先との秘密保持契約の対象が含まれないか確認した
- ☐ DBのバックアップの頻度と、復旧にかかる時間の目安を聞いた
- ☐ 文書が増えたとき(件数が10倍になったとき)の費用と性能の見込みを聞いた
- ☐ 開発会社が変わっても、DBを引き継げる形式(標準的なPostgreSQL)になっているか確認した
開発会社への質問例:
- 「文書の保存先には何のデータベースを使いますか。他社に引き継ぐとき、そのまま使えますか」
- 「文書を更新したとき、古い内容が検索に残らない仕組みはどうなっていますか」
- 「バックアップは誰が、どのくらいの頻度で取り、復元の訓練はしますか」
- 「DBの運用費(サーバー代・保守)は、月額でどのくらい増える見込みですか」
工数や費用が増えやすいのは、文書の量が非常に多い場合、権限で見られる文書を細かく分ける場合、そして社外に置けない文書があってDBを自社環境に構築する場合です。専用のベクトル検索サービスを使う案と、PostgreSQLにpgvectorを足す案とでは、初期費用と運用のしやすさの考え方が異なります。見積もりの段階で「なぜその構成にしたのか」を聞いておくと、後から比較しやすくなります。
まとめと次回予告
第2回では、pgvector入りのPostgreSQLをDocker Composeで起動する設定と、断片を保存するテーブル・保存処理を作りました。同じ文書を取り込み直しても重複しないことを、テストで固定しています。Dockerでの起動そのものは筆者の環境で未確認のため、ローカルのPostgreSQL 16とpgvectorで動作を確かめたことを併記しました。
次回の第3回では、質問と断片を数値の並び(ベクトル)にする「埋め込み」のしくみを作り、質問に近い断片を探すベクトル検索を実装します(タイトル案:「質問に近い文書を探す(ベクトル検索)」)。
この連載の記事一覧
この記事は連載「FastAPIで作る社内ナレッジ検索(RAG)」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【FastAPIで作る社内ナレッジ検索(RAG) 第0回】全体像と、動く最小APIを作る
- 【FastAPIで作る社内ナレッジ検索(RAG) 第1回】社内文書を読み込んで、検索しやすい断片に分ける
- 【FastAPIで作る社内ナレッジ検索(RAG) 第2回】PostgreSQLとpgvectorをDocker Composeで動かし、文書の断片を保存する(この記事)
- 【FastAPIで作る社内ナレッジ検索(RAG) 第3回】質問に近い社内文書を探す(ベクトル検索)を実装する
- 【FastAPIで作る社内ナレッジ検索(RAG) 第4回】見つけた社内文書を根拠に、出典つきで生成AIに回答させる
- 【FastAPIで作る社内ナレッジ検索(RAG) 第5回】答えが社内文書に無いときは「見つかりません」と返す仕組みを作る
- 【FastAPIで作る社内ナレッジ検索(RAG) 第6回】部署ごとに見てよい文書だけを検索する、アクセス制御を実装する
- 【FastAPIで作る社内ナレッジ検索(RAG) 第7回】社内のPDFを取り込み、ページ番号つきの出典で答えさせる
- 【FastAPIで作る社内ナレッジ検索(RAG) 第8回】回答の品質を評価ケースで測り、直したつもりの後退を自動テストで見つける










コメント
コメント一覧 (2件)
[…] 【FastAPIで作る社内ナレッジ検索(RAG) 第2回】PostgreSQLとpgvectorをDocker Composeで… […]
[…] 【FastAPIで作る社内ナレッジ検索(RAG) 第2回】PostgreSQLとpgvectorをDocker Composeで… […]