27 |
28 | ## デプロイ
29 |
30 | リポジトリに付属する評価用のデータを用いたデモアプリケーションをデプロイする手順を説明します。
31 |
32 | ### 前提条件
33 |
34 | - CDK アプリケーションをデプロイできる環境。
35 | - 詳細は CDK の[開発者ガイド](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html)をご参照ください。
36 | - CDK アプリケーションをデプロイするためには、事前に [Bootstrap](https://docs.aws.amazon.com/cdk/v2/guide/bootstrapping.html) が必要です。
37 | ```
38 | npx -w packages/cdk cdk bootstrap
39 | ```
40 | - Bedrock 上の Embedding モデルへのアクセス。
41 | - Bedrock のコンソールから、Embedding モデル (Titan Text Embeddings V2 / Cohere Embed Models) へのアクセス権を取得してください (デフォルトでは、Bedrock のリージョンは `us-east-1` を使用しています)。詳細については、[Bedrock 開発者ガイド](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html)をご参照ください。
42 |
43 | ### デモアプリのデプロイ
44 |
45 | デモアプリをデプロイする大まかな手順は以下の通りです。
46 |
47 | 1. 設定の確認
48 | 2. AWS リソースの作成 (cdk deploy)
49 | 3. サンプルデータ投入
50 |
51 | #### 1. 設定の確認
52 |
53 | デモアプリの設定は、`packages/cdk/cdk.json` で指定しています。
54 | 設定可能なパラメータとその意味は以下の通りです。
55 |
56 | | パラメータ | デフォルト値 | 意味 |
57 | | :---------------: | :----------: | :-----------------------------------------------------------------------------------------------: |
58 | | bedrockRegion | us-east-1 | Bedrock のモデルを呼び出すリージョン |
59 | | selfSignUpEnabled | true | Cognito のセルフサインアップの有効化の有無 (trueの場合、フロントUIからユーザー作成可能になります) |
60 |
61 | #### 2. AWS リソースの作成 (cdk deploy)
62 |
63 | デモアプリをデプロイするためには、リポジトリのクローン & ルートディレクトリに移動の上、以下のコマンドを実行します。
64 |
65 | ```
66 | $ npm ci
67 | $ npm run cdk:deploy
68 | ```
69 |
70 | `cdk deploy` を実行すると、必要な AWS リソース (OpenSearchなど) を作成します。
71 | 実行には、30分ほどかかります。
72 |
73 | ※ [Finch](https://github.com/runfinch/finch) を使用する場合、環境変数 `CDK_DOCKER=finch` を export する必要があります。詳しくは以下をご参照ください。
74 | https://github.com/aws/aws-cdk/tree/main/packages/cdk-assets#using-drop-in-docker-replacements
75 |
76 | #### 3. サンプルデータ投入
77 |
78 | 次に、サンプルデータを取り込み OpenSearch のインデックスを作成します。以下の手順は、CDK のデプロイが完了してから実施してください。
79 |
80 | OpenSearch の Domain のステータスが Active になったら、サンプルデータの投入を行います。
81 | 実行には以下の2つの方法を用意しています。
82 |
83 | - Option 1: シェルスクリプトで実行 (おすすめ)
84 | - Option 2: 直接 run-task コマンドを実行
85 |
86 | ##### Option 1 (シェルスクリプトで実行)
87 |
88 | 以下のコマンドを実行します。
89 |
90 | ```bash
91 | bash run-ingest-ecs-task.sh --index-name
149 |
150 | ## Next Steps
151 |
152 | - 自分のデータで試したい場合は、[独自データで試すには](/docs/bring-your-own-data.md)をご参照ください。
153 | - このリポジトリのサンプル実装の詳細については、[実装詳細](/docs/implementation-details.md)をご参照ください。
154 |
155 | ## Security
156 |
157 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information.
158 |
159 | ## License
160 |
161 | This library is licensed under the MIT-0 License. See the LICENSE file.
162 |
--------------------------------------------------------------------------------
/docs.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/opensearch-intelligent-search-jp/768ef04ed841d9e6d646a0858f55cf5287347528/docs.zip
--------------------------------------------------------------------------------
/docs/assets/architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/opensearch-intelligent-search-jp/768ef04ed841d9e6d646a0858f55cf5287347528/docs/assets/architecture.png
--------------------------------------------------------------------------------
/docs/assets/ui.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/opensearch-intelligent-search-jp/768ef04ed841d9e6d646a0858f55cf5287347528/docs/assets/ui.png
--------------------------------------------------------------------------------
/docs/bring-your-own-data.md:
--------------------------------------------------------------------------------
1 | # 独自データで試すには
2 |
3 | このドキュメントでは、ご自身のデータを用いてデモアプリケーションを動かすために必要な手順を説明します。
4 |
5 | ## 前提: サンプルアセットの構成
6 |
7 | opensearch-intelligent-search-jp では、リポジトリルートにある `docs.zip` を読み込み OpenSearch インデックスに取り込みを行います。
8 | `docs.zip` は、以下のようなフォルダ・ファイル構成をzip化したファイルです。
9 |
10 | ```
11 | .
12 | ├── bedrock
13 | │ ├── xxxxx.txt
14 | │ ├── ...
15 | │ └── xxxxx.txt
16 | ├── comprehend
17 | │ ├── xxxxx.txt
18 | │ ├── ...
19 | │ ├── xxxxx.txt
20 | └── kendra
21 | ├── xxxxx.txt
22 | ├── ...
23 | ├── xxxxx.txt
24 | ```
25 |
26 | opensearch-intelligent-search-jp を `cdk deploy` でデプロイすると、`docs.zip` をドキュメント保存用バケット (以下、「ドキュメントバケット」) へ展開してアップロードします。ドキュメントバケットは、`cdk deploy` 実行時の出力に含まれる `S3bucketdocumentBucketName` から確認可能です。
27 |
28 | ドキュメントバケットには、上記のフォルダ構造がそのまま反映される形でデータがアップロードされます。
29 | `bedrock` や `comprehend` といったフォルダ名が、OpenSearch のマッピングにおける `service` というフィールドに対応します。(このサンプルデータでは、AWS のサービス名に対応するため)
30 |
31 | 各フォルダには、txtファイルが格納されています。ここでは、それぞれのサービスのドキュメントをtxtファイルに変換したデータを格納しています。
32 |
33 | ## 自身のデータで試すには
34 |
35 | コードを変更せずに自身のデータで試す場合、以下の手順を実施してください。
36 |
37 | - ドキュメントバケットの `docs/` 配下にあるサンプルデータを削除します。
38 | - サンプルデータと同様のデータ構成でドキュメントバケットの `docs/` 配下にデータをアップロードします。
39 | - [README内にある「デプロイ」セクションの 3. サンプルデータ投入](./../README.md#3-サンプルデータ投入) の手順を実施する。
40 | - サンプルデータとの重複を避けるため、インデックス名を変更して実施することをおすすめします。(インデックス名がデフォルトの場合、サンプルデータ + ご自身のデータとなります。)
41 |
--------------------------------------------------------------------------------
/docs/implementation-details.md:
--------------------------------------------------------------------------------
1 | # 実装詳細
2 |
3 | このドキュメントでは、実装の詳細について解説します。
4 |
5 | - [全体構成](#全体構成)
6 | - [主要コンポーネントの詳細](#主要コンポーネントの詳細)
7 | - [OpenSearch インデックス](#opensearch-インデックス)
8 | - [検索方法と検索単位](#検索方法と検索単位)
9 | - [マッピング定義](#マッピング定義)
10 | - [データ取り込み処理](#データ取り込み処理)
11 | - [検索パイプライン](#検索パイプライン)
12 | - [collapse-hybrid-search-pipeline](#collapse-hybrid-search-pipeline)
13 | - [collapse-search-pipeline](#collapse-search-pipeline)
14 | - [hybrid-search-pipeline](#hybrid-search-pipeline)
15 |
16 | ## 全体構成
17 |
18 | opensearch-intelligent-search-jp のリポジトリ構成は以下の通りです。
19 |
20 | ```
21 | .
22 | ├── README.md
23 | ├── docs # opensearch-intelligent-search-jp のドキュメント
24 | ├── docs.zip # サンプルデータ
25 | ├── package-lock.json
26 | ├── package.json
27 | ├── packages
28 | │ ├── cdk # CDK コード
29 | │ └── ui # フロント UI 用コード
30 | └── run-ingest-ecs-task.sh # データ取り込み処理用シェルスクリプト
31 | ```
32 |
33 | NPM の workspaces を用いた monorepo 構成となっており、packages ディレクトリ以下に cdk 用のパッケージと フロント UI 用のパッケージから構成されています。
34 |
35 | ## 主要コンポーネントの詳細
36 |
37 | ### OpenSearch インデックス
38 |
39 | #### 検索方法と検索単位
40 |
41 | このサンプル実装では、検索方法としてキーワード検索、ベクトル検索、ハイブリッド検索の 3 種類に対応しています。
42 |
43 | - キーワード検索
44 | - キーワード検索は Okapi BM25 アルゴリズムを使ってドキュメントのスコアを計算します。検索クエリをトークンに分割し、そのトークンがドキュメント内に多く現れるか、トークンが一般的な単語 (例: the) ではないか、などを考慮して類似性を測ります。
45 | - opensearch-intelligent-search-jp ではトークン化を行うトークナイザーとして Sudachi を利用しています。Sudachi の設定内容は [opensearch.py](../packages/cdk/ecs/ingest-data/app/opensearch.py) を参照ください。
46 | - ベクトル検索
47 | - ベクトル検索は、文書を機械学習モデルを使ってベクトル化し、そのベクトル間の類似度を測定してドキュメントのスコアを計算します。キーワード検索がキーワードの一致によってスコアを計算していたのに対し、ベクトル検索はより意味的な類似性を考慮してスコアを計算します。
48 | - ハイブリッド検索 (キーワード検索 + ベクトル検索)
49 | - ハイブリッド検索は、キーワード検索とベクトル検索を合わせた検索手法です。
50 | - opensearch-intelligent-search-jp では、OpenSearch の持つ [Hybrid search](https://opensearch.org/docs/latest/search-plugins/hybrid-search/) 機能を利用しています。
51 |
52 | また、検索の用途に合わせて document モードと chunk モードという 2 つの検索単位でドキュメントを検索することが可能です。
53 |
54 | - document モード
55 | - document 単位で検索結果を返します。例えば、データソースにファイル A とファイル B があった時、OpenSearch では chunk A-1、chunk A-2、chunk B-1、chunk B-2、chunk B-3 のように保存されています。この時この検索モードでは、同じファイルのデータが複数返ってくることはありません。つまり、これらのチャンクの中で検索クエリとの関連度が高い順にソートされ、同じドキュメントのチャンクであれば最もスコアの高いチャンクのみ返却されます。
56 | - 主要なユースケースはドキュメント検索です。
57 | - 内部的には [collapse processor](https://opensearch.org/docs/latest/search-plugins/search-pipelines/collapse-processor/) を使用しています
58 | - chunk モード
59 | - chunk 単位で検索結果を返します。document モードでは同じドキュメントで結果が重複しないような処理が行われましたが、chunk モードでは重複排除が行われません。
60 | - 主要なユースケースは RAG です。
61 |
62 | #### マッピング定義
63 |
64 | このサンプル実装における、Amazon OpenSearch Service のインデックスのマッピング定義は以下の通りです。検索結果のフィルタに使用したい項目を増やす場合は、ここにその項目を追加する必要があります。
65 |
66 | ```json
67 | "mappings": {
68 | "_meta": {"model_id": model_id}, # テキスト埋め込みに使用するモデルの ID
69 | "properties": {
70 | "vector": { # ベクトル検索用ベクトルデータ
71 | "type": "knn_vector",
72 | "dimension": dimension, # テキスト埋め込みベクトルの次元数
73 | "method": {
74 | "engine": "lucene",
75 | "space_type": "cosinesimil",
76 | "name": "hnsw",
77 | "parameters": {},
78 | },
79 | },
80 | "docs_root": {"type": "keyword"}, # ドキュメントが格納されている S3 パス
81 | "doc_name": {"type": "keyword"}, # ドキュメント名
82 | "keyword": {"type": "text", "analyzer": "custom_sudachi_analyzer"}, # テキスト検索用テキスト
83 | "service": {"type": "keyword"}, # 検索結果のフィルタに使うための情報
84 | },
85 | }
86 | ```
87 |
88 | ベクトル検索に必要なベクトルデータ vector と、vector と対になる、テキスト検索に必要なテキストデータ keyword をはじめとして、データの大元のドキュメントが格納されているファイル格納パスの docs_root, doc_name や、ドキュメントの属性 service(このサンプルでは AWS サービス名)などが設定されています。
89 |
90 | ### データ取り込み処理
91 |
92 | データ取込用の ECS タスクでは、以下の処理を実行しています。
93 |
94 | - OpenSearch インデックスの作成
95 | - インデックスに登録したい項目を変更したい場合は、packages/cdk/ecs/ingest-data/app/opensearch.py の create_index() を変更してください。
96 | - 指定された S3 パスにあるドキュメントをテキストに変換
97 | - テキストファイルと PDF ファイルのみ動作確認済みです。その他のファイル形式の読み込みに対応する場合は、packages/cdk/ecs/ingest-data/app/utils.py の read_file() を変更してください。
98 | - 変換したテキストをチャンク分割
99 | - 指定された文字数以内のキリの良い位置でチャンク分割する実装になっています。チャンク分割ロジックを変更したい場合は、packages/cdk/ecs/ingest-data/app/opensearch.py の split_text() を変更してください。
100 | - チャンクをベクトルに変換
101 | - Titan embeddings v2 を使う実装になっています。埋め込みモデルを変更したい場合は、packages/cdk/ecs/ingest-data/app/opensearch.py の embed_file() を変更してください。
102 | - ベクトルとその他の関連データを OpenSearch インデックスに登録
103 |
104 | ### 検索パイプライン
105 |
106 | このサンプル実装では、ドキュメント単位の検索機能とハイブリッド検索機能を OpenSearch の検索パイプライン機能を使って実現しています。実装されている検索パイプラインは以下の 3種類です。
107 |
108 | #### collapse-hybrid-search-pipeline
109 |
110 | ドキュメント単位検索とハイブリッド検索を組み合わせた検索パイプライン。
111 |
112 | ```python
113 | index_body = {
114 | "description": "Pipeline for hybrid search and collapse",
115 | "phase_results_processors": [
116 | {
117 | "normalization-processor": {
118 | "normalization": {"technique": "min_max"},
119 | "combination": {
120 | "technique": "arithmetic_mean",
121 | "parameters": {"weights": [0.5, 0.5]},
122 | },
123 | }
124 | }
125 | ],
126 | "response_processors": [
127 | {
128 | "collapse": {
129 | "field": "doc_name"
130 | }
131 | }
132 | ]
133 | }
134 | ```
135 |
136 | #### collapse-search-pipeline
137 |
138 | ドキュメント単位検索のための検索パイプライン。
139 |
140 | ```python
141 | index_body = {
142 | "description": "Pipeline for collapse",
143 | "response_processors": [
144 | {
145 | "collapse": {
146 | "field": "doc_name"
147 | }
148 | }
149 | ]
150 | }
151 | ```
152 |
153 | #### hybrid-search-pipeline
154 |
155 | ハイブリッド検索のための検索パイプライン。
156 |
157 | ```python
158 | index_body = {
159 | "description": "Pipeline for hybrid search",
160 | "phase_results_processors": [
161 | {
162 | "normalization-processor": {
163 | "normalization": {"technique": "min_max"},
164 | "combination": {
165 | "technique": "arithmetic_mean",
166 | "parameters": {"weights": [0.5, 0.5]},
167 | },
168 | }
169 | }
170 | ],
171 | }
172 | ```
173 |
--------------------------------------------------------------------------------
/docs/local-development.md:
--------------------------------------------------------------------------------
1 | # ローカルで開発する場合について
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "opensearch-intelligent-search-jp",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "npx -w packages/cdk jest",
8 | "cdk:deploy": "npx -w packages/cdk cdk deploy"
9 | },
10 | "keywords": [],
11 | "author": "",
12 | "workspaces": [
13 | "packages/cdk",
14 | "packages/ui"
15 | ],
16 | "devDependencies": {
17 | "eslint-config-prettier": "^9.1.0",
18 | "prettier": "^3.2.5",
19 | "prettier-plugin-organize-imports": "^3.2.4"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/cdk/.gitignore:
--------------------------------------------------------------------------------
1 | *.js
2 | !jest.config.js
3 | *.d.ts
4 | node_modules
5 |
6 | # CDK asset staging directory
7 | .cdk.staging
8 | cdk.out
9 | test/__snapshots__
--------------------------------------------------------------------------------
/packages/cdk/.npmignore:
--------------------------------------------------------------------------------
1 | *.ts
2 | !*.d.ts
3 |
4 | # CDK asset staging directory
5 | .cdk.staging
6 | cdk.out
7 |
--------------------------------------------------------------------------------
/packages/cdk/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to your CDK TypeScript project
2 |
3 | This is a blank project for CDK development with TypeScript.
4 |
5 | The `cdk.json` file tells the CDK Toolkit how to execute your app.
6 |
7 | ## Useful commands
8 |
9 | * `npm run build` compile typescript to js
10 | * `npm run watch` watch for changes and compile
11 | * `npm run test` perform the jest unit tests
12 | * `npx cdk deploy` deploy this stack to your default AWS account/region
13 | * `npx cdk diff` compare deployed stack with current state
14 | * `npx cdk synth` emits the synthesized CloudFormation template
15 |
--------------------------------------------------------------------------------
/packages/cdk/bin/cdk.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import * as cdk from 'aws-cdk-lib';
3 | import 'source-map-support/register';
4 | import { OpensearchIntelligentSearchJpStack } from '../lib/opensearch-intelligent-search-jp-stack';
5 |
6 | const app = new cdk.App();
7 | new OpensearchIntelligentSearchJpStack(
8 | app,
9 | 'OpensearchIntelligentSearchJpStack',
10 | {
11 | /* If you don't specify 'env', this stack will be environment-agnostic.
12 | * Account/Region-dependent features and context lookups will not work,
13 | * but a single synthesized template can be deployed anywhere. */
14 | /* Uncomment the next line to specialize this stack for the AWS Account
15 | * and Region that are implied by the current CLI configuration. */
16 | // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
17 | /* Uncomment the next line if you know exactly what Account and Region you
18 | * want to deploy the stack to. */
19 | // env: { account: '123456789012', region: 'us-east-1' },
20 | /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
21 | }
22 | );
23 |
--------------------------------------------------------------------------------
/packages/cdk/cdk.json:
--------------------------------------------------------------------------------
1 | {
2 | "app": "npx ts-node --prefer-ts-exts bin/cdk.ts",
3 | "watch": {
4 | "include": ["**"],
5 | "exclude": [
6 | "README.md",
7 | "cdk*.json",
8 | "**/*.d.ts",
9 | "**/*.js",
10 | "tsconfig.json",
11 | "package*.json",
12 | "yarn.lock",
13 | "node_modules",
14 | "test"
15 | ]
16 | },
17 | "context": {
18 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true,
19 | "@aws-cdk/core:checkSecretUsage": true,
20 | "@aws-cdk/core:target-partitions": ["aws", "aws-cn"],
21 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
22 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
23 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true,
24 | "@aws-cdk/aws-iam:minimizePolicies": true,
25 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true,
26 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true,
27 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true,
28 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true,
29 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true,
30 | "@aws-cdk/core:enablePartitionLiterals": true,
31 | "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true,
32 | "@aws-cdk/aws-iam:standardizedServicePrincipals": true,
33 | "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true,
34 | "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true,
35 | "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
36 | "@aws-cdk/aws-route53-patters:useCertificate": true,
37 | "@aws-cdk/customresources:installLatestAwsSdkDefault": false,
38 | "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true,
39 | "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true,
40 | "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true,
41 | "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true,
42 | "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true,
43 | "@aws-cdk/aws-redshift:columnId": true,
44 | "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true,
45 | "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true,
46 | "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true,
47 | "@aws-cdk/aws-kms:aliasNameRef": true,
48 | "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true,
49 | "@aws-cdk/core:includePrefixInUniqueNameGeneration": true,
50 | "@aws-cdk/aws-efs:denyAnonymousAccess": true,
51 | "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true,
52 | "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true,
53 | "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true,
54 | "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true,
55 | "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true,
56 | "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true,
57 | "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true,
58 | "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true,
59 | "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true,
60 | "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true,
61 | "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true,
62 | "@aws-cdk/aws-eks:nodegroupNameAttribute": true,
63 | "bedrockRegion": "us-east-1",
64 | "selfSignUpEnabled": true
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/packages/cdk/custom-resource/associate-package/index.py:
--------------------------------------------------------------------------------
1 | import boto3
2 | import json
3 | import time
4 |
5 | opensearch = boto3.client('opensearch')
6 |
7 |
8 | def handler(event, context):
9 | print("Received event: " + json.dumps(event, indent=2))
10 |
11 | domain_name = event['ResourceProperties']['DomainName']
12 |
13 | if event['RequestType'] == 'Create':
14 | # OpenSearch ドメインが作成完了してから、Package を Associate 可能になるまで時間がかかることがあるため、一定時間待機
15 | time.sleep(120)
16 |
17 | # OpenSearch ドメインが既に作成されているか確認。Domain Status の processing が true だった場合は待機
18 | while True:
19 | res = opensearch.describe_domain(
20 | DomainName=domain_name
21 | )
22 |
23 | if res['DomainStatus']['Processing'] is False:
24 | break
25 |
26 | time.sleep(10)
27 |
28 | # OpenSearch 2.13 用の Sudachi Package ID を取得する(リージョンによって ID が変わる)
29 | res = opensearch.describe_packages(
30 | Filters=[
31 | {
32 | "Name": "PackageName",
33 | "Value": ["analysis-sudachi"]
34 | },
35 | {
36 | "Name": "EngineVersion",
37 | "Value": ["OpenSearch_2.13"]
38 | }]
39 | )
40 |
41 | package_id = res['PackageDetailsList'][0]['PackageID']
42 |
43 | res = opensearch.list_domains_for_package(
44 | PackageID=package_id
45 | )
46 |
47 | # もし該当のドメインにパッケージがまだ Associate されていない場合は、Associate する
48 | skip_association = False
49 | for detail in res['DomainPackageDetailsList']:
50 | if detail['DomainName'] == domain_name:
51 | skip_association = True
52 |
53 | if not skip_association:
54 | opensearch.associate_package(
55 | DomainName=domain_name,
56 | PackageID=package_id
57 | )
58 |
59 | return {"package_id": package_id}
60 | if event['RequestType'] == 'Delete':
61 | return {}
62 | if event['RequestType'] == 'Update':
63 | return {}
64 |
65 |
66 | def is_complete(event, context):
67 | print("Received event: " + json.dumps(event, indent=2))
68 |
69 | domain_name = event['ResourceProperties']['DomainName']
70 |
71 | if event['RequestType'] == 'Create':
72 | package_id = event['package_id']
73 |
74 | res = opensearch.list_domains_for_package(
75 | PackageID=package_id
76 | )
77 |
78 | # もし domain_name に一致するドメインがあり、そのドメインのステータスが ACTIVE だった場合は、complete とする
79 | for detail in res['DomainPackageDetailsList']:
80 | if detail['DomainName'] == domain_name and detail['DomainPackageStatus'] == 'ACTIVE':
81 | return {'IsComplete': True}
82 |
83 | elif event['RequestType'] == 'Delete':
84 | return {'IsComplete': True}
85 |
86 | elif event['RequestType'] == 'Update':
87 | return {'IsComplete': True}
88 |
89 | return {'IsComplete': False}
90 |
--------------------------------------------------------------------------------
/packages/cdk/ecs/ingest-data/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM public.ecr.aws/docker/library/python:3.11.6-slim-bookworm
2 |
3 | RUN apt-get update && apt-get install -y \
4 | build-essential cmake \
5 | # opencv package requirements
6 | libgl1 \
7 | libglib2.0-0 \
8 | # unstructured package requirements for file type detection
9 | libmagic-mgc libmagic1 \
10 | && rm -rf /var/lib/apt/lists/*
11 |
12 | WORKDIR /backend
13 |
14 | COPY requirements.txt .
15 | RUN pip3 install -r requirements.txt --no-cache-dir
16 |
17 | COPY ./app ./app
18 |
19 | ENTRYPOINT [ "python3" ]
20 | CMD ["-u", "./app/main.py"]
--------------------------------------------------------------------------------
/packages/cdk/ecs/ingest-data/app/main.py:
--------------------------------------------------------------------------------
1 | import argparse
2 | import os
3 | import requests
4 |
5 | from opensearch import OpenSearchController
6 | from utils import *
7 |
8 |
9 | METADATA_URI = os.environ.get("ECS_CONTAINER_METADATA_URI_V4")
10 |
11 |
12 | def get_exec_id() -> str:
13 | # Get task id from ECS metadata
14 | # Ref: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-metadata-endpoint-v4.html#task-metadata-endpoint-v4-enable
15 | response = requests.get(f"{METADATA_URI}/task")
16 | data = response.json()
17 | task_arn = data.get("TaskARN", "")
18 | task_id = task_arn.split("/")[-1]
19 | return task_id
20 |
21 |
22 | def ingest_data(
23 | host_http, index_name, dimension, model_id, docs_url, bedrock_region
24 | ):
25 |
26 | exec_id = ""
27 | try:
28 | exec_id = get_exec_id()
29 | except Exception as e:
30 | print(f"[ERROR] Failed to get exec_id: {e}")
31 | exec_id = "FAILED_TO_GET_ECS_EXEC_ID"
32 |
33 | print("exec_id:", exec_id)
34 |
35 | cfg = {
36 | "host_http": host_http,
37 | "index_name": index_name,
38 | "dimension": dimension,
39 | "model_id": model_id,
40 | "docs_url": docs_url,
41 | "bedrock_region": bedrock_region,
42 | "max_chunk_length": 400,
43 | }
44 |
45 | opensearch = OpenSearchController(cfg)
46 | opensearch.ingest_data()
47 |
48 |
49 | if __name__ == "__main__":
50 | parser = argparse.ArgumentParser()
51 | parser.add_argument(
52 | "--host-http",
53 | type=str,
54 | default=os.environ.get("OPENSEARCH_ENDPOINT", ""),
55 | )
56 | args = parser.parse_args()
57 |
58 | index_name = os.environ.get("OPENSEARCH_INDEX_NAME", "")
59 | dimension = os.environ.get("EMBED_DIMENSION", 1024)
60 | model_id = os.environ.get("EMBED_MODEL_ID", "")
61 | docs_url = os.environ.get("DOCUMENT_S3_URI", "")
62 | bedrock_region = os.environ.get("BEDROCK_REGION", "")
63 |
64 | ingest_data(
65 | args.host_http,
66 | index_name,
67 | dimension,
68 | model_id,
69 | docs_url,
70 | bedrock_region,
71 | )
72 |
73 | print("Data ingestion was completed.")
74 |
--------------------------------------------------------------------------------
/packages/cdk/ecs/ingest-data/app/opensearch.py:
--------------------------------------------------------------------------------
1 | from opensearchpy import (
2 | OpenSearch,
3 | RequestsHttpConnection,
4 | AWSV4SignerAuth,
5 | helpers,
6 | )
7 | import boto3
8 | import json
9 | import time
10 | import re
11 | import utils
12 | from concurrent.futures import ThreadPoolExecutor
13 |
14 |
15 | class OpenSearchController:
16 | def __init__(self, cfg):
17 | self.cfg = cfg
18 | self.bedrock_runtime = boto3.client(
19 | service_name="bedrock-runtime",
20 | region_name=cfg["bedrock_region"],
21 | )
22 | self.aos_client = self.get_aos_client()
23 |
24 | def get_aos_client(self):
25 | host = self.cfg["host_http"]
26 | region = host.split(".")[1]
27 |
28 | service = "es"
29 | credentials = boto3.Session().get_credentials()
30 | auth = AWSV4SignerAuth(credentials, region, service)
31 |
32 | client = OpenSearch(
33 | hosts=[{"host": host, "port": 443}],
34 | http_auth=auth,
35 | use_ssl=True,
36 | verify_certs=True,
37 | connection_class=RequestsHttpConnection,
38 | pool_maxsize=20,
39 | )
40 |
41 | return client
42 |
43 | def init_cluster_settings(self):
44 | # インデックス時のスレッド数を指定
45 | self.aos_client.cluster.put_settings(
46 | body={
47 | "persistent": {
48 | "knn.algo_param.index_thread_qty": "4",
49 | }
50 | }
51 | )
52 |
53 | def create_index(self):
54 | index_name = self.cfg["index_name"]
55 | model_id = self.cfg["model_id"]
56 | dimension = self.cfg["dimension"]
57 |
58 | if not self.aos_client.indices.exists(index_name):
59 | print("create index")
60 |
61 | self.aos_client.indices.create(
62 | index_name,
63 | body={
64 | "settings": {
65 | "index": {
66 | "analysis": {
67 | "filter": {
68 | "custom_sudachi_part_of_speech": {
69 | "type": "sudachi_part_of_speech",
70 | "stoptags": [
71 | "感動詞,フィラー",
72 | "接頭辞",
73 | "代名詞",
74 | "助詞",
75 | "助動詞",
76 | "動詞,一般,*,*,*,終止形-一般",
77 | "名詞,普通名詞,副詞可能",
78 | ],
79 | }
80 | },
81 | "analyzer": {
82 | "custom_sudachi_analyzer": {
83 | "filter": [
84 | "sudachi_normalizedform",
85 | "custom_sudachi_part_of_speech",
86 | ],
87 | "char_filter": ["icu_normalizer"],
88 | "type": "custom",
89 | "tokenizer": "sudachi_tokenizer",
90 | }
91 | },
92 | },
93 | "knn": True,
94 | # インデックス時のパフォーマンスを考慮して refresh_interval を大きく設定
95 | "refresh_interval": "1000s",
96 | }
97 | },
98 | "mappings": {
99 | "_meta": {"model_id": model_id},
100 | "properties": {
101 | "vector": {
102 | "type": "knn_vector",
103 | "dimension": dimension,
104 | "method": {
105 | "engine": "lucene",
106 | "space_type": "cosinesimil",
107 | "name": "hnsw",
108 | "parameters": {},
109 | },
110 | },
111 | "docs_root": {"type": "keyword"},
112 | "doc_name": {"type": "keyword"},
113 | "keyword": {
114 | "type": "text",
115 | "analyzer": "custom_sudachi_analyzer",
116 | },
117 | "service": {"type": "keyword"},
118 | },
119 | },
120 | },
121 | )
122 |
123 | print("Index was created.")
124 | time.sleep(20)
125 |
126 | def split_text(self, text):
127 | chunks = []
128 | current_chunk = ""
129 | current_length = 0
130 | max_length = self.cfg["max_chunk_length"]
131 |
132 | # for English sentences
133 | period_pattern = re.compile(r"[.!?][\s]")
134 |
135 | # for Japanese sentences
136 | kuten_pattern = re.compile(r"[。!?…\n]")
137 |
138 | split_pattern = re.compile(
139 | rf"(.{{1,{max_length}}}?({period_pattern.pattern}|{kuten_pattern.pattern}))",
140 | flags=re.DOTALL,
141 | )
142 | find = split_pattern.finditer(text)
143 |
144 | while list(find)[0].span()[0] != 0:
145 | max_length += 10
146 | split_pattern = re.compile(
147 | rf"(.{{1,{max_length}}}?({period_pattern.pattern}|{kuten_pattern.pattern}))",
148 | flags=re.DOTALL,
149 | )
150 | find = split_pattern.finditer(text)
151 |
152 | for match in split_pattern.finditer(text):
153 | chunk = match.group(1)
154 | chunk_length = len(chunk)
155 |
156 | if current_length + chunk_length <= max_length:
157 | current_chunk += chunk
158 | current_length += chunk_length
159 | else:
160 |
161 | chunks.append(current_chunk)
162 | current_chunk = chunk
163 | current_length = chunk_length
164 |
165 | chunks.append(current_chunk)
166 |
167 | return chunks
168 |
169 | def embed_file(self, file_name):
170 |
171 | text = utils.read_file(file_name)
172 |
173 | chunks = self.split_text(text)
174 |
175 | if "cohere" in self.cfg["model_id"]:
176 | vectors = self.embed_with_cohere(chunks)
177 | else:
178 | vectors = self.embed_with_titan(chunks)
179 |
180 | return vectors, chunks
181 |
182 | def embed_with_titan(self, chunks):
183 | vectors = []
184 | for chunk in chunks:
185 | # API schema is adjust to Titan embedding model
186 | body = json.dumps({"inputText": chunk})
187 | query_response = self.bedrock_runtime.invoke_model(
188 | body=body,
189 | modelId=self.cfg["model_id"],
190 | accept="application/json",
191 | contentType="application/json",
192 | )
193 | vectors.append(
194 | json.loads(query_response["body"].read()).get("embedding")
195 | )
196 | return vectors
197 |
198 | def embed_with_cohere(self, chunks):
199 | vectors = []
200 | max_text_num = 96
201 | for i in range(0, len(chunks), max_text_num):
202 | body = json.dumps(
203 | {
204 | "texts": chunks[i : min(len(chunks), i + max_text_num)],
205 | "input_type": "search_document",
206 | "embedding_types": ["float"],
207 | }
208 | )
209 | query_response = self.bedrock_runtime.invoke_model(
210 | body=body,
211 | modelId=self.cfg["model_id"],
212 | accept="*/*",
213 | contentType="application/json",
214 | )
215 | vectors.extend(
216 | json.loads(query_response["body"].read()).get("embeddings")[
217 | "float"
218 | ]
219 | )
220 | return vectors
221 |
222 | def parse_response(query_response):
223 |
224 | response_body = json.loads(query_response.get("body").read())
225 | return response_body.get("embedding")
226 |
227 | def embed_documents(self, file_list):
228 | vectors = []
229 | counter = 0
230 | for file_name in file_list:
231 | print(f"embedding: {counter}/{len(file_list)}")
232 | counter += 1
233 | try:
234 | chunk_vectors, texts = self.embed_file(file_name)
235 |
236 | except Exception as e:
237 | continue
238 |
239 | for i, embedding in enumerate(chunk_vectors):
240 | vectors.append(
241 | {
242 | "_index": self.cfg["index_name"],
243 | "vector": embedding,
244 | "docs_root": "/".join(file_name.split("/")[:3]),
245 | "doc_name": "/".join(file_name.split("/")[3:]),
246 | "keyword": texts[i],
247 | "service": file_name.split("/")[-2],
248 | }
249 | )
250 |
251 | print(
252 | f"{len(file_list)} documents ({len(vectors)} chunks) were embedded."
253 | )
254 | return vectors
255 |
256 | def create_search_pipeline(self):
257 | # collapse-hybrid-search-pipeline の作成
258 | index_body = {
259 | "description": "Pipeline for hybrid search and collapse",
260 | "phase_results_processors": [
261 | {
262 | "normalization-processor": {
263 | "normalization": {"technique": "min_max"},
264 | "combination": {
265 | "technique": "arithmetic_mean",
266 | "parameters": {"weights": [0.5, 0.5]},
267 | },
268 | }
269 | }
270 | ],
271 | "response_processors": [{"collapse": {"field": "doc_name"}}],
272 | }
273 |
274 | self.aos_client.http.put(
275 | "/_search/pipeline/collapse-hybrid-search-pipeline", body=index_body
276 | )
277 |
278 | # collapse-search-pipeline の作成
279 | index_body = {
280 | "description": "Pipeline for collapse",
281 | "response_processors": [{"collapse": {"field": "doc_name"}}],
282 | }
283 |
284 | self.aos_client.http.put(
285 | "/_search/pipeline/collapse-search-pipeline", body=index_body
286 | )
287 |
288 | # hybrid-search-pipeline の作成
289 | index_body = {
290 | "description": "Pipeline for hybrid search",
291 | "phase_results_processors": [
292 | {
293 | "normalization-processor": {
294 | "normalization": {"technique": "min_max"},
295 | "combination": {
296 | "technique": "arithmetic_mean",
297 | "parameters": {"weights": [0.5, 0.5]},
298 | },
299 | }
300 | }
301 | ],
302 | }
303 |
304 | self.aos_client.http.put(
305 | "/_search/pipeline/hybrid-search-pipeline", body=index_body
306 | )
307 |
308 | def update_index(self):
309 | # index 作成時に大きく設定していた refresh_interval を元に戻す
310 | index_name = self.cfg["index_name"]
311 | self.aos_client.indices.put_settings(
312 | index=index_name,
313 | body={"index": {"refresh_interval": "60s"}},
314 | )
315 |
316 | def ingest_data(self):
317 | self.init_cluster_settings()
318 | self.create_search_pipeline()
319 | self.create_index()
320 | docs_url = self.cfg["docs_url"]
321 |
322 | file_list = utils.get_all_filepath(docs_url)
323 |
324 | with ThreadPoolExecutor(max_workers=8) as executor:
325 | thread = executor.submit(self.embed_documents, file_list)
326 | vectors = thread.result()
327 |
328 | batch_size = 50
329 | for i in range(0, len(vectors), batch_size):
330 | helpers.bulk(
331 | self.aos_client,
332 | vectors[i : min(i + batch_size, len(vectors))],
333 | request_timeout=1000,
334 | )
335 |
336 | self.update_index()
337 |
338 | print("Process finished.")
339 |
--------------------------------------------------------------------------------
/packages/cdk/ecs/ingest-data/app/utils.py:
--------------------------------------------------------------------------------
1 | import boto3
2 | import tempfile
3 | import os
4 |
5 | # from unstructured.partition.auto import partition
6 | from langchain_community.document_loaders import (
7 | Docx2txtLoader,
8 | TextLoader,
9 | UnstructuredHTMLLoader,
10 | UnstructuredPowerPointLoader,
11 | PyPDFLoader,
12 | )
13 |
14 | s3_client = boto3.client("s3")
15 |
16 |
17 | def parse_s3_uri(s3_uri):
18 | """
19 | S3のURIをバケット名、キー名、拡張子に分割する
20 |
21 | Args:
22 | s3_uri (str): 例's3://bucket_name/test/test.txt'
23 | Returns:
24 | bucket: バケット名(bucket_name)
25 | key: キー名(test/test.txt)
26 | extension: 拡張子(.txt)
27 | """
28 | bucket = s3_uri.split("//")[1].split("/")[0]
29 | key = '/'.join(s3_uri.split("//")[1].split("/")[1:])
30 | extension = os.path.splitext(key)[-1]
31 |
32 | return bucket, key, extension
33 |
34 |
35 | def read_file(file_url):
36 | bucket, key, extension = parse_s3_uri(file_url)
37 |
38 | text = ""
39 |
40 | with tempfile.NamedTemporaryFile(
41 | delete=True, suffix=extension
42 | ) as temp_file:
43 | temp_file_path = temp_file.name
44 | s3_client.download_file(bucket, key, temp_file_path)
45 |
46 | print(f"Load file: {os.path.basename(key)}")
47 |
48 | if extension == ".txt":
49 | text = load_text(temp_file_path)
50 | if extension == ".pdf":
51 | text = load_pdf(temp_file_path)
52 | text = (
53 | text.replace("\n", "").replace("\r", "").replace("\u00A0", " ")
54 | )
55 | if extension == ".docx":
56 | text = load_word(temp_file_path)
57 | if extension == ".pptx":
58 | text = load_ppt(temp_file_path)
59 | if extension == ".html":
60 | text = load_html(temp_file_path)
61 | text = (
62 | text.replace("\n", "").replace("\r", "").replace("\u00A0", " ")
63 | )
64 |
65 | return text
66 |
67 |
68 | def load_text(file_path):
69 | try:
70 | loader = TextLoader(str(file_path))
71 | except Exception as e:
72 | print(e)
73 | pages = loader.load_and_split()
74 | text = ""
75 | for page in pages:
76 | text += page.page_content
77 | return text
78 |
79 |
80 | def load_pdf(file_path):
81 | try:
82 | loader = PyPDFLoader(str(file_path))
83 | except Exception as e:
84 | print(e)
85 |
86 | pages = loader.load_and_split()
87 | text = ""
88 | for page in pages:
89 | try:
90 | text += bytes(page.page_content, "latin1").decode("shift_jis")
91 | except UnicodeEncodeError:
92 | text += page.page_content
93 | except UnicodeDecodeError:
94 | text += "Unicode Decode Error"
95 |
96 | return text
97 |
98 |
99 | def load_word(file_path):
100 | try:
101 | loader = Docx2txtLoader(str(file_path))
102 | except Exception as e:
103 | print(e)
104 | pages = loader.load_and_split()
105 | text = ""
106 | for page in pages:
107 | text += page.page_content
108 | return text
109 |
110 |
111 | def load_ppt(file_path):
112 | try:
113 | loader = UnstructuredPowerPointLoader(str(file_path))
114 | except Exception as e:
115 | print(e)
116 | pages = loader.load_and_split()
117 | text = ""
118 | for page in pages:
119 | text += page.page_content
120 | return text
121 |
122 |
123 | def load_html(file_path):
124 | try:
125 | loader = UnstructuredHTMLLoader(str(file_path))
126 | except Exception as e:
127 | print(e)
128 | pages = loader.load_and_split()
129 | text = ""
130 |
131 | for page in pages:
132 | text += page.page_content
133 | return text
134 |
135 |
136 | def get_all_filepath(file_url):
137 | bucket_name = file_url.split("/")[2]
138 | prefix = "/".join(file_url.split("/")[3:])
139 |
140 | file_list = []
141 | kwargs = {"Bucket": bucket_name, "Prefix": prefix}
142 | while True:
143 | response = s3_client.list_objects_v2(**kwargs)
144 | if response.get("Contents"):
145 | file_list.extend(
146 | [f's3://{bucket_name}/{content["Key"]}' for content in response["Contents"]]
147 | )
148 | if not response.get("IsTruncated"): # レスポンスが切り捨てられていない場合
149 | break
150 | kwargs["ContinuationToken"] = response["NextContinuationToken"]
151 |
152 | return file_list
153 |
154 |
155 | def get_all_keys(file_url):
156 | bucket_name = file_url.split("/")[2]
157 | prefix = "/".join(file_url.split("/")[3:])
158 |
159 | file_list = []
160 | objects = s3_client.list_objects_v2(Bucket=bucket_name, Prefix=prefix)
161 | if "Contents" in objects:
162 | file_list.extend(([content["Key"] for content in objects["Contents"]]))
163 | while objects.get("isTruncated"):
164 | start_after = file_list[-1]
165 | objects = s3_client.list_objects_v2(
166 | Bucket=bucket_name, Prefix=prefix, StartAfter=start_after
167 | )
168 | if "Contents" in objects:
169 | file_list.extend(
170 | ([content["Key"] for content in objects["Contents"]])
171 | )
172 |
173 | return file_list
174 |
175 |
176 | def get_documents(file_url, dst_path):
177 |
178 | bucket_name = file_url.split("/")[2]
179 | file_list = get_all_keys(file_url)
180 |
181 | for s3_key in file_list:
182 | dst_dir = f"{dst_path}/{s3_key}"
183 | if not os.path.exists(os.path.dirname(dst_dir)):
184 | os.makedirs(os.path.dirname(dst_dir))
185 | s3_client.download_file(bucket_name, s3_key, f"{dst_path}/{s3_key}")
186 |
--------------------------------------------------------------------------------
/packages/cdk/ecs/ingest-data/requirements.txt:
--------------------------------------------------------------------------------
1 | boto3==1.34.105
2 | langchain==0.1.20
3 | pypdf==4.2.0
4 | unstructured==0.13.6
5 | python-pptx==0.6.23
6 | docx2txt==0.8
7 | opensearch-py==2.5.0
--------------------------------------------------------------------------------
/packages/cdk/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | testEnvironment: 'node',
3 | roots: ['Index Name:
108 | 124 |Search Result Unit:
127 | 143 |Search Method:
146 | 162 |{item.text}
200 |