만들어 보기 · 글 11편만들어 보기
  1. 코딩 에이전트로 작은 로컬 AI 도구 만드는 법
  2. 로컬 채팅 웹앱 만들기
  3. 요약 창 프로그램 만들기
  4. 엑셀에서 로컬 모델을 함수로 부르기
  5. CSV를 한 줄씩 분류하는 도구 만들기
  6. 문서 한 개에 질문하는 도구 만들기
  7. 자연어로 DB를 조회하는 도구 만들기
  8. 로컬 OCR 문서 도구 만들기
  9. 영상·음성 더빙 파이프라인 만들기
  10. 개인정보 점검 도구 만들기
  11. 문서 폴더에 요약과 태그 붙이기

이 글에서 다루는 것텍스트 문서를 단락으로 나누고 질문의 낱말로 관련 단락을 찾아 로컬 모델에 넘기는, 임베딩 없이 만드는 문서 질문 도구입니다.

무엇을 만드나

텍스트 문서 하나를 불러오고 질문을 입력하면, 문서에서 관련 단락을 찾아 그 내용만을 근거로 답하는 도구입니다. 왼쪽에는 문서 원문이, 오른쪽에는 질문과 답이 보입니다. 답 아래에는 어떤 낱말로 몇 개 단락을 찾았는지 표시됩니다.

이 글의 방식은 임베딩을 쓰지 않습니다. 질문에 들어 있는 낱말이 단락에 몇 번 나오는지 세는 단순한 검색입니다. 그래서 준비가 가볍고, 모델 하나만 있으면 됩니다.

이럴 때 맞습니다.

  • 규정집, 안내서, 매뉴얼처럼 한 파일로 된 문서에서 조항을 찾을 때
  • 문서의 용어와 질문의 용어가 대체로 같을 때
  • 문서 전체를 모델에 넣기에는 길 때

뜻은 같지만 낱말이 다른 질문까지 찾으려면 임베딩을 쓰는 검색이 필요합니다. 그 내용은 로컬 AI 학습의 RAG와 임베딩 시리즈에서 다룹니다.

준비물

항목 내용
Ollama 실행 중, 대화용 모델 하나
Python 화면 파일을 띄우는 데만 씁니다. 추가 패키지 없음
문서 UTF-8 텍스트 파일(.txt). 단락 사이가 빈 줄로 나뉘어 있어야 합니다

구조

1. 문서를 빈 줄 기준으로 단락으로 나눔        (브라우저)
2. 질문에서 조사·의문사를 빼고 낱말을 뽑음     (브라우저)
3. 단락마다 낱말이 나온 횟수로 점수를 매김     (브라우저)
4. 점수 높은 단락 5개 + 질문을 프롬프트로 묶음  (브라우저)
5. POST /api/generate, stream=true            (Ollama)
6. 답을 글자 단위로 표시하고 검색 근거를 덧붙임 (브라우저)

CSV 분류 도구와 같은 형태입니다. HTML 한 파일이 모든 일을 하고, serve.py는 그 파일을 http://localhost:8081로 띄워 줍니다.

에이전트에게 줄 요청

문서 질문 도구를 HTML 한 파일로 만들어 줘. 외부 라이브러리와 CDN은 쓰지 않는다.
- 왼쪽: .txt 파일을 불러와 원문을 보여 준다. 빈 줄 기준으로 단락을 나누고 10자 이하 단락은 버린다.
- 오른쪽: 질문 입력과 답변 목록.
- 검색: 질문에서 조사와 의문사를 뺀 2글자 이상 낱말을 뽑아, 단락마다 나온 횟수로 점수를 매긴다.
  점수 높은 단락 5개를 고른다. 하나도 없으면 문서 앞부분 5단락을 쓴다.
- 찾은 낱말을 왼쪽 원문에서 강조 표시한다.
- 프롬프트: "아래 문서 내용만 근거로 답하고, 없으면 없다고 답하라"는 지시 + 단락 + 질문.
- http://localhost:11434/api/generate를 stream=true로 호출해 글자 단위로 보여 준다.
- 답 아래에 어떤 낱말로 몇 개 단락을 찾았는지 적는다.
- serve.py: 표준 라이브러리만으로 127.0.0.1:8081에 띄우고 브라우저를 연다.

핵심 코드

문서를 단락으로 나누는 것은 한 줄입니다.

docParagraphs = text.split(/\n\s*\n/).map(p => p.trim()).filter(p => p.length > 10);

검색은 두 단계입니다. 먼저 질문에서 낱말을 뽑습니다. “은, 는, 이, 가” 같은 조사와 “무엇, 어떻게, 알려, 주세요” 같은 말은 미리 정한 목록으로 뺍니다.

const keywords = question
  .replace(/[?.,!~\s]+/g, " ")
  .split(" ")
  .map(w => w.trim())
  .filter(w => w.length >= 2 && !stopwords.includes(w));

다음으로 단락마다 점수를 매깁니다. 낱말이 나온 횟수에 10점씩, 한 번이라도 나오면 5점을 더합니다.

const scored = docParagraphs.map((para, idx) => {
  let score = 0;
  const lower = para.toLowerCase();
  for (const kw of keywords) {
    const kwLower = kw.toLowerCase();
    const count = (lower.split(kwLower).length - 1);
    score += count * 10;
    if (lower.includes(kwLower)) score += 5;
  }
  return { para, idx, score };
});

찾은 단락은 질문과 함께 프롬프트에 넣습니다. 문서에 없는 내용을 지어내지 않도록 지시문을 앞에 둡니다. 원래 코드의 지시문에서 역할 문구만 일반적인 말로 바꾸면 다음과 같습니다.

const prompt = `아래 문서 내용만을 근거로 질문에 답하세요.
문서에 없는 내용은 "문서에 해당 내용이 없습니다"라고 답하세요.
답변은 한국어로, 간결하게 작성하세요.

[문서 내용]
${context}

[질문] ${question}

[답변]`;

답은 스트리밍으로 받습니다. 브라우저에서는 응답을 조각으로 읽어 줄 단위로 JSON을 풉니다.

const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const chunk = decoder.decode(value, { stream: true });
  for (const line of chunk.split("\n").filter(l => l.trim())) {
    try {
      const json = JSON.parse(line);
      if (json.response) fullText += json.response;
    } catch(e) {}
  }
}

실행하고 확인하기

  1. python serve.py를 실행합니다.
  2. 시험용 문서를 불러옵니다. 지어낸 규정 문서를 한 쪽 분량으로 만들어 쓰면 답이 맞는지 판단하기 쉽습니다. 예: “제5조(연차) 연차는 입사 1년 뒤 15일을 준다.”
  3. 문서에 답이 있는 질문을 합니다. 예: “연차는 며칠인가요?”
  4. 답 아래의 검색 표시에서 뽑힌 낱말과 단락 수를 확인합니다. 왼쪽 원문에 낱말이 강조되는지도 봅니다.
  5. 문서에 없는 내용을 물어 “문서에 해당 내용이 없습니다”라고 답하는지 확인합니다.
  6. 같은 뜻을 다른 낱말로 물어 봅니다. 예: “휴가 일수”. 이때 찾지 못하는 것이 이 방식의 한계입니다.

자주 생기는 문제

증상 원인과 해결
단락이 한 개로 잡힘 문서에 빈 줄이 없습니다. “제○조”처럼 조항 번호로도 나누게 고칩니다
엉뚱한 단락을 근거로 답함 질문의 낱말이 여러 단락에 흔하게 나옵니다. 질문에 조항 이름 같은 구체적인 낱말을 넣습니다
“키워드 매칭 없음”이 표시됨 질문 낱말이 문서에 없습니다. 이때는 문서 앞부분만 넘어가므로 답을 믿지 않습니다
조사가 붙은 낱말을 못 찾음 “연차는”처럼 띄어쓰기 단위로 자르기 때문입니다. 흔한 조사를 낱말 끝에서 떼어 내게 고칩니다
문서에 없는 내용을 답함 작은 모델에서 생깁니다. 답 아래에 근거 단락 원문을 함께 보여 주게 해 사람이 대조합니다

확장하기

  • 근거 단락 보여 주기: 답 아래에 사용한 단락을 접었다 펼 수 있게 붙입니다. 확인 시간이 줄어듭니다.
  • 조항 번호 인식: 단락 첫머리의 “제○조”를 뽑아 답에 조항 번호를 적게 합니다.
  • 여러 파일: 파일 이름을 단락에 붙여 두고, 답에 파일 이름을 함께 표시합니다.
  • PDF와 한글 문서: 브라우저만으로는 읽기 어렵습니다. 파일을 텍스트로 바꾸는 작은 백엔드를 붙입니다. 스캔한 문서라면 로컬 OCR 문서 도구를 먼저 거칩니다.