하나의 문장으로 답하고 그 과정을 보여주는 정부 복지 혜택 검색기.
이렇게 입력합니다:
저는 하이데라바디에 거주하는 34세 재단사이며, 연간 약 2.4 lakh를 벌고 있고, 학교에 다니는 아이가 두 명 있습니다.
결과는 실제로 자격이 되는 인도 정부 제도 488개입니다. 각 카드는 해당 제도를 통과하게 만든 규칙을 명시합니다. 그 아래에는 근접했지만 아쉽게 탈락한 제도 72개가 있으며, 각각은 이를 막은 정확한 수치를 명시합니다: “귀하의 ₹2.4 lakh는 ₹2.4 lakh 상한액보다 ₹10,000 초과입니다.”
이것이 바로 Eligible입니다. 이 시스템은 MyScheme 코퍼스에서 2,066개의 실제 제도를 읽어옵니다: 중앙 정부 제도 527개, 주(state) 제도 1,539개, 발행 부처 및 기관 80곳, 그리고 카피라이터가 아닌 사무원들이 작성한 1340만 자의 자격 요건 산문입니다.
이 글은 이 시스템을 작동하게 만든 설계 결정, 거의 망하게 만들었던 네 가지 버그, 그리고 첫 버전이 제가 출시했던 다른 모든 도구들과 너무 비슷해 보여서 진행된 인터페이스 재구축에 관한 내용입니다.
잘못된 첫 번째 디자인
가장 직관적인 구축 방식은 하나의 테이블, 하나의 검색 기능, 하나의 목록일 것입니다.
schemes: name, summary, state, income_ceiling, min_age, max_age
모든 것을 요청하고, 소득과 나이로 필터링하면 오후 안에 끝낼 수 있습니다.
하지만 이 방식은 즉시 실패하며, 그 실패 자체가 흥미롭습니다. 하이데라바디의 재단사는 '재단사'라는 단어를 검색하는 것이 아닙니다. 그는 “수공예품 및 직조 부문 종사 예술가와 직조공을 위한” 제도가 쓰여 있는 것을 필요로 합니다. 키워드 중복 자체가 전혀 없습니다. Full text search는 아무것도 반환하지 않으며, 가장 필요한 사람이 이 제도는 존재하지 않는다고 생각하며 떠나갑니다.
따라서 문제의 전반부는 '의미(meaning)'이며, 의미는 임베딩(embeddings)을 원합니다.
하지만 후반부는 '산술(arithmetic)'이고, 임베딩은 산술에 매우 취약합니다. 0.83의 cosine similarity score가 ₹2,40,000가 ₹2,50,000 상한액 아래에 있는지 여부에 대해서는 아무것도 말해주지 못합니다. 애플리케이션 메모리에서 80개의 float scores를 필터링한다는 것은 데이터베이스는 아무것도 하지 않고 프로세스가 모든 것을 처리했다는 의미이며, 이것이 바로 “데이터베이스를 얼마나 의도적으로 사용했는지”라는 질문이 다루고자 하는 핵심입니다.
자격 요건(Eligibility)은 두 가지 질문으로 이루어져 있습니다. 따라서 두 개의 데이터베이스가 필요합니다.
flowchart TD
B[
Browser<br/>React 18 + Vite, one screen]
) -->|"POST /api/search"|
A[
Express API<br/>64 kB body cap]
)
A --> P[
parseProfile()<br/>optional LLM, output validated
]...
VectorsDB는 각 스킴(scheme)당 하나의 문서—슬러그와 임베딩된 자격 요건 단락—를 보유합니다. 그 외에는 아무것도 없습니다. TablesDB는 산술적인 정보를 보유합니다: 관할권(jurisdiction), 등급(level), 최소 및 최대 연령, 소득 상한선(income ceiling), 급여액(benefit amount), 문서 개수, 여성 전용 여부 및 장애인 전용 플래그입니다. 슬러그가 이 둘을 연결합니다.
제가 계속 되돌아가는 규칙은 이것입니다: 만약 두 데이터베이스 모두 소득 상한선을 저장한다면, 동일한 사실에 대한 두 개의 진실의 출처(sources of truth)가 존재하게 되고, 그들이 의견이 다르다는 날 앱은 거짓말을 하기 시작할 것입니다. 벡터 스토어는 의미를 알고, 테이블은 사실을 압니다.
임계값을 지어내지 않고 2,066개 스킴 읽기
server/extract.ts 파일은 산문(prose)을 유형화된 사실로 변환합니다. 어려운 부분은 파싱(parsing) 자체가 아니라, 문서가 아무것도 말하지 않을 때 무엇을 할지 결정하는 것입니다.
잘못된 임계값(threshold)이 누락된 값보다 더 위험합니다. 만약 스킴에 소득 상한선이 없다고 명시되어 있는데 파서가 0을 작성한다면, 그 스킴은 지구상의 모든 사람들을 조용히 제외하게 됩니다. 만약 Infinity를 작성한다면, 부처(ministry)가 규칙을 변경할 때까지 아무도 깨진 파싱을 알아차리지 못합니다. 따라서 파서는 값과 함께 규칙이 명시되었는지 여부를 반환합니다:
export function parseIncomeCeiling(text: string): { ceiling: number; stated: boolean; clause: string } {
const parts = sentences(text);
let best: { ceiling: number; clause: string } | null = null;
...
}
겉보기보다 더 중요한 세 가지 세부 사항이 있습니다:
- 문장 필터링(The sentence filter). "income"을 포함하는 줄만 고려합니다. 지출 상한선, 프로젝트 비용, 대출 금액은 모두 "lakh"라고 명시되어 있으며 소득 한도가 아닙니다.
- 최대값(best) 규칙. 단락에 여러 금액이 언급될 경우, 파서는 "income" 항목에서 가장 높은 값을 가져옵니다. "up to ₹2 lakh, ₹2.5 lakh for SC/ST"라는 계획은 잘못된 값을 선택할 경우 모두 2.4 lakh로 인정하게 됩니다. 최대값을 취하는 것이 접근성을 넓히는 선택이며, 이는 혜택 도구의 관점에서 더 안전한 오류입니다.
- 조항(clause). 모든 상한선은 원래 문장이 사용하던 형태를 유지하므로, UI는 단순히 숫자가 아닌 정부가 직접 사용한 원문을 보여줄 수 있습니다.
나이대와 추측할 수 없는 두 개의 플래그(womenOnly, disabilityOnly)에도 동일한 형식이 적용됩니다. 이들은 왼쪽 고정형 정규식(left anchored regexes)으로 계산되는데, 이전 버전에서는 'female' 안에 있는 'male'을 일치시켜 여성용 계획을 남성용 계획으로 잘못 처리했기 때문입니다.
엔진과 버그 리포트가 담긴 주석
server/rules.ts는 순수 함수(pure function)입니다. 데이터베이스도, 네트워크 연결도, 모델도 없습니다. 이것은 의도적인 것입니다. 결정 과정 자체가 감사 가능해야 하므로, 종속성이 없는 부분이 되어야 하기 때문입니다.
// 중앙 계획은 행이 어떻게 명시하든 모든 곳에 적용됩니다: `level = central`을 저장하는 테이블은 관할권(jurisdiction)의 문자열로 판단해서는 안 됩니다.
const everywhere =
...
저 주석은 제가 배포했던 버그를 설명하는 것입니다. 이전 버전에서는 관할권 문자열을 비교했습니다. 이는 로컬 테스트 환경에서는 작동했는데, ingest.ts가 중앙 계획에 대해 'All India'를 작성했기 때문입니다. 이후 행들이 Appwrite에 도착했고 일부는 jurisdiction: Central 및 level: central로 돌아왔습니다. 이 모든 계획들은 텔랑가나(Telangana) 사용자에게 거절되었는데, 이는 철자가 정확했지만 완전히 잘못된 것이었습니다. 이제는 level이 결정합니다. 문자열은 장식일 뿐입니다.
도구를 정직하게 유지하는 두 번째 규칙은 다음과 같습니다:
어떤 계획은 실제로 명시한 필드에서만 거부됩니다.
모든 블로커(blocker)는 카드가 그대로 인쇄하는 gap 문자열을 가지고 있습니다. 이것이 근접한 미스(near miss)가 '미적격'이라고 말하는 대신 "귀하의 수입 ₹2,40,000은 상한선인 ₹1,00,000보다 높습니다"라고 읽는 이유입니다. 숫자가 제품입니다. 사람들은 판결을 원하는 것이 아니라 자신이 얼마나 떨어져 있는지 알고 싶어 합니다.
조인(join)과 벡터 스토어가 담아서는 안 되는 것
export async function semanticCandidates(text: string, limit = 80): Promise<Hit[]> {
const { vectors } = services();
const [vector] = await embed([text]);
...
코사인 유사도 기반으로 80개의 후보군을 가져옵니다. 그런 다음 테이블 레이어(table layer)가 자체적인 술어 쿼리(predicate query)를 실행하고, 이 두 결과의 합집합이 규칙 엔진(rules engine)으로 전달됩니다. 어느 쪽 레이어도 스스로 결정하는 것이 아니며, 이것이 핵심입니다: 재현율(recall)과 진실성(truth)은 별개의 작업이며, '예'라고 말할 수 있는 유일한 주체는 엔진뿐입니다.
Illustration of Eligible
요청은 엔드투엔드로 다음과 같이 보입니다:
semanticCandidates 80 tableCandidates 500 union 560
killedByRules 72 matched 488 total 95 ms
이것들은 실제 코퍼스(corpus)의 데모 문장에서 나온 실제 수치이며, 검색 시 UI에 표시됩니다. 저는 스피너(spinner)보다는 영수증을 보여주는 편이 낫습니다.
기록할 가치가 있는 네 가지 버그
1. Invalid query: Syntax error. 설정 스크립트가 queries: ['limit(1)']를 전달했는데, 이 SDK 버전은 Query.limit(1)을 원했습니다. 앱 코드는 올바르게 작성되었고; 잘못된 것은 오직 스크립트뿐이었습니다. 단순 문자열 하나 때문에 30분을 허비했습니다.
2. Cannot read properties of undefined (reading 'length'). ingest.ts는 2,066개의 스키마를 가진 일반 배열(plain array)을 작성합니다. 설정 스크립트는 { rows, embedText, meta }를 예상했습니다. 모든 스키마 구문은 성공했지만, 첫 번째 행 호출에서 .length를 undefined로부터 읽으려고 시도했습니다. 수정된 코드는 두 가지 형태 중 어느 쪽이든 읽을 수 있도록 했는데, 변경되는 임시(fixture) 형식처럼 보여서는 안 되기 때문입니다.
3. 'Invalid document structure: Unknown attribute: "issuer"'. 이 오류 하나가 전체 로드를 망쳤습니다. 테이블과 인덱스는 생성되었지만, 모든 배치(batch)가 실패하여 2,066개 행 중 0개가 저장되었습니다. Appwrite는 배치를 하나의 페이로드(payload)로 검증합니다. 단 하나의 선언되지 않은 키(undeclared key)만 있어도 그 안에 있는 수백 개의 행 전체를 거부하며, 오류 메시지는 가장 먼저 걸린 키의 이름만을 알려줄 뿐, 실제로 잘못된 항목들의 목록을 보여주지 않습니다.
Fixture는 엔진이 테이블에 쓸모없는 구절이나 조항(prose and clauses) 때문에 28개의 필드를 가지고 있습니다 (issuer, embedText, eligibilityExcerpt, ageClause, incomeClause, genders). 하지만 테이블은 23개만 선언합니다. 해결책은 스키마를 확장하는 것이 아니라 '프로젝션(projection)'입니다:
const payload = chunk.map((r) => ({ ...toRow(r as never), $id: String(r.slug) }));
이제 toRow()만이 전송되는 행의 모양을 결정할 수 있는 유일한 방법이며, 테이블이 선언하지 않은 모든 키는 자동으로 제거됩니다. 이 함수 안에는 두 번째로 조용한 버그가 숨어 있었습니다. 스키마에서는 documentsCount를 사용하는데 API 측면에서는 docsCount를 읽었기 때문에, 성공적인 로드라 할지라도 모든 카드에 0개의 문서 카운트가 표시될 것이었습니다. 이 두 가지 버그 모두 tsc(TypeScript Compiler)의 경고를 유발하지 않았는데, 양쪽 모두 Record<string, unknown>으로 타입 지정되어 있었기 때문입니다. 스키마 드리프트(Schema drift)는 타입 오류가 아니라 개발자가 작성하지 않은 테스트 케이스입니다. 그래서:
columns 23 rows 2066 violations 0
payload keys 23/23 missing [] extra [] max row 2741 bytes
4. 유효한 키를 사용했음에도 빈 화면이 나타나는 경우. 프로젝트는 설정되어 있지만 비어 있을 수 있습니다. 테이블도, 행도, 벡터도 없습니다. 이러한 상태에 대한 정직한 버전은 영수증(receipt)에 그 사실을 알려주는 폴백(fallback) 메커니즘입니다:
if (rows.length === 0 && hits.length === 0) {
const result = searchLocal({ text, profile: merged, parsedBy });
result.receipts.vectorLayer = 'local fallback: Appwrite returned no rows (run npm run verify:appwrite)';
...
잘못된 키를 사용했을 때도 흰 화면이 아니라 레이블과 함께 점진적으로 오류가 발생해야 합니다.
인터페이스(Interface) 자체가 논거이다(Argument)
첫 번째 빌드는 제가 이전에 출시했던 것과 비슷했습니다. 따뜻한 종이 위에 둥근 카드 형태로, 녹색과 사프란색 악센트를 사용했죠. 이전 작업물 옆에 나란히 놓이니 단지 다른 단어로 쓰인 같은 제품처럼 보였고, 바로 그 이유로 거절당했습니다.
재구축은 이 제품이 실제로 무엇인지에서 시작합니다. 적격성(Eligibility)은 마켓플레이스 목록이 아니라, 사용자가 권리를 갖는 문서입니다. 그래서 페이지는 차가운 회색 책상 위의 흰 종이 같고, 카드 경계선 대신 얇은 선으로 구분되어 있으며, 스킴이 거부된 곳에만 나타나는 단 하나의 빨간 봉인이 있습니다. 여기서 빨강은 장식이 아닙니다. 빨강은 '아니오'를 의미합니다.
대부분의 작업은 세 가지 결정에서 나왔습니다:
하나의 봉인, 하나의 의미. --seal: #a32a1c는 거부, 즉 '실패 원인' 수치에만 사용되며 그 외에는 아무 데도 쓰이지 않습니다. 빨간색이 버튼을 강조하는 순간, 거부라는 것은 무게를 잃습니다. 어두운 판결 영역에서는 같은 의도를 전달하기 위해 더 밝은 혼합색인 #e2644f가 필요합니다. 왜냐하면 #a32a1c를 #14181b에 적용할 경우 대비율이 4.39:1로 측정되어 AA 등급을 통과하지 못하기 때문입니다. 빨간색 두 가지, 용도 두 가지.
비례적으로 그려진 막대. 답변은 네 개의 영역으로 열립니다: 모든 내용(everything read), 의미에 따라 추려낸 내용(everything shortlisted by meaning), 통과한 내용(everything that passed), 거부된 내용(everything refused). 이들은 같은 크기의 알약처럼 균등하게 배치되지 않고 비례적으로 크기가 조정되어, 거부 영역이 눈에 띄게 얇습니다. 23.6%가 통과했고 3.5%가 거부되었으며, 단어 하나를 읽기 전에 이를 확인할 수 있습니다.
의도적으로 크게 설정된 타이포그래피. 명세서와 금액에는 Newsreader를 사용하고, 컨트롤 및 행에는 Public Sans를 사용했습니다. 두 글꼴 모두 public/fonts에서 미리 로드한 자체 호스팅 가변 woff2 파일입니다. 루트는 18px, 제출 버튼은 52px, 그 외 모든 요소는 최소 44px로 설정되었습니다. 이 디자인은 밤 9시에 60세일 수 있는 사람이 휴대폰으로 읽을 것입니다.
제가 중요하다고 예상하지 못했던 부분은 플레이스홀더였습니다. 이것은 흰색 배경에 #8b959c로 배치되어 있었는데, 이는 대비율이 3.05:1로 기술적으로는 괜찮지만 실제로 읽기 매우 어려운 수준이었습니다. 현재는 #6b757c로 변경하여 4.71:1을 달성했습니다. 제가 이 수치를 알게 된 유일한 이유는 감사(audit)가 의견 대신 숫자를 출력하기 때문입니다.
const need = size >= 24 || (size >= 18.66 && bold) ? 3 : 4.5;
const r = ratio(rgb(cs.color), bgOf(el));
if (r < need) low.push({ text, cls, ratio: r, need, size });
npm run audit는 Chrome을 1440, 1024, 390 픽셀에서 구동하며, 결과 화면의 모든 레이블을 탐색하고, 실제 그려진 배경과 대비(contrast)를 계산하며, 모든 컨트롤을 측정하고, 수평 오버플로우(horizontal overflow)를 확인하며, 회귀(regression)가 발생하면 0이 아닌 값으로 종료됩니다:
1440px overflow 0 sub-AA 0 targets<44 0 errors 0 bar 23.6/3.5% grid 148px 644px 128px
1024px overflow 0 sub-AA 0 targets<44 0 errors 0 bar 23.6/3.5% grid 148px 540px 128px
390px overflow 0 sub-AA 0 targets<44 0 errors 0 bar 23.6/3.5% grid 318px
이것이 바로 스크린샷 대신 스크립트로 방어되는 전체 디자인 시스템입니다.
솔직한 비용 분석