지난 시간에 이어, streamlit을 활용하여 RAG 데모 서비스를 구축한다.
해당 실습을 진행하며 해결한 오류 및 알게된 점을 작성한다.
streamlit을 활용해 답변을 생성하는 스트리밍 과정과 검색된 청크를 보여주어야 한다.
이때, 처리 시간은 progress bar를 이용하고, 청크 출력에는 청크의 파일명과 앞 200자만 사용한다.
start_time = time.time() # ⏱ 처리 시간 측정 시작
my_bar = st.progress(0, text="🚀 준비 중...") # ✅ 진행률 표시 바 생성
st.write(f"💬 질문: {question}")
...
my_bar.progress(10, text="🧠 질문 임베딩 생성 중...")
question_embeddings = get_embeddings([question])
...
my_bar.progress(40, text="🔍 Milvus 검색 중...")
context_texts = search_milvus(search_vector, selected_collection, top_k=search_limit)
...
my_bar.progress(70, text="💬 LLM 답변 생성 시작...")
response_stream = generate_answer_stream(question, full_context)
...
my_bar.progress(100, text="✅ 답변 완성!")
st.success("✅ 답변이 성공적으로 생성되었습니다!")
selectbox를 사용하여 milvus 벡터 컬렉션 중 하나를 선택하고 싶었다.
다만, 사용자가 A 컬렉션을 선택하여도 임의로 컬렉션이 변경되는 듯 고정되지 않는 값이 발생하는 문제가 매번 발생하였다.
이에 대해서 찾아보니, streamlit은 selectbox에서 index 기반으로 선택한 옵션을 기억한다고 하여, 컬렉션의 순서를 보장하기 위해 sorted()
를 사용해야 한다는 것이었다.
sorted()
로 리스트 순서를 고정하면, index 기반 상태 관리가 안정적으로 작동한다.# 사이드바 설정
# 1. 벡터 DB 컬렉션 리스트 조회 및 선택
st.sidebar.title("🔧 Settings")
st.sidebar.subheader("📚 1. Milvus Collections")
try:
# 🧨 문제: 컬렉션 A 선택 시, (다른 컬렉션이 선택되고) 고정되지 않는 문제
# 🧵 해결: 컬렉션 리스트를 정렬하여 순서를 보장하고, selectbox의 선택된 컬렉션을 유지
# 🤷♀️ 이유: Streamlit이 index 기반으로 selectbox 선택값을 기억하기 때문에, 순서가 바뀌면 선택도 바뀌는 문제 발생.
collections = sorted(
client.list_collections()
) # 컬렉션 리스트 알파벳순 정렬 → 순서 보장
if collections: # Check if the list is not empty
selected_collection = st.sidebar.selectbox(
"📍 Select a collection", collections
)
st.sidebar.write(f"You Selected: {selected_collection}")
else:
st.sidebar.write("❌ No collections found.")
except Exception as e:
st.sidebar.error(f"❌ Failed to retrieve collections: {e}")
st.write(
f"📍 선택된 컬렉션: {selected_collection if 'selected_collection' in locals() else 'None'}"
)
st.sidebar.divider()
작년 인턴의 여파..? 덕분인지(?)
코드를 작성하거나 작성된 코드를 이해하려고 할때
사용된 함수나 인자를 더욱 자세히 바라보는 습관이 생겼다.
해당 변수에는 어떤 값이 담겨있고, 이 함수의 용도는 뭐고, 필수 인자는 무엇이고 선택 인자는 무엇인지 등등..
이번 실습을 진행할 때도 궁금증이 생기면 해결하고 지나가야 하는 성격이 되어
코드 하나하나를 뜯어보며 값을 출력하고 확인하며 실습을 이어나갔다.
# ✅ 첫 번째 쿼리 결과만 사용 (단일 쿼리이므로 results[0] 안에 top_k개 결과 존재)
hits = results[0]
# ✅ 결과에서 'text' 필드만 추출하여 리스트로 반환
return [hit["entity"]["text"] for hit in hits if hit["entity"].get("text")]
client.search()
를 호출하면 결과가 2중 리스트 형태로 온다.
📢 예시 (질문 하나를 검색했을 때):
results = [
[ # ✅ 첫 번째 쿼리에 대한 검색 결과 (top_k 만큼)
{"id": 1, "distance": 0.12, "entity": {"text": "첫 번째 문장", "filenm": "a.pdf"}},
{"id": 2, "distance": 0.20, "entity": {"text": "두 번째 문장", "filenm": "b.pdf"}},
{"id": 3, "distance": 0.30, "entity": {"text": "세 번째 문장", "filenm": "c.pdf"}}
]
]
results
자체가 바깥 리스트 ➡ 여러 개 질문(query)을 동시에 보낼 때를 대비해서 이렇게 구조화됨.
우리는 질문 하나만 보내기 때문에 results[0]
만 꺼내면 됨.
hits = results[0]
➡ “첫 번째(그리고 유일한) 쿼리의 검색 결과(top_k개)”를 hits
에 담는 것.
📢 이제 hits
안에는 이런 딕셔너리들이 들어있다:
[
{"id": 1, "distance": 0.12, "entity": {"text": "첫 번째 문장", "filenm": "a.pdf"}},
{"id": 2, "distance": 0.20, "entity": {"text": "두 번째 문장", "filenm": "b.pdf"}}
]
hit
는 검색된 하나의 결과(row)hit["entity"]
→ Milvus에 저장된 메타데이터(여기선 text
, filenm
)hit["entity"]["text"]
→ 검색된 chunk의 실제 텍스트hits
안에 있는 각 hit
에서hit["entity"]["text"]
값을 꺼내서 새 리스트에 담음if hit["entity"].get("text")
조건 → 혹시 text
값이 None
이거나 없는 경우 제외# ✅ [1단계] 질문 임베딩 생성
my_bar.progress(10, text="🧠 질문 임베딩 생성 중...")
question_embeddings = get_embeddings([question])
question
)을 벡터화하는 코드[question]
으로 리스트 형태로 넣는 이유:question_embeddings
→ 리스트 안에 벡터가 들어 있음[[0.123, 0.456, 0.789, ...]]
if question_embeddings: # ✅ 임베딩이 성공적으로 생성됐는지 확인
get_embeddings()
함수에서 API 오류가 나면 None
을 리턴하게 되어 있음search_vector = question_embeddings[0] # 첫 번째(유일한) 질문 임베딩 사용
question_embeddings
는 리스트이므로 question_embeddings[0]
을 꺼내야 함search_vector
→ Milvus에서 유사도 검색에 사용할 질문 벡터 (FLOAT 리스트)OpenAI에서 스트리밍으로 답변을 받아서 화면에 실시간으로 보여주는 핵심 로직
1️⃣ OpenAI API가 보내는 토큰을 하나씩 받음
2️⃣ 받을 때마다 partial_answer에 추가 → 진행률(progress) 업데이트 → UI에 출력
3️⃣ 스트리밍이 끝나면 progress bar 100%, 완료 메시지 표시
- 👉 “OpenAI가 스트리밍으로 보내는 답변을 토큰 단위로 받으면서 진행률을 올리고, 최종적으로 답변 생성을 완료시키는 단계”
# ✅ [4단계] 스트리밍 답변 생성
for chunk in response_stream:
response_stream
은 generate_answer_stream()
에서 받아온 OpenAI API 스트리밍 응답 객체for chunk in response_stream:
→ 토큰이 도착할 때마다 chunk
가 하나씩 반복문에 들어옴.if chunk.choices[0].delta.content: # ✅ 토큰이 있으면
chunk 내부
{
"id": "chatcmpl-abc123",
"object": "chat.completion.chunk",
"created": 1730000000,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"content": "안녕하세요"
},
"finish_reason": null
}
]
}
# chunk.choices[0].delta → 새로 도착한 토큰(조각) 이 들어있는 부분
# chunk.choices[0].delta.content → 실제로 화면에 출력할 텍스트 토큰
chunk
안에는 여러 메타데이터가 들어있는데, 그중 choices[0].delta.content
에 실제 텍스트 토큰이 담겨 있다delta.content
가 있는 경우만 처리.token = chunk.choices[0].delta.content
partial_answer += token
token_count += 1
token
: 새로 도착한 한 조각의 텍스트.partial_answer
에 계속 이어 붙임 → 답변이 점점 길어짐.token_count
를 1씩 증가 → 몇 번째 토큰인지 카운트.# ✅ 진행률 70→100% 점진적 증가
progress_ratio = 70 + min(
int((token_count / total_tokens_estimate) * 30),
30,
)
total_tokens_estimate
(예: 120)과 현재 받은 token_count
를 이용해서 30% 구간(70→100)을 채움.min(..., 30)
을 쓴 이유 → 100% 이상 안 넘어가도록 제한.my_bar.progress(
progress_ratio,
text=f"💬 답변 생성 중... ({progress_ratio}%)",
)
# ✅ 실시간 답변 표시 (placeholder 갱신)
# answer_box.markdown(partial_answer)
partial_answer
를 Streamlit placeholder(answer_box)에 표시해서 실시간으로 화면에 답변 출력.# ✅ 스트리밍 완료 후 상태 업데이트
my_bar.progress(100, text="✅ 답변 완성!")
st.success("✅ 답변이 성공적으로 생성되었습니다!")
st.success()
로 화면에 “답변이 성공적으로 생성되었습니다!”라는 확인 메시지를 띄움.🔵 1️⃣ 청크(chunk)
📌 왜 청크로 나누나?
📊 예시
인공지능(AI)은 컴퓨터가 사람처럼 학습하고 추론하는 기술이다.
최근 LLM은 RAG와 결합해 더 정확한 답변을 생성한다.
🟢 2️⃣ 토큰(token)
📌 왜 토큰 단위가 중요한가?
[ "사", "과", "를", " 먹", "었다" ]
→ 5 토큰📊 예시
🔄 비유로 구분하기
👉 즉, RAG에서는 “청크”를 나눠 Milvus에 저장하고, LLM은 그 “청크”를 불러와 “토큰” 단위로 읽고 답변을 만들어냄.
chunk의 내부를 살펴보자.
# ✅ [4단계] 스트리밍 답변 생성
for (chunk in response_stream):
# chunk = OpenAI가 보내는 스트리밍 응답 조각
# 토큰이 도착할 때마다 chunk가 하나씩 반복문에 들어옴.
**print(chunk)**
if chunk.choices[0].delta.content: # → 토큰이 있으면
# choices[0].delta.content*에 실제 텍스트 토큰이 담겨 있음
token = chunk.choices[0].delta.content # token: 새로 도착한 한 조각의 텍스트.
partial_answer += token # partial_answer에 계속 이어 붙임 → 답변이 점점 길어짐.
token_count += 1 # token_count를 1씩 증가 → 몇 번째 토큰인지 카운트.
ChatCompletionChunk(
id='chatcmpl-By------...',
choices=
[Choice(delta=ChoiceDelta(
content='', function_call=None,
refusal=None, role='assistant',
tool_calls=None),
finish_reason=None, index=0, logprobs=None)],
created=1753------,
model='gpt-4.1-mini-2025-04-14',
object='chat.completion.chunk',
service_tier='default',
system_fingerprint='fp_0e---...', usage=None)
벡터 서치 과정을 알아보자.
results = client.search(
collection_name=collection_name, # 검색 대상 컬렉션 이름
data=[query_vec], # 입력 쿼리 벡터 (리스트 형태로 전달)
anns_field="dense_vector", # 임베딩한 벡터가 저장된 필드명
search_params=search_params, # 추가 검색 파라미터 필요 시 사용
limit=top_k, # 검색할 유사 문서 개수
output_fields=["text", "filenm"], # 결과로 가져올 메타데이터 필드
)
print(results)
# ✅ 첫 번째 쿼리 결과만 사용 (단일 쿼리이므로 results[0] 안에 top_k개 결과 존재)
hits = results[0] # results[0] → “질문 하나에 대한 top_k 검색 결과 전체”
# ✅ text가 없는 결과는 건너뜀 & 결과에서 'text' 필드만 추출하여 리스트로 반환
return [hit["entity"]["text"] for hit in hits if hit["entity"].get("text")]
data: [[
{'document_id': 'NABO_경제동향_(제45호).pdf_35_0',
'distance': 0.5186111927032471,
'entity':
{'text': '2024년 10월호\n2 고용\n9월 취업자 수는 전년동월대비 14.4만명 증가하였고 실업률은 전년동월대비 0.2%p 하락\n■ 9월 \u200c 취업자 수는 2,884.2만명으로 전년동월대비 14.4만명 증가, 계절조정 취업자 수는 전월대비 5.5만명 증가\n• 취업자 수(전년동월대비, 만명): (’24.7월)17.2 → (8월)12.3 → (9월)14.4\n• 계절조정 취업자 수(전월대비, 만명): (’24.7월)1.9 → (8월)1.4 → (9월)5.5\n| 그림 14\u2009| 전체 취업자 수 추이\n(만명) ■\u2002 취업자수\u2003 \u2002증감(우축) (전년동월대비, 만명)\n3,000 120\n2,884.2 100\n2,900 2,884.2 100\n2,900\n80\n2,800\n60\n2,700 40\n2,600 14.4 20\n20\n2,500 0\n2022.1 4 7 10 2023.1 4 7 10 2024.1 4 7 9\n자료: 통계청, 「경제활동인구조사」\n■ 경제활동참가율(15세 \u200c 이상 인구 중 경제활동인구 비중)은 64.6%로 전년동월과 같은 수준\n• 경제활동참가율(%): (’24.7월)64.9 → (8',
'filenm': 'NABO_경제동향_(제45호).pdf'}},
{'document_id': 'NABO_경제동향_(제45호).pdf_36_1',
'distance': 0.47488752007484436,
'entity':
{'text': '� 취업자 수(전년동월대비, 만명): (’24.7월)-8.1 → (8월)-8.4 → (9월)-10.0\n9월 「연령별 취업자 수」는 30대, 50대 및 60세 이상의 증가세 유지되는 가운데 15~29세\n및 40대는 감소세 지속\n■ 고령층(60세 \u200c 이상) 취업자 수는 전년동월대비 27.2만명 증가하여 증가세 유지\n• 고령층 취업자 수(전년동월대비, 만명): (’24.7월)27.8 → (8월)23.1 → (9월)27.2\n■ 15~29세 \u200c 취업자 수는 전년동월대비 16.8만명 감소하여 감소세 지속\n• 15~29세 취업자 수(전년동월대비, 만명): (’24.7월)-14.9 → (8월)-14.2 → (9월)-16.8\n■ 30대와 \u200c 50대 취업자 수는 증가, 40대 취업자 수는 하락\n• 30대 취업자 수(전년동월대비, 만명): (’24.7월)11.0 → (8월)9.9 → (9월)7.7\n• 40대 취업자 수(전년동월대비, 만명): (’24.7월)-9.1 → (8월)-6.8 → (9월)-6.2\n• 50대 취업자 수(전년동월대비, 만명): (’24.7월)2.3 → (',
'filenm': 'NABO_경제동향_(제45호).pdf'}},
{'document_id': 'NABO_경제동향_(제45호).pdf_38_2',
'distance': 0.46165961027145386,
'entity':
{'text': '4 7 10 ’22.1 4 7 10 ’23.1 4 7 10 ’24.1 4 7 9\n자료: 통계청, 「경제활동인구조사」\n| 그림 22\u2009| 빈 일자리 수 변화율 추이\n(전년동월대비, %) \u2002 300인 이상\u2003 \u2002 300인 미만\u2003 \u2002 전체\n30\n20\n10\n0\n0\n-1.4\n-10\n-18.7\n-20\n-20 -19.4\n-19.4\n-30\n-40\n-50\n-60\n’21.1 4 7 10 ’24.1 4 7 8\n자료: 고용노동통계, 「사업체노동력조사」\n36',
'filenm': 'NABO_경제동향_(제45호).pdf'}}]]
모델이 텍스트를 임베딩하는 과정을 살펴보자.
response = client_openai.embeddings.create(model=model, input=texts)
# ✅ API 응답(response.data) 안에 각 텍스트의 embedding 벡터가 들어 있음
# - item.embedding: 한 텍스트의 임베딩 (float 값들의 리스트)
print(response)
return [item.embedding for item in response.data]
except Exception as e:
print(f"❌ 임베딩 생성 오류: {e}")
return None
CreateEmbeddingResponse(data=[Embedding(embedding=[......,0.002619395265355706],
index=0,
object='embedding')],
model='text-embedding-3-small',
object='list',
usage=Usage(prompt_tokens=11, total_tokens=11))