-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 약 검색 품질 재설계 (초성·edge n-gram·오타·관련도) #93
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
156af47
301e5b2
bbf8129
c58b26a
5f619eb
a88e8e7
7a08095
c874771
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| # 이슈 #92 — 약 검색 품질 재설계 (초성·edge n-gram·오타·관련도) | ||
|
|
||
| > 브랜치: `feature/92` · 관련 이슈: [PIUDAProject/Backend#92](https://github.com/PIUDAProject/Backend/issues/92) | ||
| > | ||
| > 전제: #90(무중단 재색인 인프라) — 매핑을 바꾼 뒤 `POST /api/admin/drugs/reindex` 한 번으로 반영 | ||
|
|
||
| --- | ||
|
|
||
| ## 1. 배경 | ||
|
|
||
| ### 기존 | ||
|
|
||
| `DrugDocument.itemName` = `text` 필드 1개(기본 애널라이저), 쿼리는 `match_phrase_prefix` 하나. | ||
|
|
||
| ```text | ||
| "타이레놀정500밀리그램(아세트아미노펜)" | ||
| → standard 토크나이저 → ["타이레놀정500밀리그램", "아세트아미노펜"] (한글 런은 통짜) | ||
| ``` | ||
|
|
||
| ### 안 되던 것 | ||
|
|
||
| | 입력 | 기존 | 이유 | | ||
| |---|---|---| | ||
| | `ㅌㅇㄹㄴ` | ❌ 0건 | 초성 개념이 색인에 없음 | | ||
| | `타이레올` | ❌ 0건 | 통짜 토큰이라 fuzzy가 무력 | | ||
| | `서방정` (중간 단어) | ❌ 0건 | prefix만 매칭 | | ||
| | `아세트아미노펜` (괄호 속 성분명) | △ | standard가 괄호는 분리하나 정확 토큰만 매칭 | | ||
| | 결과 순서 | 무작위 | `match_phrase_prefix`는 스코어 변별력 약함 | | ||
|
|
||
| --- | ||
|
|
||
| ## 2. 설계 — `itemName` 한 값을 여러 방식으로 색인 + bool.should 조합 | ||
|
|
||
| ### 멀티필드 | ||
|
|
||
| | 필드 | 색인 애널라이저 | 검색 애널라이저 | 용도 | | ||
| |---|---|---|---| | ||
| | `itemName` | `drug_search_analyzer` (standard + lowercase) | 동일 | 시작 매칭, 관련도 기준 | | ||
| | `itemName.keyword` | (keyword) | (keyword) | 이름 전체 완전 일치 | | ||
| | `itemName.autocomplete` | `drug_edge_ngram_analyzer` (edge_ngram 1~20) | `drug_search_analyzer` | 앞에서부터 타이핑하는 자동완성 | | ||
| | `itemName.ngram` | `drug_ngram_analyzer` (ngram 2~4) | `drug_ngram_analyzer` | 중간 단어·성분명·끝자리 오타 (ngram 겹침) | | ||
| | `itemNameChosung` | `keyword` (통짜 토큰) | `keyword` | 초성 검색 (`match_phrase_prefix` 로 접두 매칭 → 길이 제한 없음) | | ||
|
|
||
| - 애널라이저 정의: `src/main/resources/elasticsearch/drug-info-settings.json` (`@Setting`) | ||
| - `number_of_replicas: 0` 명시 (single-node → 클러스터 green) | ||
| - `edge_ngram`/`ngram` 토크나이저의 `token_chars: [letter, digit]` → `(`, `)`, `,`, `:` 는 토큰 경계 | ||
| → `"타치온정(글루타티온)"` 에서 `글루타티온` 이 독립적으로 ngram 색인됨 | ||
|
|
||
| ### 초성 처리 | ||
|
|
||
| ES에 한글 자모 분해기가 없으므로 **색인 시점에 Java로** 생성한다. | ||
|
|
||
| ```text | ||
| HangulChosungExtractor.extract("타이레놀정500") → "ㅌㅇㄹㄴㅈ500" | ||
| ``` | ||
|
|
||
| `DrugInfoConverter.toDocument` 에서 `itemNameChosung` 필드에 주입한다. | ||
| 검색 시점에는 변환이 필요 없다 — 사용자가 입력한 `ㅌㅇㄹㄴ` 을 그대로 `itemNameChosung` 에 `match_phrase_prefix`(접두) 매칭한다. | ||
| `itemNameChosung` 을 통짜 토큰(`keyword`)으로 색인하므로 **입력 길이 제한이 없다**. | ||
| 사용자가 완성형(`타이레`)을 입력하면 이 필드엔 매칭 0이라, 쿼리에 항상 포함해도 무해하다. | ||
|
|
||
| ### 검색 쿼리 (`DrugSearchRepository.searchByItemName`) | ||
|
|
||
| ```text | ||
| bool.should (minimum_should_match: 1): | ||
| term(itemName.keyword) boost 20 ← 이름 전체 완전 일치 (최우선) | ||
| match_phrase_prefix(itemName) boost 5 ← "입력한 그대로 시작" | ||
| match(itemName.autocomplete) boost 2 ← edge n-gram 자동완성 | ||
| match(itemName, fuzziness=AUTO) boost 2 ← 짧은 약품명의 1글자 오타 | ||
| match(itemName.ngram, minimum_should_match=50%) boost 1 ← 중간 단어·성분명·끝자리 오타 | ||
| match_phrase_prefix(itemNameChosung) boost 3 ← 초성 | ||
| ``` | ||
|
|
||
| boost로 완전/시작 일치가 상단에 오고, `ElasticsearchRepository` `@Query` 는 기본 `_score` 내림차순이라 서비스는 순서 그대로 매핑한다. | ||
|
|
||
| --- | ||
|
|
||
| ## 3. 변경 파일 | ||
|
|
||
| ### 신규 | ||
|
|
||
| | 파일 | 역할 | | ||
| |---|---| | ||
| | `global/util/HangulChosungExtractor` | 완성형 한글 → 초성 문자열, `isChosungOnly` 판별 | | ||
| | `resources/elasticsearch/drug-info-settings.json` | 커스텀 애널라이저 3개 + replica 0 | | ||
| | `scripts/drug-search-bench.py` | before/after Recall@10 / MRR / latency 벤치 | | ||
|
|
||
| ### 수정 | ||
|
|
||
| | 파일 | 변경 | | ||
| |---|---| | ||
| | `DrugDocument` | `@Setting` 추가 / `itemName` → `@MultiField`(keyword, autocomplete, ngram) / `itemNameChosung` 필드 추가 | | ||
| | `DrugInfoConverter.toDocument` | `itemNameChosung = HangulChosungExtractor.extract(itemName)` | | ||
| | `DrugSearchRepository.searchByItemName` | `match_phrase_prefix` 단일 → `bool.should` 6절 | | ||
| | `DrugSearchQueryService.search` | 주석만 (시그니처·흐름 동일) | | ||
|
|
||
| 폴백 경로(`DrugInfoController` → MySQL `LIKE`)와 색인 파이프라인(#90 `DrugIndexManager`)은 그대로. | ||
| `DrugIndexManager.createTimestampedIndex()`가 `@Setting`/`@MultiField`를 그대로 반영하므로 매핑 배포는 재색인 API 한 번. | ||
|
|
||
| --- | ||
|
|
||
| ## 4. before/after 측정 (로컬, 실제 4,745건) | ||
|
|
||
| `scripts/drug-search-bench.py` — 동일 4,745건으로 old(기존 매핑 + `match_phrase_prefix`) / new(신규 매핑 + `bool.should`) 벤치 인덱스를 만들어 라벨링 테스트셋 24개로 비교. | ||
|
|
||
| **정답 판별**: 결과 `itemName` 에 해당 쿼리의 기대 부분문자열 포함 여부. | ||
| **Recall@10** = (상위 10건 중 정답 수) / min(전체 정답 수, 10) · **MRR** = 1 / (첫 정답 순위). | ||
|
|
||
| | 지표 | old | new | | ||
| |---|---|---| | ||
| | **Recall@10 (평균)** | **0.471** | **0.978** | | ||
| | **MRR (평균)** | **0.521** | **0.979** | | ||
|
|
||
| | 유형 | old R@10 | new R@10 | 비고 | | ||
| |---|---|---|---| | ||
| | 정확/시작 (타이레놀, 게보린 등 10개) | 0.83 | 1.00 | new는 완전 일치가 1위 | | ||
| | 초성 (ㅌㅇㄹㄴ, ㄱㅂㄹ 등 4개) | 0.00 | 0.89 | 신규 | | ||
| | 오타 (타이레올, 게보른 등 4개) | 0.00 | 1.00 | 신규 | | ||
| | 중간 단어 (서방정, 현탁액) | 0.00 | 1.00 | 신규 | | ||
| | 성분명 (아세트아미노펜 등 4개) | 0.75 | 0.98 | ngram으로 개선 | | ||
|
|
||
| ### latency (동일 쿼리 50회, size=20) | ||
|
|
||
| | 쿼리 | old p50 / p95 | new p50 / p95 | | ||
| |---|---|---| | ||
| | `타이레` | 1.5 / 2.9 ms | 2.1 / 3.0 ms | | ||
| | `게보린` | 1.4 / 1.8 ms | 1.9 / 2.7 ms | | ||
| | `아세트아미노펜` | 1.6 / 2.5 ms | 4.1 / 5.3 ms | | ||
|
|
||
| - should 절이 6개로 늘어 쿼리당 **약 +1~3ms** (ngram 매칭이 많은 성분명 쿼리가 가장 큼). | ||
| - 절대값은 여전히 한 자릿수 ms. 로컬 단일 노드 + 캐시된 상태 기준. | ||
| - 부팅 시 재색인 4,745건 ≈ 1.6초. | ||
|
|
||
| ### 알려진 한계 | ||
|
|
||
| **한국어 형태소 분석(nori) 미도입** → 약품명이 통짜 토큰이라 `타이래놀`(레→래, 중간 음절 오타)처럼 **중간 위치 오타**는 잘 못 잡는다. | ||
|
|
||
| - prefix 오타, 끝자리 오타, 짧은 이름 오타는 커버됨 | ||
| - nori를 넣으면 `타이레놀정` → `타이레놀`+`정` 형태소 분리 → 중간 오타도 fuzzy로 잡힘 | ||
| - nori는 ES 8.x 기본 이미지에 없어 커스텀 Docker 이미지 필요 → **2단계로 분리** (측정 후 필요하면) | ||
|
|
||
| `마그네슘`(성분명) 은 R@10 0.90 이지만 MRR 0.50 — 첫 결과가 마그네슘 제품이 아님(ngram 노이즈). `function_score` 로 조정 여지. | ||
|
|
||
| --- | ||
|
|
||
| ## 5. 수동 검증 절차 (로컬) | ||
|
|
||
| ```bash | ||
| # 1. 새 매핑으로 재색인 (alias 있는 상태면 API, 없으면 부팅 시 자동) | ||
| # 로컬에서 강제로 새로 만들려면: | ||
| curl -s -XDELETE "localhost:9200/$(curl -s 'localhost:9200/_cat/aliases/drug_info?h=index')" | ||
| # → 앱 재기동 시 DrugIndexingInitializer가 새 매핑으로 부트스트랩 | ||
|
|
||
| # 2. 매핑 확인 | ||
| curl -s localhost:9200/drug_info/_mapping | jq '.[].mappings.properties.itemName, .[].mappings.properties.itemNameChosung' | ||
|
|
||
| # 3. 검색 | ||
| for q in 타이레놀 ㅌㅇㄹㄴ 타이레올 게보른 서방정 아세트아미노펜; do | ||
| echo "$q:"; curl -s "localhost:8080/api/search/drugs?keyword=$q" | jq -r '.data[:3][].itemName' | ||
| done | ||
|
|
||
| # 4. before/after 벤치 (drug_info 가 신규 매핑이어야 함) | ||
| python3 scripts/drug-search-bench.py | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## 6. 후속 | ||
|
|
||
| - **nori 2단계**: 커스텀 ES 이미지(`elasticsearch-plugin install analysis-nori`) + `nori_tokenizer` 애널라이저 추가. 중간 음절 오타 + 관련도 개선 | ||
| - **동의어**: 성분명 ↔ 대표 제품명 사전 (별도 이슈) | ||
| - **관련도 미세조정**: `function_score` 로 필드 길이 정규화 완화 (짧은 이름이 과하게 상위로 오는 경우) | ||
| - **테스트셋 확장**: 현재 24개 → 50개+, 실제 검색 로그 기반 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| #!/usr/bin/env python3 | ||
| """ | ||
| 약 검색 품질 before/after 벤치마크 (이슈 #92). | ||
|
|
||
| 로컬 Elasticsearch의 `drug_info` 인덱스에서 문서를 뽑아 두 벤치 인덱스를 만든다. | ||
| - drug_bench_old : 기존 매핑(itemName: text 기본 애널라이저), match_phrase_prefix | ||
| - drug_bench_new : 신규 매핑(멀티필드 + 초성), bool.should 조합 | ||
| 라벨링 테스트셋으로 Recall@10 / MRR / latency 를 비교 출력한다. | ||
|
|
||
| 사용: 앱을 한 번 띄워 drug_info 를 신규 매핑으로 재색인한 뒤 | ||
| python3 scripts/drug-search-bench.py | ||
| """ | ||
| import json | ||
| import statistics | ||
| import sys | ||
| import time | ||
| import urllib.request | ||
|
|
||
| ES = "http://localhost:9200" | ||
| SETTINGS_PATH = "src/main/resources/elasticsearch/drug-info-settings.json" | ||
|
|
||
| # (query, 정답 판별용 부분문자열, 유형) | ||
| TESTSET = [ | ||
| ("타이레놀", "타이레놀", "정확/시작"), | ||
| ("타이레", "타이레놀", "시작"), | ||
| ("타이레놀정", "타이레놀정", "정확/시작"), | ||
| ("게보린", "게보린", "정확/시작"), | ||
| ("판피린", "판피린", "시작"), | ||
| ("겔포스", "겔포스", "시작"), | ||
| ("이지엔", "이지엔", "시작"), | ||
| ("훼스탈", "훼스탈", "시작"), | ||
| ("부루펜", "부루펜", "시작"), | ||
| ("까스활명수", "까스활명수", "정확/시작"), | ||
| ("ㅌㅇㄹㄴ", "타이레놀", "초성"), | ||
| ("ㄱㅂㄹ", "게보린", "초성"), | ||
| ("ㅍㅍㄹ", "판피린", "초성"), | ||
| ("ㄱㅍㅅ", "겔포스", "초성"), | ||
| ("타이레올", "타이레놀", "끝오타"), | ||
| ("게보른", "게보린", "짧은이름오타"), | ||
| ("판피링", "판피린", "끝오타"), | ||
| ("겔포수", "겔포스", "끝오타"), | ||
| ("서방정", "서방정", "중간단어"), | ||
| ("현탁액", "현탁액", "중간단어"), | ||
| ("아세트아미노펜", "아세트아미노펜", "성분명"), | ||
| ("이부프로펜", "이부프로펜", "성분명"), | ||
| ("클로르페니라민", "클로르페니라민", "성분명"), | ||
| ("마그네슘", "마그네슘", "성분명"), | ||
| ] | ||
|
|
||
| OLD_MAPPING = {"properties": {"itemName": {"type": "text"}, "itemNameChosung": {"type": "text"}}} | ||
| NEW_MAPPING = {"properties": { | ||
| "itemName": {"type": "text", "analyzer": "drug_search_analyzer", "fields": { | ||
| "keyword": {"type": "keyword"}, | ||
| "autocomplete": {"type": "text", "analyzer": "drug_edge_ngram_analyzer", | ||
| "search_analyzer": "drug_search_analyzer"}, | ||
| "ngram": {"type": "text", "analyzer": "drug_ngram_analyzer", | ||
| "search_analyzer": "drug_ngram_analyzer"}}}, | ||
| "itemNameChosung": {"type": "text", "analyzer": "keyword", "search_analyzer": "keyword"}}} | ||
|
|
||
|
|
||
| def req(method, path, body=None): | ||
| r = urllib.request.Request( | ||
| ES + path, method=method, | ||
| data=json.dumps(body).encode() if body is not None else None, | ||
| headers={"Content-Type": "application/json"}) | ||
| return json.load(urllib.request.urlopen(r)) | ||
|
|
||
|
|
||
| def bulk_load(index, docs): | ||
| lines = [] | ||
| for d in docs: | ||
| lines.append('{"index":{}}') | ||
| lines.append(json.dumps({"itemName": d["itemName"], | ||
| "itemNameChosung": d.get("itemNameChosung", "")}, ensure_ascii=False)) | ||
| raw = ("\n".join(lines) + "\n").encode() | ||
| r = urllib.request.Request(f"{ES}/{index}/_bulk?refresh", method="POST", data=raw, | ||
| headers={"Content-Type": "application/x-ndjson"}) | ||
| if json.load(urllib.request.urlopen(r)).get("errors"): | ||
| print("bulk errors", file=sys.stderr) | ||
|
|
||
|
|
||
| def old_query(kw): | ||
| return {"match_phrase_prefix": {"itemName": {"query": kw, "max_expansions": 50}}} | ||
|
|
||
|
|
||
| def new_query(kw): | ||
| return {"bool": {"minimum_should_match": 1, "should": [ | ||
| {"term": {"itemName.keyword": {"value": kw, "boost": 20.0}}}, | ||
| {"match_phrase_prefix": {"itemName": {"query": kw, "boost": 5.0}}}, | ||
| {"match": {"itemName.autocomplete": {"query": kw, "boost": 2.0}}}, | ||
| {"match": {"itemName": {"query": kw, "fuzziness": "AUTO", "boost": 2.0}}}, | ||
| {"match": {"itemName.ngram": {"query": kw, "minimum_should_match": "50%", "boost": 1.0}}}, | ||
| {"match_phrase_prefix": {"itemNameChosung": {"query": kw, "boost": 3.0}}}, | ||
| ]}} | ||
|
|
||
|
|
||
| def evaluate(index, qfn, docs): | ||
| recalls, rrs = [], [] | ||
| for kw, needle, _ in TESTSET: | ||
| hits = req("POST", f"/{index}/_search", | ||
| {"size": 10, "_source": ["itemName"], "query": qfn(kw)})["hits"]["hits"] | ||
| names = [h["_source"]["itemName"] for h in hits] | ||
| rel = [needle in n for n in names] | ||
| total_rel = sum(1 for d in docs if needle in d["itemName"]) | ||
| recalls.append(sum(rel) / min(total_rel, 10) if total_rel else 0.0) | ||
| rrs.append(next((1.0 / (i + 1) for i, ok in enumerate(rel) if ok), 0.0)) | ||
| return recalls, rrs | ||
|
|
||
|
|
||
| def latency(index, qfn, kw, n=50): | ||
| ts = [] | ||
| for _ in range(n): | ||
| s = time.perf_counter() | ||
| req("POST", f"/{index}/_search", {"size": 20, "query": qfn(kw)}) | ||
| ts.append((time.perf_counter() - s) * 1000) | ||
| ts.sort() | ||
| return statistics.median(ts), ts[int(n * 0.95)] | ||
|
|
||
|
|
||
| def main(): | ||
| docs = [] | ||
| r = req("POST", "/drug_info/_search?scroll=2m", {"size": 1000, "query": {"match_all": {}}}) | ||
| sid = r["_scroll_id"] | ||
| while r["hits"]["hits"]: | ||
| docs += [h["_source"] for h in r["hits"]["hits"]] | ||
| r = req("POST", "/_search/scroll", {"scroll": "2m", "scroll_id": sid}) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
# Resolve the Elasticsearch version declared by repository configuration.
rg -n -i -C 2 \
'docker\.elastic\.co/elasticsearch|elasticsearch:[[:alnum:]._-]+|ELASTIC_VERSION|elasticsearch\.version' \
. -g '!**/build/**' -g '!**/dist/**' -g '!**/node_modules/**'
# Inspect the benchmark scroll loop after resolving the effective version.
sed -n '120,130p' scripts/drug-search-bench.pyRepository: PIUDAProject/Backend Length of output: 1142 🌐 Web query:
💡 Result: Yes, when using the Elasticsearch Scroll API, you should always use the most recently received _scroll_id from the latest scroll response [1]. According to official Elasticsearch documentation, while the _scroll_id may not change between every request, it is possible for it to change, and you must use the most recently received value to ensure the search context remains correctly identified [1]. Key practices for the Scroll API include: - Always pass the latest _scroll_id returned by your previous request [1]. - The Scroll API is no longer recommended for deep pagination; for paging through more than 10,000 hits, Elasticsearch officially recommends using the search_after parameter combined with a point in time (PIT) [2][3][1]. - You can extend the life of the search context by including a scroll parameter (e.g.,?scroll=1m) in your scroll requests [4][1]. If no scroll parameter is provided in a request, the search context may be freed [1]. Citations:
최신 Elasticsearch Scroll API는 각 응답에서 반환한 최신 🤖 Prompt for AI Agents |
||
| print(f"수집: {len(docs)}건") | ||
|
|
||
| settings = json.load(open(SETTINGS_PATH)) | ||
| for name, s, m in [("drug_bench_old", None, OLD_MAPPING), ("drug_bench_new", settings, NEW_MAPPING)]: | ||
| try: | ||
| req("DELETE", "/" + name) | ||
| except urllib.error.HTTPError: | ||
| pass | ||
| body = {"mappings": m} | ||
| if s: | ||
| body["settings"] = s | ||
| req("PUT", "/" + name, body) | ||
| bulk_load(name, docs) | ||
|
|
||
| or_, orr = evaluate("drug_bench_old", old_query, docs) | ||
| nr, nrr = evaluate("drug_bench_new", new_query, docs) | ||
|
|
||
| print("\n%-16s %-13s | %-9s %-9s | %-8s %-8s" | ||
| % ("query", "유형", "old R@10", "new R@10", "old RR", "new RR")) | ||
| print("-" * 78) | ||
| for (kw, _, typ), a, b, c, d in zip(TESTSET, or_, nr, orr, nrr): | ||
| print("%-16s %-13s | %-9.2f %-9.2f | %-8.2f %-8.2f" % (kw, typ, a, b, c, d)) | ||
| print("-" * 78) | ||
| print("%-16s %-13s | %-9.3f %-9.3f | %-8.3f %-8.3f" | ||
| % ("평균", "", statistics.mean(or_), statistics.mean(nr), | ||
| statistics.mean(orr), statistics.mean(nrr))) | ||
|
|
||
| print("\n--- latency (동일 쿼리 50회, size=20) ---") | ||
| for kw in ["타이레", "게보린", "아세트아미노펜"]: | ||
| op50, op95 = latency("drug_bench_old", old_query, kw) | ||
| np50, np95 = latency("drug_bench_new", new_query, kw) | ||
| print(f" '{kw}': old p50={op50:.1f}ms p95={op95:.1f}ms | new p50={np50:.1f}ms p95={np95:.1f}ms") | ||
|
|
||
| for name in ["drug_bench_old", "drug_bench_new"]: | ||
| req("DELETE", "/" + name) | ||
| print("\n벤치 인덱스 정리 완료") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Bulk 색인 오류에서 벤치마크를 중단하세요.
Bulk API가
errors: true를 반환해도 현재 코드는 계속 측정합니다. 일부 문서가 누락되면 old/new 인덱스의 데이터 집합이 달라질 수 있고, 출력된 Recall@10·MRR·latency 결과가 잘못됩니다. 오류를 출력한 뒤 예외를 발생시키세요.수정 예시
if json.load(urllib.request.urlopen(r)).get("errors"): print("bulk errors", file=sys.stderr) + raise RuntimeError("benchmark bulk indexing failed")📝 Committable suggestion
🧰 Tools
🪛 Ruff (0.16.3)
[error] 78-78: Audit URL open for permitted schemes. Allowing use of
file:or custom schemes is often unexpected.(S310)
🤖 Prompt for AI Agents