견적 요청은 정해진 양식으로 오지 않습니다. "현장에 배수관 100 다섯 개랑 엘보 25A 열 개요, 열선도 좀 필요할 것 같은데" 같은 문장으로 옵니다. 담당자는 이걸 읽고 품목을 골라 단가표를 뒤져 견적서를 만듭니다.
그래서 사람들은 대개 요청 폼부터 만듭니다. 그런데 폼을 만들면 고객이 폼을 안 씁니다. 전화하거나 카톡을 보냅니다. 입력을 정형화하려 하지 말고, 비정형 입력을 처리하는 쪽을 자동화하는 게 맞습니다.
TXT자유 문장 요청AI품목 · 수량 추출MT카탈로그 대조PDF견적서 생성
이 케이스의 핵심 원칙 — 모델은 단가표를 보지 않습니다
AI로 견적서를 만든다고 하면 가장 먼저 나오는 걱정이 "엉뚱한 가격을 지어내면 어쩌나"입니다. 타당한 걱정이고, 구조로 막아야 합니다.
- 모델에게 단가표를 아예 주지 않습니다. 모델이 하는 일은 문장에서 품목명과 수량을 뽑는 것 하나뿐입니다. 가격은 프롬프트에도, 응답 형식에도 없습니다. 볼 수 없는 값은 지어낼 수도 없습니다.
- 가격은 SQL이 붙입니다. 추출된 품목명을 실제 카탈로그 테이블과 대조해 단가를 가져옵니다. 견적서에 찍히는 모든 숫자는 데이터베이스에서 온 값입니다.
- 매칭이 확실하지 않으면 가격을 붙이지 않습니다. 애매한 품목은 견적서에 안 들어가고, 이유와 함께 담당자에게 갑니다.
이 순서가 중요합니다. 흔한 실패 구성은 단가표를 통째로 프롬프트에 넣고 "견적서를 만들어줘"라고 하는 것입니다. 그러면 모델이 품목을 비슷한 다른 품목으로 바꿔치기해도 아무도 모릅니다. 숫자가 그럴듯해 보이기 때문입니다. 추출과 가격 결정을 분리하면 이 실패가 구조적으로 불가능해집니다.
품목 매칭 — 문자열이 정확히 일치하지 않습니다
"스텐 엘보 25A"와 카탈로그의 "스테인리스 엘보 90도 25A"는 같은 물건인데 문자열은 다릅니다. 그래서 정확 일치가 아니라 유사도로 찾습니다. PostgreSQL의 pg_trgm 확장이 이 일을 합니다.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE IF NOT EXISTS catalog (
sku text PRIMARY KEY,
name text NOT NULL,
unit text,
unit_price numeric NOT NULL
);
-- 카탈로그가 크면 유사도 검색용 인덱스를 걸어두세요.
CREATE INDEX IF NOT EXISTS catalog_name_trgm_idx
ON catalog USING gin (name gin_trgm_ops);실제로 재본 유사도 값입니다. 한글 품목명에서도 잘 동작합니다.
요청 문자열 카탈로그 품목 유사도
"PVC 배수관 100mm" → PVC 배수관 100mm 1.000
"배수관 100mm" → PVC 배수관 100mm 0.714
"열선 10m" → 동파방지 열선 10m 0.583
"스텐 엘보 25A" → 스테인리스 엘보 90도 25A 0.421
"없는품목 XYZ" → (0.2 넘는 후보 없음)가장 위험한 경우는 '못 찾는 것'이 아니라 '잘못 찾는 것'입니다
여기가 이 케이스에서 가장 중요한 부분입니다. 카탈로그에 없는 품목은 점수가 낮아서 쉽게 걸러집니다. 진짜 문제는 비슷한 품목이 여러 개 있을 때입니다.
요청: "배수관" (지름이 안 적혀 있음)
PVC 배수관 150mm 유사도 0.286
PVC 배수관 100mm 유사도 0.286 ← 완전히 동점
1위만 보면 150mm 가 선택됩니다. 그런데 그건 정렬 순서가 정한 것이지
요청이 정한 게 아닙니다. 절반의 확률로 틀린 지름을 견적에 넣게 됩니다.- 그래서 임계값을 두 개 씁니다.
MATCH_MIN(기본 0.35)은 1위가 충분히 비슷한지 보고,MIN_GAP(기본 0.15)은 1위가 2위를 확실히 이겼는지를 봅니다. 둘 다 통과해야 가격이 붙습니다. - 두 번째 조건을 빼먹기 쉽습니다. 유사도 매칭을 처음 붙이면 대개 임계값 하나만 둡니다. 그러면 위 같은 동점에서 조용히 아무거나 고릅니다.
- 동점은 매칭이 아니라 질문입니다. "배수관 말씀하신 게 100mm인가요 150mm인가요?"라고 되물어야 할 상황입니다. 워크플로우는 그 판단을 사람에게 넘기고, 어느 두 품목이 헷갈렸는지까지 알려줍니다.
- 두 값은 카탈로그마다 다시 잡아야 합니다. 품목명이 서로 비슷한 카탈로그(규격만 다른 제품이 많은 경우)일수록
MIN_GAP을 올려야 합니다.
무엇이 견적서에 들어가고, 무엇이 보류되나
가격을 붙이는 조건 (셋 다 만족)
1위 유사도 ≥ MATCH_MIN (0.35)
1위 − 2위 ≥ MIN_GAP (0.15)
수량이 양수로 명시됨
보류 사유별 표시
동점·근소차 → "어느 품목인지 불분명 — A vs B (유사도 0.29 / 0.29)"
낮은 유사도 → "카탈로그에 없는 품목"
수량 없음 → "수량이 적혀 있지 않음"
보류가 한 건이라도 있으면 status = needs_review
→ 견적서 PDF 가 자동 발송되지 않습니다수량을
null로 두는 것도 의도적입니다. 프롬프트에 "수량이 명시되지 않았으면 절대 1로 가정하지 말라"고 못 박았습니다. "열선도 좀 필요할 것 같은데"를 1롤로 해석하면 견적이 조용히 틀립니다. 모르면 모른다고 표시하는 편이 낫습니다.필요한 것
- n8n — 요청을 외부에서 받으므로 인터넷에서 접근 가능한 주소가 필요합니다.
- PostgreSQL + `pg_trgm` 확장 — 대부분의 배포판에 기본 포함돼 있고
CREATE EXTENSION한 줄이면 됩니다. 관리형 서비스에서도 대개 허용됩니다. - 카탈로그(단가표) 데이터 — SKU·품목명·단위·단가. 이게 이 케이스의 전제입니다. 엑셀로 관리 중이면 먼저 테이블로 옮기세요.
- LLM API 키 — 품목 추출용. 요청 한 건당 짧은 호출 하나라 비용은 작습니다.
- PDF 생성 서비스 — HTML을 PDF로 만들어주는 서비스면 무엇이든 됩니다. 요청 형식이 서비스마다 달라 노드는 비워 두었습니다.
- Slack Bot User OAuth Token — 결과·보류 항목 알림용.
⬇︎ 워크플로우 다운로드 (quote-generator.json)
Postgres 자격증명 하나, Slack 자격증명 하나, LLM 키와 모델명이면 추출·매칭·게이트가 그대로 돕니다. 카탈로그 테이블과 PDF 서비스만 각자 환경에 맞게 채우세요.
검증 범위를 정확히 밝힙니다. 매칭 쿼리와 게이트 로직은 PostgreSQL 17(
pg_trgm 1.6)과 Node.js에서 5개 품목 요청으로 실행해 확인했습니다 — 정확 일치(1.000), 접두어가 빠진 요청(0.714), 약어 요청 "스텐 엘보 25A"→"스테인리스 엘보 90도 25A"(0.421), 수량이 없는 요청, 카탈로그에 없는 품목. 동점 사례도 실제로 재현했습니다 — "배수관"이 100mm와 150mm 양쪽에 0.286으로 완전히 동점이 나며, 게이트가 이를 가격 없이 보류시키고 어느 두 품목이 헷갈렸는지 표시하는 것까지 확인했습니다. 금액 계산도 손으로 대조했습니다(4,500×10=45,000 / 12,000×5=60,000). 노드 타입·버전은 기존 배포 워크플로우와 동일한 조합입니다. 확인하지 않은 것: 실제 LLM 추출 정확도, 실제 PDF 생성 서비스 연동, 실제 Slack 게시. 그리고 위 유사도 수치는 이 예시 카탈로그 기준이며, 품목명 체계가 다르면 값이 달라지므로 임계값은 반드시 자체 카탈로그로 다시 잡으셔야 합니다. 마지막 검증: 2026-08-15.설정 순서 (40분)
- 카탈로그 테이블 준비 —
CREATE EXTENSION IF NOT EXISTS pg_trgm;실행 후 위catalog테이블을 만들고 단가표를 넣습니다. 품목명은 실제로 부르는 이름과 최대한 가깝게 넣으세요. 매칭 품질이 여기서 갈립니다. - 유사도 감 잡기 — 자주 오는 요청 표현 10개를 골라
SELECT name, similarity(name, '요청표현') FROM catalog ORDER BY 2 DESC LIMIT 3;를 돌려보세요. 임계값을 정하기 전에 자기 카탈로그의 점수 분포를 먼저 봐야 합니다. - Postgres·Slack 자격증명 등록 — 각각 Test로 확인하고 채널에
/invite @봇이름. - 워크플로우 import — n8n → Workflows →
...→ Import from File. - 모델·API 키 입력 —
Build Extraction Prompt의 모델명과AI Extract Items의 API 키를 채웁니다. - 임계값 조정 — 2단계에서 본 점수 분포에 맞춰
Price or Flag노드의MATCH_MIN과MIN_GAP을 정합니다. 규격만 다른 제품이 많다면 `MIN_GAP`을 0.2 이상으로 올리세요. - PDF 서비스 연결 —
Generate Quote PDF노드에 사용하는 서비스의 엔드포인트·인증·본문을 넣습니다. - 일부러 모호한 요청으로 테스트 — 규격을 뺀 품목명(예: 지름 없는 "배수관")으로 요청을 보내보세요. 가격이 붙지 않고 보류로 떠야 합니다. 여기서 값이 붙으면
MIN_GAP이 너무 낮은 것입니다. - Activate 후 실제 요청 문장 몇 건으로 확인합니다.
예외 상황이 있으면 어떻게 되나
- 카탈로그에 없는 품목 — 가격을 붙이지 않고 "카탈로그에 없는 품목"으로 표시합니다. 신규 품목이라면 카탈로그에 추가하면 다음 요청부터 잡힙니다.
- 규격이 빠진 요청 — 동점으로 잡혀 보류됩니다. 담당자에게 어느 두 품목이 헷갈렸는지 함께 표시되므로 되묻기가 쉽습니다.
- 수량이 없는 품목 — 1로 가정하지 않고 보류합니다. "좀 필요할 것 같은데" 같은 표현이 실제로 자주 옵니다.
- 보류 항목이 하나라도 있으면 —
status가needs_review가 되고 견적서가 자동 발송되지 않습니다. 확정된 품목의 소계는 함께 보여주므로 담당자가 나머지만 채우면 됩니다. - AI 응답이 JSON이 아닌 경우 — 빈 품목 목록으로 처리되어 견적이 만들어지지 않습니다. 조용히 잘못된 견적이 나가는 것보다 안전합니다.
- 단가가 바뀐 경우 — 견적은 항상 카탈로그의 현재 단가로 계산됩니다. 과거 견적을 재현해야 한다면 견적 시점의 단가를 별도로 저장해야 합니다. 기본 구조에는 단가 이력이 없습니다.
- 같은 요청이 두 번 들어온 경우 — 중복 방지가 없어 견적이 두 번 만들어집니다. 필요하면
request_id를 멱등키로 삼아ON CONFLICT를 거세요.
0건
지어낸 가격
2단계
매칭 게이트
동점 감지
규격 혼동 방지
40분
셋업 시간
카탈로그의 정확한 품번과 대조하고, 없는 품목은 가격을 지어내지 말고 표시해 주세요.— n8n 커뮤니티에 올라온 실제 유료 발주 요건