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?

【前編】Amazon Neptune で EC サイトの OWL オントロジーを構築する(設計〜データ投入)

0
Posted at

はじめに

Amazon Neptune 上に OWL (Web Ontology Language) で厳密に定義した EC サイトドメインのオントロジーを構築し、SPARQL で検索できる環境を作ります。

本記事(前編)では、Neptune クラスターの構築からオントロジー設計、データ投入までを扱います。後編では EC2 + セッションマネージャーで SPARQL を直接叩ける環境を構築します。

なぜ Neptune + OWL?

RDB ではテーブル間の関係は外部キーで定義しますが、「この顧客は Person の一種で、Agent の一種でもある」「Electronics と Furniture は互いに排他」といった 意味的な制約 は表現できません。

OWL オントロジーを使うと:

  • クラス階層 (Customer is-a Person is-a Agent)
  • プロパティ制約 (各 Order は必ず1人の Customer に紐づく)
  • Disjoint制約 (ElectronicsProduct と BookProduct は排他)
  • 列挙型 (MembershipTier は Bronze/Silver/Gold/Platinum のいずれか)

をグラフ上で形式的に定義でき、SPARQL でこれらの意味構造を活かした検索が可能になります。

構成図

┌──────────────────────────────────────────────────┐
│  VPC (us-east-1)                                  │
│                                                    │
│  ┌─────────────┐        ┌─────────────────────┐  │
│  │   Lambda    │──8182──→│  Neptune (SPARQL)   │  │
│  │ (ブリッジ)  │        │  ecommerce-ontology │  │
│  └─────────────┘        └─────────────────────┘  │
│                                                    │
└──────────────────────────────────────────────────┘
         ↑ invoke
    ─────┴─────
    AWS CLI (ローカル)

前提条件

  • AWS アカウント
  • AWS CLI インストール・設定済み
  • Python 3 + boto3
  • us-east-1 リージョン(デフォルト VPC あり)

Step 1: ネットワーク・セキュリティの準備

セキュリティグループ作成

SG_ID=$(aws ec2 create-security-group \
  --description "Security group for Neptune ontology cluster" \
  --group-name neptune-ontology-sg \
  --vpc-id <YOUR_VPC_ID> \
  --region us-east-1 \
  --query 'GroupId' --output text)

# VPC内からポート8182への接続を許可
aws ec2 authorize-security-group-ingress \
  --group-id $SG_ID \
  --protocol tcp --port 8182 \
  --cidr 172.31.0.0/16 \
  --region us-east-1

Neptune サブネットグループ作成

aws neptune create-db-subnet-group \
  --db-subnet-group-name neptune-ontology-subnet-group \
  --db-subnet-group-description "Subnet group for Neptune ontology cluster" \
  --subnet-ids '["<SUBNET_AZ_A>", "<SUBNET_AZ_B>", "<SUBNET_AZ_C>"]' \
  --region us-east-1

最低2つの異なるAZのサブネットが必要です。デフォルトVPCのサブネットをそのまま使えます。


Step 2: Neptune クラスター作成

# クラスター作成
aws neptune create-db-cluster \
  --db-cluster-identifier ecommerce-ontology \
  --db-subnet-group-name neptune-ontology-subnet-group \
  --engine neptune \
  --no-deletion-protection \
  --vpc-security-group-ids "[\"$SG_ID\"]" \
  --region us-east-1
# => Endpoint を控える

# インスタンス作成
aws neptune create-db-instance \
  --db-cluster-identifier ecommerce-ontology \
  --db-instance-identifier ecommerce-ontology-instance-1 \
  --db-instance-class db.t3.medium \
  --engine neptune \
  --region us-east-1

クラスターが available になるまで 5〜10分 かかります。以下で状態確認:

aws neptune describe-db-clusters \
  --db-cluster-identifier ecommerce-ontology \
  --query 'DBClusters[0].Status' --output text

Step 3: IAM ロール設定

Neptune S3 アクセスロール

aws iam create-role --role-name NeptuneS3LoadRole \
  --assume-role-policy-document '{
    "Version":"2012-10-17",
    "Statement":[{"Effect":"Allow","Principal":{"Service":"rds.amazonaws.com"},"Action":"sts:AssumeRole"}]
  }'

aws iam put-role-policy --role-name NeptuneS3LoadRole \
  --policy-name NeptuneS3ReadPolicy \
  --policy-document '{
    "Version":"2012-10-17",
    "Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:ListBucket"],
      "Resource":["arn:aws:s3:::<YOUR_BUCKET>","arn:aws:s3:::<YOUR_BUCKET>/*"]}]
  }'

ハマりポイント: ロール紐づけのタイミング

add-role-to-db-cluster はクラスターが available になってから実行してください。creating 中に実行すると以下のエラーになります:

InvalidParameterValue: IAM role ARN value is invalid or does not include the required permissions
# クラスターが available になってから実行
aws neptune add-role-to-db-cluster \
  --db-cluster-identifier ecommerce-ontology \
  --role-arn arn:aws:iam::<ACCOUNT_ID>:role/NeptuneS3LoadRole \
  --region us-east-1

Lambda 用ロール

aws iam create-role --role-name NeptuneLambdaRole \
  --assume-role-policy-document '{
    "Version":"2012-10-17",
    "Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]
  }'

aws iam attach-role-policy --role-name NeptuneLambdaRole \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole

Step 4: OWL オントロジー設計

クラス階層

Agent
 └── Person
      └── Customer

Product
 ├── ElectronicsProduct ─┐
 ├── FurnitureProduct ───┼── (disjoint)
 └── BookProduct ────────┘

Order ──hasOrderItem──→ OrderItem ──containsProduct──→ Product
  │                         
  ├── orderedBy ──→ Customer
  └── inRegion ──→ SalesRegion {Northeast, West, Southeast, Midwest}

Customer ──hasMembership──→ MembershipTier {Bronze, Silver, Gold, Platinum}

OWL で表現する意味的制約

制約 OWL表現
Product のサブカテゴリは排他 owl:AllDisjointClasses
MembershipTier は4値の列挙 owl:oneOf + owl:equivalentClass
各 Order は必ず1人の顧客 owl:cardinality "1" on orderedBy
orderedBy と placedOrder は逆関係 owl:inverseOf
email は顧客ごとに1つ owl:FunctionalProperty + owl:cardinality "1"

Turtle ファイル (ecommerce-ontology.ttl)

全文(クリックで展開)
@prefix : <http://example.org/ecommerce#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .

# === Ontology Declaration ===
<http://example.org/ecommerce> rdf:type owl:Ontology ;
    rdfs:label "E-Commerce Domain Ontology"@en ;
    rdfs:comment "OWL ontology modeling an e-commerce domain."@en ;
    owl:versionInfo "1.0.0" .

# === Classes ===
:Agent rdf:type owl:Class ; rdfs:label "Agent"@en ;
    rdfs:comment "An entity capable of performing actions."@en .
:Person rdf:type owl:Class ; rdfs:subClassOf :Agent ;
    rdfs:label "Person"@en ; rdfs:comment "A human being."@en .
:Customer rdf:type owl:Class ; rdfs:subClassOf :Person ;
    rdfs:label "Customer"@en ;
    rdfs:comment "A person who has registered on the e-commerce platform."@en ;
    owl:equivalentClass [ rdf:type owl:Restriction ;
        owl:onProperty :hasSignupDate ; owl:someValuesFrom xsd:date ] .

:Product rdf:type owl:Class ; rdfs:label "Product"@en .
:ElectronicsProduct rdf:type owl:Class ; rdfs:subClassOf :Product ;
    rdfs:label "Electronics Product"@en .
:FurnitureProduct rdf:type owl:Class ; rdfs:subClassOf :Product ;
    rdfs:label "Furniture Product"@en .
:BookProduct rdf:type owl:Class ; rdfs:subClassOf :Product ;
    rdfs:label "Book Product"@en .
[] rdf:type owl:AllDisjointClasses ;
    owl:members ( :ElectronicsProduct :FurnitureProduct :BookProduct ) .

:Order rdf:type owl:Class ; rdfs:label "Order"@en .
:OrderItem rdf:type owl:Class ; rdfs:label "Order Item"@en .

:MembershipTier rdf:type owl:Class ; rdfs:label "Membership Tier"@en ;
    owl:equivalentClass [ rdf:type owl:Class ;
        owl:oneOf ( :Bronze :Silver :Gold :Platinum ) ] .
:SalesRegion rdf:type owl:Class ; rdfs:label "Sales Region"@en ;
    owl:equivalentClass [ rdf:type owl:Class ;
        owl:oneOf ( :Northeast :West :Southeast :Midwest ) ] .

# === Object Properties ===
:placedOrder rdf:type owl:ObjectProperty ;
    rdfs:domain :Customer ; rdfs:range :Order .
:orderedBy rdf:type owl:ObjectProperty, owl:FunctionalProperty ;
    rdfs:domain :Order ; rdfs:range :Customer ; owl:inverseOf :placedOrder .
:hasOrderItem rdf:type owl:ObjectProperty ;
    rdfs:domain :Order ; rdfs:range :OrderItem .
:containsProduct rdf:type owl:ObjectProperty, owl:FunctionalProperty ;
    rdfs:domain :OrderItem ; rdfs:range :Product .
:hasMembership rdf:type owl:ObjectProperty, owl:FunctionalProperty ;
    rdfs:domain :Customer ; rdfs:range :MembershipTier .
:inRegion rdf:type owl:ObjectProperty, owl:FunctionalProperty ;
    rdfs:domain :Order ; rdfs:range :SalesRegion .

# === Data Properties ===
:firstName rdf:type owl:DatatypeProperty, owl:FunctionalProperty ;
    rdfs:domain :Person ; rdfs:range xsd:string .
:lastName rdf:type owl:DatatypeProperty, owl:FunctionalProperty ;
    rdfs:domain :Person ; rdfs:range xsd:string .
:email rdf:type owl:DatatypeProperty, owl:FunctionalProperty ;
    rdfs:domain :Customer ; rdfs:range xsd:string .
:hasSignupDate rdf:type owl:DatatypeProperty, owl:FunctionalProperty ;
    rdfs:domain :Customer ; rdfs:range xsd:date .
:lifetimeValue rdf:type owl:DatatypeProperty, owl:FunctionalProperty ;
    rdfs:domain :Customer ; rdfs:range xsd:decimal .
:productName rdf:type owl:DatatypeProperty, owl:FunctionalProperty ;
    rdfs:domain :Product ; rdfs:range xsd:string .
:unitPrice rdf:type owl:DatatypeProperty, owl:FunctionalProperty ;
    rdfs:domain :Product ; rdfs:range xsd:decimal .
:category rdf:type owl:DatatypeProperty, owl:FunctionalProperty ;
    rdfs:domain :Product ; rdfs:range xsd:string .
:orderDate rdf:type owl:DatatypeProperty, owl:FunctionalProperty ;
    rdfs:domain :Order ; rdfs:range xsd:date .
:totalAmount rdf:type owl:DatatypeProperty, owl:FunctionalProperty ;
    rdfs:domain :Order ; rdfs:range xsd:decimal .
:quantity rdf:type owl:DatatypeProperty, owl:FunctionalProperty ;
    rdfs:domain :OrderItem ; rdfs:range xsd:integer .
:lineTotal rdf:type owl:DatatypeProperty, owl:FunctionalProperty ;
    rdfs:domain :OrderItem ; rdfs:range xsd:decimal .

# === OWL Restrictions ===
:Order rdfs:subClassOf [ rdf:type owl:Restriction ;
    owl:onProperty :hasOrderItem ; owl:minCardinality "1"^^xsd:nonNegativeInteger ] .
:Order rdfs:subClassOf [ rdf:type owl:Restriction ;
    owl:onProperty :orderedBy ; owl:cardinality "1"^^xsd:nonNegativeInteger ] .
:OrderItem rdfs:subClassOf [ rdf:type owl:Restriction ;
    owl:onProperty :containsProduct ; owl:cardinality "1"^^xsd:nonNegativeInteger ] .
:Customer rdfs:subClassOf [ rdf:type owl:Restriction ;
    owl:onProperty :email ; owl:cardinality "1"^^xsd:nonNegativeInteger ] .

# === Individuals: Tiers & Regions ===
:Bronze rdf:type owl:NamedIndividual, :MembershipTier ; rdfs:label "Bronze"@en .
:Silver rdf:type owl:NamedIndividual, :MembershipTier ; rdfs:label "Silver"@en .
:Gold rdf:type owl:NamedIndividual, :MembershipTier ; rdfs:label "Gold"@en .
:Platinum rdf:type owl:NamedIndividual, :MembershipTier ; rdfs:label "Platinum"@en .
:Northeast rdf:type owl:NamedIndividual, :SalesRegion ; rdfs:label "Northeast"@en .
:West rdf:type owl:NamedIndividual, :SalesRegion ; rdfs:label "West"@en .
:Southeast rdf:type owl:NamedIndividual, :SalesRegion ; rdfs:label "Southeast"@en .
:Midwest rdf:type owl:NamedIndividual, :SalesRegion ; rdfs:label "Midwest"@en .

Step 5: Lambda 関数デプロイ(Neptune へのブリッジ)

Neptune は VPC 内にあるためローカルから直接アクセスできません。VPC 内に Lambda を配置し、SPARQL の実行ブリッジとします。

Lambda コード

lambda_function.py
import json
import urllib.request
import urllib.error
import urllib.parse
import ssl

NEPTUNE_ENDPOINT = "<YOUR_NEPTUNE_ENDPOINT>"
NEPTUNE_PORT = 8182

def lambda_handler(event, context):
    action = event.get('action', 'sparql')
    ctx = ssl.create_default_context()
    base_url = f"https://{NEPTUNE_ENDPOINT}:{NEPTUNE_PORT}"

    if action == 'sparql':
        query = event.get('query', 'SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 10')
        url = f"{base_url}/sparql"
        payload = urllib.parse.urlencode({'query': query}).encode('utf-8')
        req = urllib.request.Request(url, data=payload, method='POST')
        req.add_header('Content-Type', 'application/x-www-form-urlencoded')
        req.add_header('Accept', 'application/sparql-results+json')
        try:
            resp = urllib.request.urlopen(req, context=ctx, timeout=60)
            return {"statusCode": 200, "body": json.loads(resp.read().decode())}
        except urllib.error.HTTPError as e:
            return {"statusCode": e.code, "body": e.read().decode()}
        except Exception as e:
            return {"statusCode": 500, "body": f"{type(e).__name__}: {str(e)}"}

    elif action == 'update':
        update = event.get('update', '')
        url = f"{base_url}/sparql"
        payload = urllib.parse.urlencode({'update': update}).encode('utf-8')
        req = urllib.request.Request(url, data=payload, method='POST')
        req.add_header('Content-Type', 'application/x-www-form-urlencoded')
        try:
            resp = urllib.request.urlopen(req, context=ctx, timeout=60)
            return {"statusCode": 200, "body": resp.read().decode()}
        except urllib.error.HTTPError as e:
            return {"statusCode": e.code, "body": e.read().decode()}
        except Exception as e:
            return {"statusCode": 500, "body": f"{type(e).__name__}: {str(e)}"}

デプロイ

zip lambda_function.zip lambda_function.py

aws lambda create-function \
  --function-name neptune-ontology-loader \
  --runtime python3.12 \
  --role arn:aws:iam::<ACCOUNT_ID>:role/NeptuneLambdaRole \
  --handler lambda_function.lambda_handler \
  --zip-file fileb://lambda_function.zip \
  --timeout 120 --memory-size 256 \
  --vpc-config SubnetIds=<SUBNET_A>,<SUBNET_B>,SecurityGroupIds=<SG_ID> \
  --region us-east-1

ハマりポイント: Lambda の VPC ENI 作成

Lambda を VPC に配置すると ENI の作成に 2〜3分 かかります。Active になるまで待ってください:

aws lambda get-function --function-name neptune-ontology-loader \
  --query 'Configuration.State' --output text

ハマりポイント: Neptune は HTTPS

Neptune 1.4 はデフォルトで HTTPS を使用します。HTTP でアクセスすると Connection refused になります。Lambda コード内で https:// を使い、ssl.create_default_context() を渡してください。


Step 6: データ投入

方針: SPARQL INSERT DATA を使う

Neptune のバルクローダー API (POST /loader) は VPC 内から呼び出す必要がありますが、Lambda 経由だとタイムアウトする場合があります。また、SPARQL の LOAD コマンドは S3 プロトコルに非対応です。

データ量が小さい場合(数百トリプル)は SPARQL INSERT DATA で直接投入するのが確実です。

Turtle → SPARQL INSERT DATA 変換

convert_ttl.py
import re, json

def ttl_to_insert_data(ttl_content):
    """Turtle形式をSPARQL INSERT DATA文に変換"""
    prefix_lines = []
    body_lines = []
    for line in ttl_content.split('\n'):
        stripped = line.strip()
        if stripped.startswith('@prefix'):
            match = re.match(r'@prefix\s+(\S+)\s+<([^>]+)>\s*\.', stripped)
            if match:
                prefix_lines.append(f"PREFIX {match.group(1)} <{match.group(2)}>")
        elif stripped.startswith('#') or stripped == '':
            continue
        else:
            body_lines.append(line)
    return '\n'.join(prefix_lines) + '\nINSERT DATA {\n' + '\n'.join(body_lines) + '\n}'

# 変換してペイロード生成
with open('ecommerce-ontology.ttl', 'r') as f:
    ontology_sparql = ttl_to_insert_data(f.read())
with open('ecommerce-data.ttl', 'r') as f:
    data_sparql = ttl_to_insert_data(f.read())

# Lambda 用 JSON ペイロードに書き出し
with open('ontology-payload.json', 'w') as f:
    json.dump({"action": "update", "update": ontology_sparql}, f)
with open('data-payload.json', 'w') as f:
    json.dump({"action": "update", "update": data_sparql}, f)

投入実行

同名クラスターを再作成した場合、以前のデータが残っていることがあります。確実にクリーンにするには先に DROP ALL を実行してください:

aws lambda invoke --function-name neptune-ontology-loader \
  --cli-binary-format raw-in-base64-out \
  --payload '{"action": "update", "update": "DROP ALL"}' \
  --region us-east-1 /tmp/out.json > /dev/null 2>&1
# オントロジー投入
aws lambda invoke --function-name neptune-ontology-loader \
  --cli-binary-format raw-in-base64-out \
  --payload file://ontology-payload.json \
  --region us-east-1 \
  /tmp/out.json > /dev/null 2>&1 && cat /tmp/out.json

# データ投入
aws lambda invoke --function-name neptune-ontology-loader \
  --cli-binary-format raw-in-base64-out \
  --payload file://data-payload.json \
  --region us-east-1 \
  /tmp/out.json > /dev/null 2>&1 && cat /tmp/out.json

確認

aws lambda invoke --function-name neptune-ontology-loader \
  --cli-binary-format raw-in-base64-out \
  --payload '{"action": "sparql", "query": "SELECT (COUNT(*) as ?count) WHERE { ?s ?p ?o }"}' \
  --region us-east-1 \
  /tmp/out.json > /dev/null 2>&1 && cat /tmp/out.json | python3 -m json.tool
# => 472 triples

前編まとめ

ここまでで以下を構築しました:

リソース 内容
Neptune クラスター ecommerce-ontology (SPARQL 1.1, db.t3.medium)
OWL オントロジー 11クラス, 6オブジェクトプロパティ, 10データプロパティ, OWL制約
RDF データ 9顧客, 10商品, 10注文 (計472トリプル)
Lambda neptune-ontology-loader (SPARQL ブリッジ)

後編では EC2 + セッションマネージャーで curl ベースの SPARQL クエリ実行環境を構築し、様々なクエリ例を紹介します。


参考リンク

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?