지난 시간에 이어, 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))