AI

0810 디코드랩 정리(AI Engineering 03)

인뽕 2026. 8. 10. 21:08

 

03

함수로 묶고 순위 지정 — def · 딕셔너리 · sorted

 

 

자른 조각 중에서 질문에 맞는 것을 고르는 일이 검색이다. 함수로 자르기를 묶고, 딕셔너리로 결과를 담고, sorted 로 상위 3개를 뽑아 아주 단순한 검색기를 직접 완성한다.

 

앞 강의에서 긴 문서를 조각으로 잘랐다. 그런데 조각을 만들어놓기만 하면 쓸모가 없다. 질문이 들어왔을 때 그 조각들 중에서 맞는 걸 골라내야 검색이 된다.

이번 강의에서 그 고르는 부분을 만든다. 끝나면 라이브러리 하나 없이 동작하는 검색기가 생긴다. 성능은 형편없을 텐데, 형편없는 이유를 아는 게 이번 강의의 목적이다.

 

 

함수로 묶는다

앞 강의에서 자르는 코드를 이렇게 썼다.

for start in range(0, len(text), 30):
    print(text[start:start + 30])

 

문서가 세 개가 되면 이걸 세 번 써야 한다. 그리고 글자 수를 30에서 50으로 바꾸고 싶으면 세 군데를 다 고쳐야 한다. 반복적인 작업코드를 이름 붙여 그룹화하여 재사용가능하게 두는 것이 함수다.

 

docs = [
    "백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다. 주로 쓰는 언어는 자바, 파이썬, 노드다.",
    "프론트엔드 개발자는 사용자가 직접 보는 화면을 만든다. 버튼을 누르면 무슨 일이 일어나는지를 설계한다. 리액트와 뷰가 많이 쓰인다.",
    "데이터 엔지니어는 흩어진 데이터를 모아 쓸 수 있는 형태로 만든다. 데이터베이스와 파이프라인을 다루고 파이썬과 SQL 을 쓴다.",
]

# def 는 "지금부터 함수를 하나 만든다"는 선언이다.
#   chunk_text          내가 붙인 이름. 앞으로 이 이름으로 부른다
#   (text, size)        밖에서 받을 값. 함수 안에서만 쓰이는 이름이다
#   끝의 콜론(:)        "아래 들여쓴 줄들이 이 함수의 몸통"이라는 표시
def chunk_text(text, size):
    """긴 글을 size 글자씩 잘라 리스트로 돌려준다."""
    # 위 문자열은 설명문이다. 없어도 돌아간다.

    result = []                                   # 빈 리스트. 여기에 조각을 쌓는다
    for start in range(0, len(text), size):
        # .append(x) 는 "리스트 끝에 x 를 하나 붙여라".
        # print 는 보여주고 끝이지만 append 는 실제로 모은다. 이 차이가 핵심이다.
        result.append(text[start:start + size])
    return result                                 # 만든 것을 함수 밖으로 내보낸다


pieces = chunk_text(docs[0], 30)

print(len(pieces))
for p in pieces:
    print(p)

 

출력결과

 

앞 강의에서 print 로 화면에 찍었던 것과 내용이 같다. 다른 점은 이번엔 리스트로 남았다는 것이다. len(pieces) 가 3인 게 그 증거다.

 

기본값을 정해둔다

chunk_text(docs[0], 50) 처럼 매번 숫자를 적는 게 번거롭다. 대개 50을 쓴다면 기본값으로 정해두면 된다.

 

docs = [
    "백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다. 주로 쓰는 언어는 자바, 파이썬, 노드다.",
    "프론트엔드 개발자는 사용자가 직접 보는 화면을 만든다. 버튼을 누르면 무슨 일이 일어나는지를 설계한다. 리액트와 뷰가 많이 쓰인다.",
    "데이터 엔지니어는 흩어진 데이터를 모아 쓸 수 있는 형태로 만든다. 데이터베이스와 파이프라인을 다루고 파이썬과 SQL 을 쓴다.",
]

# size=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


# 괄호가 겹쳐 있으면 안쪽부터 읽는다.
#   docs[0]              첫 문서를 꺼내고
#   chunk_text(...)      그걸 잘라 리스트를 받고
#   len(...)             그 리스트가 몇 개인지 센다
print(len(chunk_text(docs[0])))        # size 를 안 줬으니 기본값 50
print(len(chunk_text(docs[0], 30)))    # 줬으니 30

 

출력결과

 

88글자짜리 문서를 50으로 자르면 2조각, 30으로 자르면 3조각이다. 조각을 크게 하면 개수가 줄고, 작게 하면 늘어난다. 이 숫자 하나가 나중에 검색 품질을 좌우한다.

 

리스트 컴프리헨션 — 반복문을 한 줄로

문서 세 개를 전부 잘라 하나의 조각 목록으로 만들고 싶다.

 

docs = [
    "백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다. 주로 쓰는 언어는 자바, 파이썬, 노드다.",
    "프론트엔드 개발자는 사용자가 직접 보는 화면을 만든다. 버튼을 누르면 무슨 일이 일어나는지를 설계한다. 리액트와 뷰가 많이 쓰인다.",
    "데이터 엔지니어는 흩어진 데이터를 모아 쓸 수 있는 형태로 만든다. 데이터베이스와 파이프라인을 다루고 파이썬과 SQL 을 쓴다.",
]

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


# 반복문 안의 반복문 - 문서마다 조각을 만들어 한 리스트에 모은다
chunks = []
for doc in docs:
    for c in chunk_text(doc):
        chunks.append(c)

print(len(chunks))

# 컴프리헨션 - 리스트를 만들려고 도는 반복문을 한 줄로 쓴 것이다.
#   [ len(c)      for c in chunks ]
#     담을 것       어디서 꺼내나
print([len(c) for c in chunks])

# 뒤에 if 를 붙이면 조건에 맞는 것만 담는다
long_only = [c for c in chunks if len(c) >= 30]
print(len(long_only))

 

출력결과

 

여섯 조각이 나왔고, 길이는 50 · 38 · 50 · 23 · 50 · 21 로 들쭉날쭉하다. 문서마다 끝에 짧은 꼬리 조각이 하나씩 남았기 때문이다.

 

 

 

 

딕셔너리 — 검색 결과의 모양

이제 조각마다 점수를 매길 건데, 그러면 조각 하나에 붙는 정보가 여러 개가 된다. 원문, 점수, 어느 문서에서 왔는지. 리스트로는 이걸 담기 불편하다.

 

딕셔너리를 쓰는 이유

 

# 중괄호 { } 는 딕셔너리다. "이름: 값" 을 쉼표로 이어 담는다.
# 리스트가 번호로 꺼낸다면 딕셔너리는 이름으로 꺼낸다.
result = {"text": "백엔드 개발자는 서버와...", "score": 0.85, "doc": 0}

print(result["score"])       # 대괄호에 번호 대신 이름을 넣는다
print(result["text"])
print(result)                # 통째로 찍으면 중괄호까지 그대로 보인다

 

출력결과

 

 

 

점수를 매긴다

가장 단순한 점수는 질문의 글자가 조각에 몇 개나 들어 있는가다.

docs = [
    "백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다. 주로 쓰는 언어는 자바, 파이썬, 노드다.",
    "프론트엔드 개발자는 사용자가 직접 보는 화면을 만든다. 버튼을 누르면 무슨 일이 일어나는지를 설계한다. 리액트와 뷰가 많이 쓰인다.",
    "데이터 엔지니어는 흩어진 데이터를 모아 쓸 수 있는 형태로 만든다. 데이터베이스와 파이프라인을 다루고 파이썬과 SQL 을 쓴다.",
]

def score(query, text):
    """질문 글자 중 몇 %가 조각 안에 들어 있는지 센다. 0~1 사이 값."""
    hit = 0                 # 맞은 개수를 셀 그릇. 0부터 시작한다
    for ch in query:        # 질문을 한 글자씩 꺼낸다 (문자열도 반복할 수 있다)
        if ch in text:      # in 은 "안에 있나"를 묻는다. 참이면 아래 줄 실행
            hit += 1        # hit = hit + 1 과 같다. 하나 늘린다
    return hit / len(query) # 맞은 수 / 전체 수 = 0~1 사이 비율


# 만든 함수를 부른다. 괄호 안 순서가 def 에서 정한 순서(query, text)와 같아야 한다.
print(score("데이터베이스", docs[0]))      # 데이터베이스가 그대로 들어 있는 문서
print(score("데이터베이스", docs[1]))      # 프론트엔드 문서 - 그 단어가 없다

 

출력결과

 

첫 문서는 1.0, 즉 질문의 모든 글자가 들어 있다. 두 번째는 0.33이다. 「데이터베이스」 여섯 글자 중 이·스 같은 글자가 「사용자가」·「쓰인다」에 우연히 섞여 있어서 두 개가 맞았다.

이미 문제가 보인다. 데이터베이스 얘기가 한 마디도 없는 문서가 0.33을 받았다. 이 점수는 글자만 세지 뜻을 모른다.

전부 채점하고 줄을 세운다

 

docs = [
    "백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다. 주로 쓰는 언어는 자바, 파이썬, 노드다.",
    "프론트엔드 개발자는 사용자가 직접 보는 화면을 만든다. 버튼을 누르면 무슨 일이 일어나는지를 설계한다. 리액트와 뷰가 많이 쓰인다.",
    "데이터 엔지니어는 흩어진 데이터를 모아 쓸 수 있는 형태로 만든다. 데이터베이스와 파이프라인을 다루고 파이썬과 SQL 을 쓴다.",
]

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


def score(query, text):
    """질문 글자 중 몇 %가 조각 안에 들어 있는지 센다. 0~1 사이 값."""
    hit = 0
    for ch in query:
        if ch in text:
            hit += 1
    return hit / len(query)


query = "데이터베이스를 다루는 일"

chunks = [c for d in docs for c in chunk_text(d)]

# 컴프리헨션이 담는 것이 이번엔 딕셔너리다.
scored = [{"text": c, "score": score(query, c)} for c in chunks]

# sorted(x) 는 x 를 줄 세운 새 리스트를 돌려준다. 원본은 그대로 남는다.
#   key=lambda r: r["score"]
#     lambda 는 이름 없는 짧은 함수다. "r 을 받으면 r['score'] 를 준다".
#   reverse=True
#     기본은 작은 것부터. 점수는 큰 게 좋으니 뒤집는다.
ranked = sorted(scored, key=lambda r: r["score"], reverse=True)

print(len(chunks))

for r in ranked[:3]:        # 슬라이싱으로 위에서 3개만
    # 바깥은 홑따옴표, 안쪽은 겹따옴표다. 같으면 문자열이 거기서 끊겨 에러가 난다.
    print(f'{r["score"]:.2f}  {r["text"][:30]}')

 

출력결과

 

검색기가 동작한다. 데이터베이스를 실제로 다루는 두 조각이 나란히 1·2위로 올라왔다.

[c for d in docs for c in chunk_text(d)] 는 for 가 두 번 나온 컴프리헨션이다. 앞의 for 가 바깥, 뒤의 for 가 안쪽이다. 반복문으로 쓸 때와 순서가 같다 — 문서를 하나 꺼내고, 그 문서의 조각을 하나 꺼낸다.

 

 

이 검색기가 왜 형편없나

질문을 바꿔보면 바로 드러난다. 아래 파일은 위와 똑같고 query 한 줄만 다르다.

 

docs = [
    "백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다. 주로 쓰는 언어는 자바, 파이썬, 노드다.",
    "프론트엔드 개발자는 사용자가 직접 보는 화면을 만든다. 버튼을 누르면 무슨 일이 일어나는지를 설계한다. 리액트와 뷰가 많이 쓰인다.",
    "데이터 엔지니어는 흩어진 데이터를 모아 쓸 수 있는 형태로 만든다. 데이터베이스와 파이프라인을 다루고 파이썬과 SQL 을 쓴다.",
]

def chunk_text(text, size=50):
    result = []
    for start in range(0, len(text), size):
        result.append(text[start:start + size])
    return result


def score(query, text):
    hit = 0
    for ch in query:
        if ch in text:
            hit += 1
    return hit / len(query)


query = "백엔드가 하는 일"      # 질문만 바꾸고 아래는 그대로다

chunks = [c for d in docs for c in chunk_text(d)]
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]}')

 

 

출력결과

 

「백엔드가 하는 일」을 물었는데 1위가 프론트엔드다. 정답은 2위로 밀렸다.
이유는 허무할 만큼 단순하다.