최근 저는 개인 사이트에 RAG 챗봇을 실행하는 두 가지 방법(two ways to run a RAG chatbot on a personal site for $0)을 비교했습니다. Hashnode와 dev.to에서 각각 한 명의 독자가 제안을 남겼는데, 그 내용이 충분히 좋아서 감사의 말만 전하는 대신 직접 구현해 보았습니다.
처음에는 제가 운영하는 사이트가 이 정도 수정 사항을 적용할 만큼 작다고 생각했습니다. 당시 측정했을 때 76개의 단락(passages)밖에 없었기 때문입니다. 하지만 다시 세어보니, 두 포스팅 후에 이미 110개로 늘어났고 검색 점수는 이미 하락해 있었습니다. 매 포스팅마다 성장하는 사이트야말로 이러한 수정 사항들이 효과를 발휘할 수 있는 곳이었습니다. 그래서 저는 이 세 가지 제안을 해당 포스팅의 두 챗봇과 제가 운영하는 사이트의 챗봇 모두에 추가로 적용하고, 각각의 전후 성능을 측정했습니다.
세 가지 제안 사항
- 변경된 내용만 재색인(Re-index)하기. 각 단락의 텍스트 해시(hash) 값을 저장합니다. 페이지의 나머지 부분이 변경되어도 변하지 않는 단락 ID를 부여합니다. 그런 다음, 해시 값이 변경된 단락만 작성하고 사라진 단락은 삭제합니다. (Hashnode)
- 모든 단락에 날짜 지정하기. 사이트는 시간이 지남에 따라 스스로와 모순되는 내용을 가지게 되며, 평범하게 작성된 오래된 페이지가 정확한 최신 페이지보다 더 높은 순위를 차지할 수 있습니다. 각 단락에 날짜를 지정하고, 모델에게 가장 최신 정보를 신뢰하도록 지시하며 “~을 기준으로(as of)”라고 명시하여 항상 최신 상태인 하나의 페이지를 유지합니다. 그런 다음 오래된 페이지와 새로운 페이지 모두 관련성이 높아 보이는 질문으로 테스트해 봅니다. 독자가 말했듯이, 답변은 “출처가 명확하고, 잘 작성되었으며, 두 해 전의 정보”였습니다. (dev.to)
- 사이트가 제대로 답변하지 못하는 질문 기록하기. 각 질문이 얼마나 잘 일치했는지 기록하여, 부족한 부분들을 작성하거나 재작성해야 할 페이지 목록으로 만듭니다. (Hashnode)
측정 방법
저는 2026년 10월 19일자 사이트의 llms-full.txt 사본을 고정했습니다: 문서 15개, 단락 110개. 그리고 해당 포스팅에서 가져온 코드(이전)와 새로운 코드(이후)를 이 사본에 두 가지 버전 모두로 실행해 보았습니다:
- 재색인화(Re-indexing): 실제 상황을 모방한 5가지 실행. 첫 번째 색인은 동일한 콘텐츠를 다시 사용함 (조용한 밤). 한 게시물에서 문장 하나가 수정되고, 긴 게시물의 상단 근처에 섹션 하나가 삽입되며, 짧은 게시물 하나가 추가됨.
- 검색 품질(Search quality): 해당 게시물의 38개 질문과 사이트가 답변할 수 없는 12가지 질문 (프랑스 수도, 사워도우 빵, 나의 GPA).
- 날짜(Dates): 내 사이트는 아직 모순되지 않지만, 나는 의도적으로 모순되는 것을 만들었다. "Sam Rivera"는 2022년의 소개 페이지(프리랜서 작업 안 함, 시간당 $40)와 2026년 10월 날짜의 개요(70달러, 11월부터 다시 프리랜서 활동). MongoDB를 모든 것에 사용하는 2023년 게시물과 Postgres로 이동하는 2026년 게시물, 그리고 VS Code를 사용한 2024년 설정 게시물과 Zed로 전환하는 2026년 게시물이 있다. 오래된 페이지들은 더 평이하게 작성하여 질문의 단어와 더 잘 일치하도록 했다. 그런 다음 현재 시제(present-tense)의 질문 다섯 개를 요청했다.
1. 변경된 내용만 재색인화하기
이전에는 모든 재색인 과정에서 콘텐츠가 변경되었는지 여부와 관계없이 모든 구절을 임베딩하고 작성했습니다. 그 게시물에서 저는 Upstash의 대규모 사이트는 "콘텐츠 해시(content hash)"를 비교하는 방식으로 변경되지 않은 구절을 건너뛰어야 할 것이라고 언급했습니다. 독자가 그 방법을 설명해 주었습니다.
이제 각 구절은 텍스트의 해시를 저장하며, 재색인화는 쓰기 전에 비교합니다:
stored = store.hashes(settings) # id → hash, for every stored passage
changed = [c for c in chunks if stored.get(c.id) != c.hash]
stale = sorted(set(stored) - {c.id for c in chunks})
...
Passages written per re-index, before and after. Before, every run wrote every passage: 110, 110, 110, 111 and 115. After, only what changed: 110 for the first index, then 0 when nothing changed, 1 for a one-sentence edit, 1 for a section inserted near the top of a long post, and 4 for a short new post. On Upstash, where each write is a metered request, that's 556 writes before and 116 after._두 쌍둥이는 정확히 같은 개수를 작성했습니다.
시간은 흘러갔다. Qdrant 트윈에서는 이전에는 한 번 실행하는 데 약 4초가 걸렸고, 그 대부분을 CPU에서 110개 패시지(passages)에 임베딩(embedding)하는 데 사용했다. 이제는 조용한 밤이 0.02초 만에 끝나고 편집은 약 0.3초가 걸린다. 매번 작성된 벡터마다 일일 요청 10,000건을 기준으로 비용이 발생하는 Upstash에서는, 이전에는 그 다섯 번의 실행에 556회의 쓰기(writes)가 사용되었지만 이후에는 116회만 사용되었다. 조용한 밤은 이제 110개 대신 몇 개의 요청으로 해결된다.
실제 트윈에서도 이 점이 입증되었다. 각 플랫폼에서 배포 후 처음 실행했을 때, 인덱스는 한 번 변환되었다: 새로운 ID로 122개의 패시지가 작성되고 기존의 110개는 삭제되었다(사이트가 다시 성장했기 때문에), Qdrant에서는 18초 만에, Upstash에서는 24초 만에 완료되었다. 아무것도 변경되지 않은 다음 실행은 쓰기도 없고 삭제도 없었으며, 두 플랫폼 모두 약 반 초 만에 끝났다. 임베딩할 내용이 없었기 때문에 심지어 임베딩 모델을 로드하지 않았다.
해시(Hashes)만으로는 충분하지 않았다
첫 번째 버전은 여전히 너무 많은 것을 재작성했다. 패시지 ID는 페이지의 URL과 해당 페이지에서의 패시지 위치로 구성되었다. 만약 내가 긴 게시물의 상단 근처에 한 섹션을 삽입하면, 그 아래의 모든 패시지가 한 칸씩 위로 이동했다. 해시는 동일한 ID를 가진 텍스트를 비교하기 때문에, 25개의 패시지가 변경된 것처럼 보였고, 편집은 26개를 재작성해야 했다.
해결책은 독자가 제안한 방식이었다: 위치가 아닌 URL과 제목을 사용한 ID.
key = f"{doc.url}#{doc.title}#{section}#{n}" # n: 긴 섹션이 분할될 때의 조각(piece)
point_id = uuid.uuid5(uuid.NAMESPACE_URL, key)
digest = hashlib.sha256(text.encode()).hexdigest()[:16]
만약 페이지에 동일한 제목을 가진 두 개의 섹션이 있다면, 두 번째 섹션의 ID에는 숫자가 붙어 충돌이 발생하지 않는다. 이제 편집은 오직 해당 패시지만 건드린다. 비용은 한 가지다: 업그레이드 후 첫 실행 시 모든 것이 새로운 ID 아래에서 한 번에 작성되고 이전 것들이 삭제된다.
2. 모든 패시지에 날짜를 명시하기
가상의 사이트에서는, 이전 페이지가 다섯 가지 질문 모두에서 가장 높은 순위를 차지했다. 네 개의 답변은 여전히 현재의 사실을 사용했지만, 하나는 '저는 MongoDB를 사용합니다'라는 2023년 게시물의 내용을 언급했고, 어느 것도 '기준일(as of)'이라는 문구를 포함하지 않았다.
세 가지 변경 사항:
- 파서는 각 페이지의 날짜(Updated 또는 Published)를 읽으며, 모든 구절 헤더는 이 날짜로 끝납니다. 예:
Moving from MongoDB to Postgres · published Jun 2026. 월만 포함되는데, 이는 헤더가 해시된 텍스트의 일부이기 때문입니다. 이렇게 하면 같은 달 내에서 페이지의 날짜를 재설정해도 다시 인덱싱되지 않습니다.
- 프롬프트에 있는 각 소스는 번호 옆에 날짜를 표시합니다:
[3] https://sam.example/writing/mongodb-to-postgres/ · 2026-06-02.
- 시스템 프롬프트에 두 가지 새로운 규칙이 추가되었습니다:
-"소스 간에 의견 불일치가 있을 경우, 가장 최신 정보를 따르고 더 오래된 주장은 제외합니다."
-"답변이 시간이 지남에 따라 변경될 수 있는 경우(작업 중인 내용, 가용성, 가격, 사용 도구 등), 사용한 소스의 월과 연도를 명시하여 'as of'를 언급합니다."
| 질문 | 이전 (Before) | 이후 (After) |
|---|
| 어떤 데이터베이스를 사용하나요? | "저는 MongoDB를 사용해요" (오래된 정보) | PostgreSQL, "2026년 6월 기준" |
| ... | | |
각 답변에 대해, Groq의 gpt-oss-120b 사용. | | |
이제 다섯 가지 답변 모두 현재 사실을 사용하며, 언제 정보가 나왔는지 명시합니다.
하지만 순위는 변하지 않았습니다. 이전 페이지가 여전히 모든 질문에서 첫 번째로 나왔기 때문입니다. 날짜는 검색 자체를 바꾸지 않습니다. 하는 역할은 두 페이지 모두 읽을 수 있게 되었을 때 모델이 충돌을 해결하도록 돕는 것입니다. 새로운 페이지 하나가 겨우 5위에 도달했을 뿐이며, 날짜로는 검색되지 않은 페이지를 도울 수 없습니다. 이것이 독자가 보는 "현재" 페이지가 중요한 이유입니다. 항상 최신 사실을 담고 있는 한 페이지는 현재 시제 질문에 신뢰할 수 있는 답변 공간을 제공합니다.
내 사이트의 경우 (My site's side)
내 사이트의 llms-full.txt는 이제 모든 문서에 날짜를 부여합니다:
- 각 게시물은 발행일과 편집 시 업데이트일을 모두 가집니다. 관리자 패널에서 제가 게시할 때마다 해당 날짜가 찍힙니다.
- 각 사례 연구는 마지막으로 변경된 날짜를 가집니다.
- 개요(overview), 소개(About), 서비스(Services), FAQ, 스택(Stack) 문서는 배포될 때마다 사이트의 현재 데이터로부터 생성되기 때문에 빌드 날짜를 가집니다. 개요가 바로 "현재" 페이지입니다.
검색 품질은 변동이 없었습니다. 모든 문서에 날짜가 지정되었음에도 불구하고, 38개 질문의 점수는 이전과 동일했습니다: hit@1에서 0.82, hit@3에서 0.92 및 hit@5에서 0.864 MRR을 기록했습니다.
3. 사이트가 답변을 제대로 하지 못하는 질문 기록하기
첫 번째 문제는 어떤 점수를 기준으로 할 것인가입니다. 하이브리드 검색은 상호 순위 융합(Reciprocal Rank Fusion)을 사용하여 구절(passage)의 순위에만 기반한 점수를 매깁니다. 따라서 최상단 구절이 완벽하게 일치하는 경우와 그렇지 않은 경우 모두 비슷한 점수를 받기 때문에, 질문이 얼마나 잘 다루어졌는지 알 수 없습니다.
그래서 검색은 이제 각 구절의 의미 벡터(meaning vector)도 반환합니다. 코드는 이를 질문 자체의 벡터와 비교합니다 (코사인 유사도, 0에서 1 사이). 이때 최적의 구절 점수가 해당 질문과의 일치도가 됩니다:
points = client.query_points(..., with_vectors=["dense"])
match = max(cosine(dense, p.vector["dense"]) for p in points)
if match < settings.weak_match:
...
Upstash에서는 동일한 쿼리에서 include_vectors=True를 사용합니다. 임계값(threshold)을 설정하기 위해 두 질문 세트를 점수화했습니다:
How well each question matched the site. The 38 questions the site answers score from 0.54 to 0.86; the 12 off-topic ones score from 0.45 to 0.57. Below the 0.6 line, 22 questions are logged as weak: all 12 off-topic ones, all 3 that search now misses, and 7 it found but matched weakly.
0.6으로 설정하자, 로그는 모든 주제에서 벗어난 질문(off-topic question)과 검색이 놓치는 모든 질문을 포착합니다. 또한 검색이 올바르게 답변했지만 약하게만 다룬 7개의 질문도 플래그를 지정하여 살펴볼 가치가 있습니다.
세 가지 놓친 부분(misses)이 실제 발견된 핵심 내용이었습니다. 이 세 가지는 모두 채용 질문입니다: “그를 고용하는 데 비용이 얼마나 들까요?”, “그가 우리를 위해 작성한 소프트웨어의 소유권은 누가 가질까요?”, 그리고 “제품 출시 후에도 그가 계속 도움을 줄 수 있을까요?”. FAQ 답변들이 이를 다루고 있지만, 비용, 무료 티어, 운영 유지에 대한 모든 이야기가 담긴 비교 게시물이 이제 FAQ보다 더 높은 순위를 차지하게 되었습니다. 이 게시물 때문에 점수가 하락한 것입니다. 해당 게시물이 측정된 76개 구절에서 동일한 38개 질문은 hit@1에서 0.84, hit@5에서 1.00을 기록했습니다 (이제 하나의 답변 키는 새로운 게시물을 수용합니다). 오늘날의 110개 구절에서는 각각 0.82와 0.92를 기록합니다.
목록 유지하기(Keeping the list)
로그 라인(log line)은 목록이 아닙니다. Vercel은 무료 프로젝트의 로그를 한 시간 동안만 보관하므로, 밤사이에 던진 약한 질문은 아침이 되면 사라집니다. 그래서 각 트윈(twin)은 이제 이를 Postgres에 유지할 수 있습니다. Vercel의 Storage 탭에서 연결된 무료 Neon 데이터베이스를 사용하면, 모든 약한 질문은 점수와 함께 저장되며, 페이지 검색 순위와 시간 정보도 기록되고, 방문자에 대한 어떤 내용도 누락되지 않습니다:
- 답변 스트리밍이 완료된 후에 저장됩니다. 따라서 Neon의 5분 슬립(sleep)에서 깨어나는 데이터베이스가 답변 지연을 일으키지 않으며, 실패한 저장은 로그로만 기록됩니다.
- 90일 후 행(Rows)은 삭제되며, 채팅 창에는 잘 답변하지 못하는 질문들이 보관된다고 표시됩니다.
- 스키마는 마이그레이션(migrations)을 갖춘 모델입니다 (SQLAlchemy 및 Alembic). 이는 배포할 때마다 새 코드가 라이브되기 전에 적용되어, 프로젝트가 평소처럼 성장할 수 있도록 합니다.
- 데이터베이스가 없으면 아무것도 변하지 않습니다: 질문들은 이전과 마찬가지로 기록만 됩니다.
이 목록이 없었다면, 저는 그 세 가지 채용 질문을 오직 평가(eval)를 수동으로 재실행함으로써 발견했을 것입니다. 이제 각 질문은 제가 언제든지 읽을 수 있는 목록에 나타납니다 (python -m rag.weak). 해결책은 FAQ 답변들이 이를 방문자의 언어로 말하도록 만드는 것이며, 이것이 저의 다음 수정 사항입니다.
자체 모델의 임계값(threshold)을 보정하세요. 0.6은 bge-small-en-v1.5에 대한 값입니다. 저는 multilingual-e5-small로 동일한 측정을 시도했는데, 이 모델은 주제와 관련이 있는지 여부와 관계없이 모든 질문을 0.75에서 0.94 사이로 압축했습니다. 따라서 0.6으로는 아무것도 플래그 지정(flag)하지 못할 것입니다. 사이트가 답변하는 질문 몇 개와 그렇지 않은 질문 몇 개를 점수화하여 그 사이에 경계선을 표시하세요.
비용