[목표]
1. 네이버 뉴스 기사 데이터 ETL
- 데이터 수집 : 기존 Elasticsearch 적재되어 있는 네이버 뉴스 데이터 수집
- 데이터 변환 : 데이터 전처리 및 임베딩 수행
- 데이터 적재 : 임베딩 벡터를 포함한 데이터를 보유 서버에 적재
- 검색 기능 구현
- 키워드 검색, 벡터 검색, 하이브리드 검색 방식 구현
- Streamlit UI 구현
- 검색 화면 구현 및 검색 결과 및 소요 시간 표시

remove_fields = ["title_vector", "content_vector", "title_with_content_vector"]
...
output_path = "naver_news_cleaned_full.jsonl"
with open(output_path, "w", encoding="utf-8") as f:
for doc in hits:
cleaned = clean_doc(doc)
...
while True:
scroll_res = requests.post(
f"{COMPANY_ES_HOST}/_search/scroll",
headers=headers,
auth=HTTPBasicAuth(USERNAME, PASSWORD),
...
Elasticsearch Sroll API를 활용해 해당 데이터를 모두 순차적으로 가져와 벡터 필드 제거
리눅스 서버 내에서 회사 인덱스 데이터 -> JSONL 추출
모델의 최대 시퀀스인 512 넘는 데이터 삭제
title_with_content 필드 임베딩
with open(input_path, "r", encoding="utf-8") as infile, open(output_path, "w", encoding="utf-8") as outfile:
for line in tqdm(infile, desc="Reading"):
doc = json.loads(line)
text = doc.get("title_with_content")
if text:
batch.append((doc, text))
if len(batch) == batch_size:
texts = [t for _, t in batch]
vectors = model.encode(texts, convert_to_tensor=True, device=device).cpu().tolist()
for (doc, _), vec in zip(batch, vectors):
doc["title_with_content_vector"] = vec
outfile.write(json.dumps(doc, ensure_ascii=False) + "\n"
if batch:
texts = [t for _, t in batch]
...
Colab 과 리눅스 서버 내에서 title_with_content 필드만 임베딩 시키는 과정.
배치사이즈(1024) 가 되면 한 번에 모델에 넣고 벡터 임베딩 실행. texts는 임베딩 대상 문장들의 리스트. model_encode()로 768차원 벡터 임베딩 생성
JSONL → Elasticsearch Bulk 포맷(JSON) 변환
with open(input_path, "r", encoding="utf-8") as infile, open(output_path, "w", encoding="utf-8") as outfile:
for line in infile:
doc = json.loads(line)
doc_id = doc.get("url")
meta = {
"index": {
"_index": index_name,
"_id": doc_id
}
}
outfile.write(json.dumps(meta, ensure_ascii=False) + "\n")
outfile.write(json.dumps(doc, ensure_ascii=False) + "\n")
PUT /naver_news_embedded
{
"mappings": {
"properties": {
"title": { "type": "text" },
"content": { "type": "text" },
"title_with_content_vector": {
"type": "dense_vector",
"dims": 768,
"index": true,
"similarity": "cosine"
}
}
}
}
Elasticsearch 인덱스(naver_news_embedded) 생성 후 dense_vector 필드 생성 과정
Bulk insert 실행 (Linux 서버에서 python으로 실행)
BATCH_SIZE = 1000 # 1000줄 (500문서)씩 처리
with open(bulk_path, "r", encoding="utf-8") as f:
buffer = []
line_count = 0
total_sent = 0
for line in f:
buffer.append(line)
line_count += 1
if line_count % BATCH_SIZE == 0:
res = requests.post(
f"{ES_HOST}/_bulk",
headers={"Content-Type": "application/json"},
...
)
if res.status_code != 200 or res.json().get("errors"):
print(res.text)
total_sent += BATCH_SIZE // 2
buffer = []
if buffer:
res = requests.post(
f"{ES_HOST}/_bulk",
...
if line_count % BATCH_SIZE == 0 : 쌓인 문서를 모두 이어 붙여 Bulk 요청 본문으로 만들고 전송es.put_script(id='text_search_template', body={
"script": {
"size": "{{size}}{{^size}}3{{/size}}",
"lang": "mustache",
"source": {
"query" : {
"bool": {
"must": [{ "match": { "title_with_content": "{{search_word}}" }}],
"filter": [{
"range": {
"date": {
"gte": "{{start_date}}{{^start_date}}2023-01-01{{/start_date}}",
"lte": "{{end_date}}{{^end_date}}2023-12-31{{/end_date}}"
}}}]}}}}})
put_script 기능을 통해 search template 등록.{{varaible}} 포맷으로 사용자 입력 변수 처리.{{variable}}{{^variable}}default_value{{/variable}} 포맷으로 기본값 지정.def text_search(**params) -> tuple:
res = es.search_template(
id="text_search_template",
index="naver_news_*",
params=params
)
return res
...
def main()->None:
...
input_query = st.text_input("검색어를 입력하세요.", value = "네이버")
res = text_search(
search_word=input_query,
start_date=start_date.strftime('%Y-%m-%d'), # 날짜 형식 맞추기
end_date=end_date.strftime('%Y-%m-%d')
)
...
def vector_search(input_query:str) -> tuple:
script_query = {
"script_score": {
"query": {"match_all": {}},
"script": {
"source": "cosineSimilarity(params.query_vector, 'title_with_content_vector') + 1.0",
"params": {"query_vector": text_embedding(input_query)}
}
}
}
res = es.search(
...
query=script_query
)
return res['hits']['hits'], res['took']
knn property 사용하지 않고, query_vector와 타겟 필드(title_with_content_vector)와의 cosineSimilarity 연산을 통해 도큐먼트들의 score를 계산.+1.0을 통해 양수 값으로 전환.# hybrid search
def hybird_search(search_word:str, start_date:str='2023-01-01', end_date:str="2023-12-31") -> tuple:
res = es.search(
index='naver_news_*',
size=3,
query={
"bool": {
...
},
knn={ ... },
rank={'rrf': {}},
)
return res['hits']['hits'], res['took']
...
def main():
...
results, took = hybird_search(input_query)
...
def hybrid_script_score_search(...)-> tuple:
query_vector = text_embedding(search_word)
res = es.search(
index='naver_news_*',
size=3,
query={
"script_score": {
"query": {
"bool": {
"filter": [
{"range": {
"date": {"gte": start_date, "lte": end_date}
}},
{"exists": {"field": "title_with_content_vector"}}
]
}
},
"script": {
"source": "cosineSimilarity(params.query_vector, 'title_with_content_vector') + 1.0",
"params": {
"query_vector": query_vector
}}}})
return res["hits"]["hits"], res['took']





쑥스럽지만 코드를 공개해본다.
https://github.com/DillonLim-Jaewon/Search_demo