함수로 묶고 순위를 매긴다 — def · 딕셔너리 · sorted
Article
앞 강의에서 긴 문서를 조각으로 잘랐다. 그런데 조각을 만들어놓기만 하면 아무 쓸모가 없다. 질문이 들어왔을 때 그 조각들 중에서 맞는 걸 골라내야 검색이 된다.
이번 강의에서 그 고르는 부분을 만든다. 끝나면 라이브러리 하나 없이 동작하는 검색기가 생긴다. 성능은 형편없을 텐데, 형편없는 이유를 아는 게 이번 강의의 목적이다.
- 문서를 자른다앞 강의에서 한 것 — 이번엔 함수로 묶는다
- 조각마다 점수를 매긴다질문과 얼마나 겹치나
- 점수 높은 순으로 줄 세운다sorted
- 위에서 3개만 꺼낸다슬라이싱
준비
파이썬을 새로 켰다면 예제 문서부터 다시 붙여넣는다. 이번엔 문서를 세 개 쓴다.
docs = [
"백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다. 주로 쓰는 언어는 자바, 파이썬, 노드다.",
"프론트엔드 개발자는 사용자가 직접 보는 화면을 만든다. 버튼을 누르면 무슨 일이 일어나는지를 설계한다. 리액트와 뷰가 많이 쓰인다.",
"데이터 엔지니어는 흩어진 데이터를 모아 쓸 수 있는 형태로 만든다. 데이터베이스와 파이프라인을 다루고 파이썬과 SQL 을 쓴다.",
]
[len(d) for d in docs]
- [88, 73, 71]
리스트 안에 문자열 세 개가 들어 있고, 길이는 각각 88 · 73 · 71 글자다. docs[0] 이 첫 문서다.
함수로 묶는다
앞 강의에서 자르는 코드를 이렇게 썼다.
for start in range(0, len(text), 30):
print(text[start:start + 30])
문서가 세 개가 되면 이걸 세 번 써야 한다. 그리고 글자 수를 30에서 50으로 바꾸고 싶으면 세 군데를 다 고쳐야 한다. 같은 일을 이름 붙여 한 곳에 두는 것이 함수다.
def chunk_text(text, size):
"""긴 글을 size 글자씩 잘라 리스트로 돌려준다."""
result = [] # 조각을 담을 빈 리스트
for start in range(0, len(text), size):
result.append(text[start:start + size]) # 리스트 끝에 하나 추가
return result # 다 만든 리스트를 돌려준다
>>> 화면에서는 마지막 줄까지 붙여넣고 엔터를 한 번 더 쳐야 함수가 만들어진다. 바로 써보자.
chunk_text(docs[0], 30)
- ['백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 ',
- '눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을',
- ' 한다. 주로 쓰는 언어는 자바, 파이썬, 노드다.']
앞 강의에서 print 로 화면에 찍었던 것과 내용이 같다. 다른 점은 이번엔 리스트로 남았다는 것이다.
- 01
def chunk_text(text, size):
def로 함수를 만든다. 괄호 안은 밖에서 받을 값이다. 여기서 정한 이름(text, size)은 함수 안에서만 쓰인다. - 02
"""긴 글을 ..."""
함수 첫 줄의 문자열은 설명문이다. 없어도 돌아가지만, 두 달 뒤에 이 코드를 다시 볼 사람이 나 자신이다.
- 03
result = []
빈 리스트를 만든다. 여기에 조각을 하나씩 쌓는다.
- 04
result.append(...)
리스트 끝에 하나 붙인다.
print는 화면에 보여줄 뿐 아무것도 남기지 않는데,append는 실제로 모은다. 이 차이가 핵심이다. - 05
return result
만든 것을 밖으로 내보낸다.
return이 없으면 함수는 아무것도 안 돌려준다.
기본값을 정해둔다
chunk_text(docs[0], 50) 처럼 매번 숫자를 적는 게 번거롭다. 대개 50을 쓴다면 기본값을 정해두면 된다.
def chunk_text(text, size=50):
"""긴 글을 size 글자씩 잘라 리스트로 돌려준다. size 를 안 주면 50."""
result = []
for start in range(0, len(text), size):
result.append(text[start:start + size])
return result
이제 둘 다 된다.
len(chunk_text(docs[0])) # size 를 안 줬으니 50
len(chunk_text(docs[0], 30)) # 줬으니 30
- 2
- 3
88글자짜리 문서를 50으로 자르면 2조각, 30으로 자르면 3조각이다. 조각을 크게 하면 개수가 줄고, 작게 하면 늘어난다. 이 숫자 하나가 나중에 검색 품질을 좌우한다.
리스트 컴프리헨션 — 반복문을 한 줄로
문서 세 개를 전부 잘라 하나의 조각 목록으로 만들고 싶다. 방금 배운 것으로 쓰면 이렇다.
chunks = []
for doc in docs:
for c in chunk_text(doc):
chunks.append(c)
len(chunks)
- 6
여섯 조각이 나왔다. 그런데 이렇게 리스트를 만들려고 도는 반복문은 파이썬에서 한 줄로 쓸 수 있다.
lengths = [len(c) for c in chunks]
lengths
- [50, 38, 50, 23, 50, 21]
반복문으로
세 줄
lengths = []for c in chunks:lengths.append(len(c))
컴프리헨션으로
한 줄
lengths = [len(c) for c in chunks]- 「무엇을」 이 앞에, 「어디서」 가 뒤에
- 결과가 리스트라는 게 대괄호로 바로 보인다
읽는 순서가 코드 순서와 반대라 처음엔 어색하다. 뒤부터 읽으면 된다. "chunks 에서 c 를 하나씩 꺼내서 → len(c) 를 담는다."
길이를 보면 50 · 38 · 50 · 23 · 50 · 21 로 들쭉날쭉하다. 문서마다 끝에 짧은 꼬리 조각이 하나씩 남았기 때문이다. 컴프리헨션 뒤에 조건을 붙이면 이런 걸 걸러낼 수 있다.
# 30글자보다 짧은 꼬리 조각을 빼본다
long_only = [c for c in chunks if len(c) >= 30]
len(long_only)
- 4
여섯 개에서 네 개로 줄었다. 짧은 조각 셋 중 38글자짜리는 남고 23 · 21글자짜리 둘이 빠졌다.
딕셔너리 — 검색 결과의 모양
이제 조각마다 점수를 매길 건데, 그러면 조각 하나에 붙는 정보가 여러 개가 된다. 원문, 점수, 어느 문서에서 왔는지. 리스트로는 이걸 담기 불편하다.
리스트
["백엔드 ...", 0.85, 0]
r[1]이 점수라는 걸 외워야 한다- 순서가 바뀌면 전부 깨진다
- 코드만 봐서는 뭐가 뭔지 모른다
딕셔너리
{"text": ..., "score": 0.85}
r["score"]— 읽으면 뜻이 보인다- 순서와 상관없다
- 항목을 나중에 추가해도 안 깨진다
딕셔너리는 중괄호로 만들고, 이름과 값을 짝지어 담는다.
result = {"text": "백엔드 개발자는 서버와...", "score": 0.85, "doc": 0}
result["score"] # 이름으로 꺼낸다
- 0.85
result = { ... }
- "text"
- "백엔드 개발자는 서버와..."
- 조각 원문
- "score"
- 0.85
- 질문과 얼마나 맞는지
- "doc"
- 0
- 몇 번 문서에서 나왔나
점수를 매긴다
가장 단순한 점수는 질문의 글자가 조각에 몇 개나 들어 있는가다.
def score(query, text):
"""질문 글자 중 몇 %가 조각 안에 들어 있는지 센다. 0~1 사이 값."""
hit = 0
for ch in query:
if ch in text: # 이 글자가 조각 안에 있나
hit += 1
return hit / len(query)
score("데이터베이스", docs[0]) # 데이터베이스가 그대로 들어 있는 문서
score("데이터베이스", docs[1]) # 프론트엔드 문서
- 1.0
- 0.3333333333333333
첫 문서는 1.0, 즉 질문의 모든 글자가 들어 있다. 두 번째는 0.33이다. 「데이터베이스」 여섯 글자 중 이·스 같은 글자가 「사용자가」·「쓰인다」에 우연히 섞여 있어서 두 개가 맞았다.
이미 문제가 보인다. 데이터베이스 얘기가 한 마디도 없는 문서가 0.33을 받았다. 이 점수는 글자만 세지 뜻을 모른다.
전부 채점하고 줄을 세운다
조각 전부에 점수를 매겨 딕셔너리 목록으로 만든다.
query = "데이터베이스를 다루는 일"
# 컴프리헨션으로 조각마다 딕셔너리를 하나씩 만든다
scored = [{"text": c, "score": score(query, c)} for c in chunks]
len(scored)
- 6
이제 점수 높은 순으로 줄을 세운다. sorted() 를 쓰는데, 딕셔너리는 무엇을 기준으로 비교할지 파이썬이 모르니 기준을 알려줘야 한다.
# key= 로 "무엇을 기준으로 비교할지" 알려준다
# reverse=True 는 큰 것부터 (기본은 작은 것부터)
ranked = sorted(scored, key=lambda r: r["score"], reverse=True)
for r in ranked[:3]: # 위에서 3개만
print(f'{r["score"]:.2f} {r["text"][:30]}')
- 0.85 백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자
- 0.85 데이터 엔지니어는 흩어진 데이터를 모아 쓸 수 있는 형
- 0.54 장하고 꺼내는 일을 한다. 주로 쓰는 언어는 자바, 파
검색기가 동작한다. 데이터베이스를 실제로 다루는 두 조각이 나란히 1·2위로 올라왔다.
- 01
sorted(scored, ...)
원본은 안 건드리고 정렬된 새 리스트를 돌려준다. 원본이 필요 없어지는 게 아니라 그대로 남는다.
- 02
key=lambda r: r["score"]
lambda는 이름 없는 짧은 함수다. 「r 을 받으면 r["score"] 를 준다」는 뜻. sorted 가 항목마다 이걸 불러서 나온 값으로 비교한다. - 03
reverse=True
기본은 작은 것부터다. 점수는 큰 게 좋으니 뒤집는다.
- 04
ranked[:3]
앞 강의의 슬라이싱이다. 위에서 3개. 3개보다 적어도 에러가 안 난다.
- 05
f'{r["score"]:.2f}'
콜론 뒤는 표시 방식이다.
.2f는 소수점 둘째 자리까지. 화면에 숫자를 줄 맞춰 찍을 때 쓴다.
이 검색기가 왜 형편없나
질문을 바꿔보면 바로 드러난다. 이번엔 아주 단순한 질문이다.
query = "백엔드가 하는 일"
scored = [{"text": c, "score": score(query, c)} for c in chunks]
ranked = sorted(scored, key=lambda r: r["score"], reverse=True)
for r in ranked[:3]:
print(f'{r["score"]:.2f} {r["text"][:30]}')
- 0.78 프론트엔드 개발자는 사용자가 직접 보는 화면을 만든다.
- 0.67 백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자
- 0.67 장하고 꺼내는 일을 한다. 주로 쓰는 언어는 자바, 파
「백엔드가 하는 일」을 물었는데 1위가 프론트엔드다. 정답은 2위로 밀렸다.
이유는 허무할 만큼 단순하다.
「백엔드」
질문에 들어 있는 글자
- 백 · 엔 · 드
- 이 세 글자를 찾는다
「프론트엔드」
정답이 아닌 문서
- 프 · 론 · 트 · 엔 · 드
- 「엔드」가 통째로 들어 있다
- 게다가 「하는 일」의 글자까지 더 많이 겹쳤다
이번 강의에서 만든 것
| 쓴 것 | 하는 일 |
|---|---|
def 이름(a, b=기본값): | 같은 일을 이름 붙여 묶는다. 기본값은 뒤쪽에만 |
리스트.append(x) | 끝에 하나 추가한다 |
return x | 함수 밖으로 내보낸다. 없으면 None |
[f(c) for c in 리스트] | 반복문 한 줄 표기. 뒤부터 읽는다 |
[c for c in 리스트 if 조건] | 조건에 맞는 것만 남긴다 |
{"이름": 값} | 딕셔너리. 그대로 JSON 이 된다 |
d["이름"] | 딕셔너리에서 꺼낸다 |
sorted(x, key=..., reverse=True) | 기준을 정해 줄 세운다 |
ranked[:3] | 위에서 3개 |
f"{값:.2f}" | 소수점 둘째 자리까지 표시 |
스스로 해보기
- 01
상위 3개 대신 5개를 뽑아본다
ranked[:3]의 숫자만 바꾸면 된다. 조각이 6개뿐인데 10개를 달라고 하면 어떻게 되나도 해본다. - 02
점수가 0.7 미만인 것은 아예 빼본다
컴프리헨션의
if를 쓴다. 「관련 없으면 답하지 않는다」가 실제 서비스에서 아주 중요한 동작이다. - 03
chunk_text 의 size 를 20 · 50 · 100 으로 바꿔가며 순위가 어떻게 달라지는지 본다
「백엔드가 하는 일」로 물었을 때 정답이 몇 위에 오는지 적어둔다. 어느 크기가 제일 나은가. 정답은 없고, 왜 그렇게 생각하는지가 중요하다.
정리하면
함수로 묶으면 같은 코드를 한 곳에서 고칠 수 있고, 딕셔너리로 담으면 결과에 이름이 붙어 읽힌다. sorted 에 기준을 주고 슬라이싱으로 잘라내면 상위 N개가 나온다. 이 세 개가 검색 결과를 다루는 기본 뼈대이고, 앞으로 만들 코드에 계속 나온다.
그리고 만든 검색기가 왜 틀리는지도 봤다 — 백엔드를 물었더니 프론트엔드가 1위였다. 다음 강의에서는 진짜 파일을 읽어 조각을 만든다. 예제 문자열이 아니라 폴더에 있는 문서를 상대한다.