Accessing document fields in scripts
출처: 문서
본문
스크립트에서 문서 필드 접근하기
스크립트는 OpenSearch가 스크립트 실행 전에 주입하는 변수를 통해 문서 데이터를 읽어요. 스크립트에서 사용할 수 있는 변수는 컨텍스트에 따라 달라져요. 업데이트 중에 문서를 수정하는 스크립트는 검색 결과에 점수를 매기는 스크립트와 다른 변수 집합을 받거든요. 올바른 접근 경로를 선택하는 것이 성능에서 중요해요. 검색이나 집계 스크립트는 후보 문서마다 한 번씩 실행되고, _source 필드를 읽는 것이 doc values를 읽는 것보다 문서당 훨씬 많은 리소스를 소모하기 때문이에요.
이 페이지에서는 주요 컨텍스트 계열이 제공하는 접근 경로와 변수를 설명해요. 개별 컨텍스트의 변수와 반환 타입은 스크립트 컨텍스트를 참고하세요.
이 페이지의 예제는 scripting-products 인덱스를 사용해요. 인덱스를 만드는 방법은 테스트 설정을 참고하세요.
업데이트 스크립트
Update Document, Update By Query, Reindex API의 스크립트는 ctx 변수 하나를 받아요. 다음 표는 ctx 필드를 정리한 거예요.
| 필드 | 설명 |
|---|---|
ctx._source |
수정 가능한 맵 형태의 문서 _source예요. 항목에 값을 할당하면 OpenSearch가 쓰는 문서를 바꿀 수 있어요. |
ctx.op |
문서에 적용할 작업이에요. 수정된 문서를 쓰려면 index, 문서를 삭제하려면 delete, 변경 없이 쓰기를 건너뛰려면 noop으로 설정해요. |
ctx._index, ctx._id, 그 외 메타데이터 필드 |
처리 중인 문서의 메타데이터예요. 일부는 읽기 전용이에요. |
다음 업데이트는 quantity가 0인 문서 4에 대한 입고를 기록해요. 스크립트는 ctx._source.quantity를 읽고, 입고된 수량을 더한 뒤, 그 결과값으로 on_sale을 도출해요. 입고된 수량이 없으면 ctx.op를 noop으로 설정해서 OpenSearch가 쓰기를 건너뛰게 해요:
POST scripting-products/_update/4
{
"script": {
"lang": "painless",
"source": "if (params.received == 0) { ctx.op = 'noop' } else { ctx._source.quantity += params.received; ctx._source.on_sale = ctx._source.quantity > 20 }",
"params": { "received": 25 }
}
}
{% include copy-curl.html %}
result 필드는 문서가 쓰였음을 알려주고, _version은 증가해요:
응답
{
"_index": "scripting-products",
"_id": "4",
"_version": 2,
"result": "updated",
"_shards": {
"total": 2,
"successful": 1,
"failed": 0
},
"_seq_no": 5,
"_primary_term": 1
}
이제 문서의 quantity는 25이고 on_sale 값은 true예요. 같은 요청을 "received": 0으로 보내면 noop 분기를 타는데, 이때는 "result": "noop"이 반환되고 _version은 그대로 유지돼요.
업데이트 스크립트는 ctx._source를 통해서만 문서 데이터를 읽고 수정할 수 있어요. 다음 섹션에서 설명하는 세 가지 접근 경로 중 어느 것도 사용할 수 없어요. doc은 cannot resolve symbol [doc] 오류로 컴파일에 실패하고, params._source와 params._fields는 null이기 때문에 둘 중 하나에서 필드를 읽으면 런타임에 null_pointer_exception이 발생해요.
{: .note}
검색 및 집계 스크립트
검색과 집계의 스크립트는 매칭될 가능성이 있는 모든 문서마다 한 번씩 실행돼요. 큰 인덱스에서는 요청 하나에 스크립트가 수백만 번 실행된다는 뜻이에요. 스크립트 필드는 예외예요. 반환되는 결과마다 한 번씩 실행되기 때문에 인덱스 크기가 아니라 페이지 크기에 좌우돼요.
이런 스크립트는 doc values, _source 필드, stored fields를 통해 필드 값을 읽어요. 다음 표는 세 가지 접근 유형을 요약한 거예요.
| 접근 유형 | 문법 | 리소스 사용량 | 사용 시점 |
|---|---|---|---|
| Doc values | doc['field'] |
가장 낮음 | 숫자, 날짜, geopoint, keyword에 대해 점수 계산, 정렬, 필터링, 또는 집계할 때. |
_source |
params._source.field |
가장 높음 | 스크립트 필드가 한 페이지의 결과를 위해 JSON 객체, text 필드, 또는 정확한 원본 값이 필요할 때. |
| Stored fields | params._fields['field'].value |
높음 | _source가 크고 스크립트가 그중 몇 개의 작은 필드만 필요할 때. |
점수 계산이나 정렬에 기여하는 스크립트는 현재 문서의 관련성 점수인 _score도 받아요.
Doc values
스크립트에서 필드를 읽는 가장 빠른 방법은 doc values를 사용하는 거예요. Doc values는 인덱싱 시점에 만들어지고 디스크에서 직접 읽는 컬럼 지향 구조에 필드 값을 저장해요. 이것이 바로 스크립트에 필요한 접근 패턴이에요: 하나의 필드, 수많은 문서. 필드는 doc['field_name'] 문법으로 접근해요.
Doc values는 분석된 text 필드를 제외한 모든 필드 타입에서 기본적으로 활성화되어 있어요.
다음 검색은 가격에 재고 수량을 곱해서 각 오디오 제품의 총 재고 가치를 계산해요. 두 변수를 모두 doc values에서 읽어요:
GET scripting-products/_search
{
"_source": false,
"query": { "term": { "category": "audio" } },
"script_fields": {
"inventory_value": {
"script": {
"lang": "expression",
"source": "doc['price'] * doc['quantity']"
}
}
}
}
{% include copy-curl.html %}
각 결과에 계산된 필드가 포함돼요:
응답
{
"took": 3,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 2,
"relation": "eq"
},
"max_score": 1.0,
"hits": [
{
"_index": "scripting-products",
"_id": "1",
"_score": 1.0,
"fields": {
"inventory_value": [
10499.58
]
}
},
{
"_index": "scripting-products",
"_id": "2",
"_score": 1.0,
"fields": {
"inventory_value": [
1036.0
]
}
}
]
}
}
Doc values는 숫자, 날짜, geopoint, term 같은 스칼라 값이나, 다중 값 필드에 대해서는 스칼라 배열을 반환해요. JSON 객체는 반환할 수 없기 때문에, 중첩 객체의 구조가 필요한 스크립트는 대신 _source를 읽어야 해요.
누락된 필드 처리하기
매핑에 없는 필드에 대해 doc['field']를 읽으면 No field found for [discount] in mapping을 원인으로 하는, 이유가 runtime error인 script_exception이 발생해요. Painless에서는 doc.containsKey('field')로 접근을 보호하세요:
"source": "doc.containsKey('discount') ? doc['discount'].value : 0"
expression 스크립트에는 이에 상응하는 안전장치가 없어요. 이 언어는 필드가 매핑에 존재하는지 검사하는 방법을 제공하지 않기 때문이에요. doc['field'].empty를 사용하면 매핑은 되어 있지만 현재 문서에는 없는 필드만 구분할 수 있어요. 자세한 내용은 Lucene 표현식 언어를 참고하세요.
{: .note}
text 필드 읽기
Painless에서 doc['field'] 문법은 분석된 text 필드에 fielddata가 활성화된 경우에만 동작하고, 원본 문자열이 아니라 분석된 term을 반환해요. fielddata를 활성화하면 필드의 모든 term이 JVM 힙에 로드되는데, 이는 메모리와 CPU를 모두 소모하고 노드를 불안정하게 만들 수 있어요. keyword 서브필드를 매핑하고, 스크립트가 대신 그 서브필드를 읽게 하세요.
{: .warning}
문서 소스
_source 필드는 인덱싱된 원본 JSON 문서 본문을 담고 있어요. _source를 읽으려면 params._source.field_name 문법을 사용하세요. 중첩 필드를 읽으려면 전체 경로를 사용해요. 예를 들어 테스트 인덱스의 warehouse 필드는 {"lat": 47.6062, "lon": -122.3321}로 인덱싱된 geo_point라서, 위도는 params._source.warehouse.lat으로 읽어요.
다음 검색은 두 개의 _source 필드로 카탈로그 라벨을 만들어요:
GET scripting-products/_search
{
"_source": false,
"query": { "term": { "sku": "AUD-1002" } },
"script_fields": {
"catalog_label": {
"script": {
"lang": "painless",
"source": "params._source.brand + ': ' + params._source.name"
}
}
}
}
{% include copy-curl.html %}
이어 붙인 라벨이 결과와 함께 반환돼요:
응답
{
"took": 23,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 1,
"relation": "eq"
},
"max_score": 1.0,
"hits": [
{
"_index": "scripting-products",
"_id": "2",
"_score": 1.0,
"fields": {
"catalog_label": [
"Aurora: Wireless earbuds"
]
}
}
]
}
}
_source를 읽는 것은 doc values를 읽는 것보다 훨씬 느려요. OpenSearch가 필드 하나를 읽으려면 저장된 JSON 본문 전체를 로드하고 파싱해야 하기 때문이에요. _source 필드는 적은 수의 문서에서 많은 필드를 반환하도록 최적화되어 있고, doc values는 많은 문서에서 하나의 필드를 반환하도록 최적화되어 있어요.
스크립트 필드가 한 페이지의 결과에 대한 출력을 만들 때, 그리고 필요한 값이 doc values가 제공할 수 없는 JSON 객체나 text 필드일 때 _source를 사용하세요. 점수 계산, 정렬, 필터링, 집계에는 doc values를 사용하세요.
{: .note}
Stored fields
"store": true로 매핑한 필드는 _source와 별개로 인덱스에 기록되며, params._fields['field_name'].value 문법으로 읽을 수 있어요. 테스트 인덱스에서는 sku와 brand가 stored fields예요.
.value 접미사는 필수예요. params._fields['field_name']만 단독으로 사용하면 값이 아니라 내부 필드 조회 객체를 반환하는데, 스크립트 필드에서 이것을 반환하면 cannot write xcontent for unknown value of type 오류로 실패해요. 첫 번째 값에는 .value를, 필드에 값이 여러 개일 때는 전체 목록에 .values를 사용하세요.
{: .note}
다음 검색은 brand와 sku stored fields를 하나의 식별자로 결합해요:
GET scripting-products/_search
{
"_source": false,
"query": { "term": { "sku": "AUD-1001" } },
"script_fields": {
"brand_sku": {
"script": {
"lang": "painless",
"source": "params._fields['brand'].value + ' ' + params._fields['sku'].value"
}
}
}
}
{% include copy-curl.html %}
응답에 결합된 값이 담겨 있어요:
응답
{
"took": 9,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 1,
"relation": "eq"
},
"max_score": 1.0,
"hits": [
{
"_index": "scripting-products",
"_id": "1",
"_score": 1.0,
"fields": {
"brand_sku": [
"Aurora AUD-1001"
]
}
}
]
}
}
_source 필드 자체도 stored field이기 때문에, stored field를 읽는 데는 _source를 읽는 것과 거의 같은 시간과 메모리가 들어요. Stored field와 달리 _source는 인덱싱된 정확한 JSON을 반환해요. null과 없는 필드의 차이, 그리고 요소가 하나뿐인 배열과 스칼라의 차이를 그대로 보존하죠.
Stored fields는 _source가 크지만 스크립트가 그중 몇 개의 작은 값만 필요할 때 읽기 시간을 줄여줘요. 그 값들을 개별적으로 저장해 두면 본문 전체를 로드하지 않아도 되거든요. 그 외의 모든 경우에는 필드를 stored로 표시해도 읽기 시간은 줄지 않고 인덱스 크기만 늘어나요.
관련성 점수 접근하기
script_score 쿼리, function_score 쿼리, 스크립트 기반 정렬, 그리고 집계의 스크립트는 감싸고 있는 쿼리가 현재 문서에 대해 계산한 관련성 점수인 _score를 받아요. _score를 읽으면 스크립트가 쿼리가 만들어낸 랭킹을 조정할 수 있어서, 텍스트 관련성이 최종 순서에 여전히 반영돼요.
다음 검색은 각 제품의 텍스트 관련성에 재고 수량을 곱해서, 쿼리와 일치하면서 수량도 충분한 제품을 상위로 올려요. 수량을 10으로 나누면 부스트가 적정 비율을 유지해서, 재고 수량이 랭킹을 지배하지 않으면서 순위를 조정해요:
GET scripting-products/_search
{
"_source": ["name", "quantity"],
"query": {
"function_score": {
"query": { "match": { "name": "wireless" } },
"script_score": {
"script": {
"lang": "expression",
"source": "_score * (1 + doc['quantity'] / 10)"
}
}
}
}
}
{% include copy-curl.html %}
헤드폰은 텍스트 매치가 더 약함에도 불구하고 이어버드보다 높은 순위를 차지해요. 헤드폰의 재고가 훨씬 많기 때문이에요:
응답
{
"took": 4,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 2,
"relation": "eq"
},
"max_score": 0.5083568,
"hits": [
{
"_index": "scripting-products",
"_id": "1",
"_score": 0.5083568,
"_source": {
"quantity": 42,
"name": "Wireless noise cancelling headphones"
}
},
{
"_index": "scripting-products",
"_id": "2",
"_score": 0.3282812,
"_source": {
"quantity": 8,
"name": "Wireless earbuds"
}
}
]
}
}