STORM Fooding
Sionic AI에서는 팀원들의 선호도와 메뉴/식당 데이터를 지식 그래프로 구성하여 매일 점심 메뉴를 추천하고 Slack을 통해 효율적으로 주문을 관리하고 있습니다.
이번 아티클에서는 Sionic AI의 STORM Fooding 프로젝트를 소개합니다.
다음주부터 밥이 안온다고?
1년 동안 사용하고 있던 점심 식사 구독 서비스가 업계 측 사정으로 인해 종료되었습니다
사실 그동안 아주 만족스럽지는 않았지만
매일 정해진 시간에 식사가 오고 음식물 처리가 간편했기에
여러 불편함을 감수하고 사용했었는데요.
이 참에 새로 저희만의 사내 음식 주문 자동화 서비스를 만들어보는건 어떨까라는 생각을 해봤습니다
STORM Fooding 프로젝트의 배경
저희가 기존까지 사용했던 점심 식사 구독 서비스의 단점으로는 다음과 같은 것들이 있었습니다.
•
식당 pool이 고정되어 있기 때문에 먹었던 식당의 같은 메뉴를 먹게 되는 경우가 많음
◦
맛있었던 식당이면 괜찮지만, 만족스럽지 않은 식당의 메뉴도 주기적으로 먹게됨
◦
반년정도면 한 식당의 같은 메뉴를 5~7번 이상 먹게 되어 질리기 쉬움
•
전날 3시 이후 수량 변경, 취소 불가
◦
평이 안좋았던 메뉴를 취소하려면 미리 3일전에 확인해서 취소해야 함
◦
당일 사무실 상황에 따라 유동적으로 식수 변경 불가
•
서비스 이용료, VAT10% 등이 추가되어 실제로 주문해서 먹는 비용보다 비쌈
위와 같은 문제 의식을 바탕으로 다음과 같은 수정 원칙을 만들었습니다.
•
팀원분들의 선호를 반영
◦
평이 좋은 식당들 위주로 매번 다른 메뉴 조합으로 주문
◦
평이 나빴던 식당/메뉴는 안 나오도록
◦
당일 상황에 맞게 알잘딱 주문
•
Slack에서 전날 미리 수요조사 및 메뉴 공지
◦
당일 사무실 상황에 맞게 수량 주문
•
절약하는 비용은 더 알찬 식단에 재투자
알잘딱 메뉴 추천을 위한 고민 - Graph RAG
처음에는 메뉴 추천을 위해 많이 사용되던 Rule Based 접근 방식을 고려했습니다. 하지만 이 방법으로는 평점이 높더라도 실제 맛이나 서비스가 일관적이지 않을 수 있는 문제를 해결하기 어려웠습니다. 또한 대체로 리뷰 이벤트를 많이 하는 프렌차이즈(치킨,피자,족발,마라탕등등)의 비율이 높고 미리 정해 둔 식비에 맞추기도 어렵습니다.
결국, 평점의 장점을 취하면서도 팀의 선호도와 가격 대비 효율성 등을 고려할 수 있는 방법론이 필요했습니다.
이때 Semantic RAG를 사용한다면 Review들을 모두 Embedding으로 변환한 뒤 VectorDB에 저장해 리뷰를 자연어로 검색할 수 있습니다. 가령, 가성비, 더울 때 먹기 좋은, 또 먹고 싶은, 바삭바삭한 등등의 키워드로 식당/메뉴를 추천받을 수 있습니다.
Text to SQL
또는 LLM에게 Data Table의 Schema를 주고 SQL Query를 작성하도록 할 수도 있습니다.
위의 방법으로 가장 리뷰 수가 많은 식당 뿐만 아니라, 미리 분류해 놓았다면 가장 가성비가 있다는 평이 많은(비율이 높은) 식당 등을 찾을 수 있습니다. 하지만 여전히 다른 팀원의 선호를 반영해 메뉴를 추천하기는 어렵습니다.
Graph RAG
Semantic하게 Review를 검색할 수 있고,
Graph를 통해 데이터의 관계, 구조를 통한 Insight까지 알 수 있는 Graph RAG를 사용한다면
맛있는 식당을 찾아야하고 맞춤형 추천을 해야하는 저희의 Task를 해결할 수 있을지도 모릅니다
먼저 그래프의 Schema부터 생각해보면,
[고객] -:구매함:→ [메뉴] -:속해있음:→ [식당]
이런 식으로 관계를 표현할 수 있습니다
저희는 카테고리별로 메뉴와 식당을 확실하게 구분하고 싶었기 때문에 아래와 같이 Schema를 구성했습니다.
이제 데이터를 넣으면 아래와 같이 카테고리를 기준으로 Graph가 구성되는 것을 볼 수 있습니다
셈플 150개 추출
# create constraints - one uniqueness constraint for each node label
gds.run_cypher('CREATE CONSTRAINT unique_department_no IF NOT EXISTS FOR (n:Restaurant) REQUIRE n.departmentNo IS UNIQUE')
gds.run_cypher('CREATE CONSTRAINT unique_product_code IF NOT EXISTS FOR (n:Menu) REQUIRE n.productCode IS UNIQUE')
gds.run_cypher('CREATE CONSTRAINT unique_article_id IF NOT EXISTS FOR (n:Category) REQUIRE n.articleId IS UNIQUE')
gds.run_cypher('CREATE CONSTRAINT unique_customer_id IF NOT EXISTS FOR (n:Customer) REQUIRE n.customerId IS UNIQUE')
# load nodes
gds_db_load.load_nodes(gds, department_df, 'departmentNo', 'Restaurant')
gds_db_load.load_nodes(gds, article_df.drop(columns=['productCode', 'departmentNo']), 'articleId', 'Category')
gds_db_load.load_nodes(gds, product_df, 'productCode', 'Menu')
gds_db_load.load_nodes(gds, customer_df, 'customerId', 'Customer')
# load relationships
gds_db_load.load_rels(gds, article_df[['articleId', 'departmentNo']], source_target_labels=('Category', 'Restaurant'),
source_node_key='articleId', target_node_key='departmentNo',
rel_type='FROM_Restaurant')
gds_db_load.load_rels(gds, article_df[['articleId', 'productCode']], source_target_labels=('Category', 'Menu'),
source_node_key='articleId',target_node_key='productCode',
rel_type='VARIANT_OF')
gds_db_load.load_rels(gds, transaction_df, source_target_labels=('Customer', 'Category'),
source_node_key='customerId', target_node_key='articleId', rel_key='txId',
rel_type='PURCHASED')
# convert transaction dates
gds.run_cypher('''
MATCH (:Customer)-[r:PURCHASED]->()
SET r.tDat = date(r.tDat)
''')
# convert NaN product descriptions
gds.run_cypher('''
MATCH (n:Menu) WHERE valueType(n.detailDesc) <> "STRING NOT NULL"
SET n.detailDesc = ""
RETURN n
''')
# create combined text property. This will help simplify later with semantic search and RAG
gds.run_cypher("""
MATCH(p:Menu)
SET p.text = '##Menu\n' +
'Name: ' + p.prodName + '\n' +
'Type: ' + p.productTypeName + '\n' +
'Group: ' + p.productGroupName + '\n' +
'MenuType: ' + p.menuGroupName + '\n' +
'Description: ' + p.detailDesc
RETURN count(p) AS propertySetCount
""")
Python
복사
Node Embedding 변환
Node Embedding은 Semantic Embedding과 달리 그래프의 구조를 저차원으로 압축해 노드간의 유사도, 관계를 파악하는 것이 목표입니다.
# create FastRP node embeddings
gds.fastRP.mutate(g, mutateProperty='embedding', embeddingDimension=128, randomSeed=7474, concurrency=4, iterationWeights=[0.0, 1.0, 1.0])
Python
복사
Node Embedding을 통해 다양한 Node간의 관계들을 만들어볼 수 있습니다.
이제, CUSTOMERS_ALSO_LIKE 라는 관계를 만들어 보겠습니다.
# graph projection - project co-purchase graph into analytics workspace
gds.run_cypher('''
MATCH (a1:Category)<-[:PURCHASED]-(:Customer)-[:PURCHASED]->(a2:Category)
WITH gds.graph.project("proj", a1, a2,
{sourceNodeLabels: labels(a1),
targetNodeLabels: labels(a2),
relationshipType: "COPURCHASE"}) AS g
RETURN g.graphName
''')
g = gds.graph.get("proj")
# draw KNN
knn_stats = gds.knn.write(g, nodeProperties=['embedding'], nodeLabels=['Category'],
writeRelationshipType='CUSTOMERS_ALSO_LIKE', writeProperty='score',
sampleRate=1.0, initialSampler='randomWalk', concurrency=1, similarityCutoff=0.75, randomSeed=7474)
# write embeddings back to database to introspect later
gds.graph.writeNodeProperties(g, ['embedding'], ['Category'])
Plain Text
복사
먼저 유저가 Category(낙곱새, 제육볶음, 짜장면 등)별 음식을 구매한 관계를 가져온 뒤 임베딩으로 변환합니다.
이후 두 Category의 유사도가 0.75 이상이면 CUSTOMERS_ALSO_LIKE 라는 관계를 새로 연결할 수 있습니다.
이로써 ‘짬뽕 Category를 주문하는 사람은 짜장면, 탕수육 Category를 주문한다’부터
‘마라탕이라는 Category를 좋아하는 사람은 탕후루도 좋아한다’라는 Insight를 얻을 수도 있습니다.
이 뿐만 아니라 관계기반 검색처럼 Semantic Graph Search도 가능합니다.
GraphDB 관계기반 검색
"피자헛에서 가장 많이 팔린 메뉴 찾기"
MATCH (c:Customer)-[:Purchase]->(m:Menu)-[:From]->(r:Restaurant)
WHERE r.name = '피자헛'
RETURN m.name, COUNT(c) as Count
ORDER BY Count DESC
Plain Text
복사
GraphDB Semantic 검색
“차돌 곱도리탕과 유사한 메뉴 찾기”
MATCH (m1:Menu {name: '차돌 곱도리탕'})
MATCH (m2:Menu)
WHERE m1 <> m2
WITH m1, m2, gds.similarity.cosine(m1.embedding, m2.embedding) AS similarity
WHERE similarity > 0.8
RETURN m2.name, similarity
ORDER BY similarity DESC
Python
복사
“’더울 때 먹기 좋은’ 식당 찾기”
WITH Embedding{'더울 때 먹기 좋은'} AS embedding
MATCH (c:Customer)-[:Purchase]->(m:Menu)-[:From]->(r:Restaurant)
WITH r, COUNT(DISTINCT c) AS customer_count,
AVG(gds.similarity.cosine(m.embedding, embedding)) AS avg_suitability
WHERE avg_suitability > 0.8
RETURN r.name, customer_count, avg_suitability
ORDER BY customer_count DESC, avg_suitability DESC
LIMIT 5
Python
복사
질문 예시
어제 고기 먹었고 저녁에는 삼겹살 먹었어
오늘 점심 안느끼한걸로 추천 부탁 알잘딱
Global Search Response: ## 점심 추천: 가벼운 식사 옵션
어제 고기 요리를 드셨다면, 오늘 점심은 가벼운 메뉴를 선택하는 것이 좋습니다. 여러 가지 추천 옵션이 있습니다.
### 국수 및 면 요리
- **길동우동-신사역점**: 가벼운 우동이나 국수를 제공하는 이 식당은 식사 후 느끼함을 피할 수 있는 좋은 선택입니다 [Data: Reports (79)].
- **팔당냉면-강남본점**: 한국 냉면 전문점으로, 평균 리뷰 점수가 4.9로 고객 만족도가 높습니다. 가벼운 식사를 원하 신다면 이곳이 적합할 수 있습니다 [Data: Reports (166)].
### 샐러드 및 건강식
- **에브리데이샐러드-역삼점**: 다양한 샐러드를 전문으로 하며, 가벼운 점심으로 적합합니다 [Data: Reports (684)].
- **그리너샐러드-논현점**: 건강한 샐러드를 제공하여 점심으로 가볍고 상쾌한 선택이 될 수 있습니다 [Data: Reports (499)].
### 아시아 요리
- **베트남노상식당**: 다양한 가벼운 베트남 요리를 제공하여, 느끼함을 피할 수 있는 좋은 선택입니다 [Data: Reports (691)].
- **좋은초밥-선릉점**: 신선한 초밥을 전문으로 하며, 가벼운 식사로 적합합니다 [Data: Reports (506, 837, 476)].
### 한식 옵션
- **이두부야순두부찌개-강남논현점**: 두부 요리를 전문으로 하며, 고기 요리보다 가벼운 옵션을 제공합니다 [Data: Entities (2247)].
- **찜닭두찜-강남역삼점**: 찜탕 요리를 제공하며, 가벼운 한식으로 적합합니다 [Data: Reports (793)].
이러한 옵션들은 모두 가벼운 식사를 원하시는 분들에게 적합하며, 고객 만족도가 높아 추천할 만합니다. 점심 선택에 도움이 되시길 바랍니다!
Python
복사
Microsoft의 GraphRAG의 경우 이러한 관계들을 미리 Report의 형태로 정리해놓고 Semantic하게 Report를 검색하고 있습니다
결과물
이러한 구조로 구축된 데이터가 충분히 누적되면 "누가", "어떤 종류의 음식을", "어디서", "언제" 주문했는지, 그리고 그에 대한 "만족도"는 어땠는지 등의 관계를 데이터로 보관할 수 있게 됩니다.
Todo List
•
추천되는 메뉴가 직원들의 선호도를 반영하지 못하는 문제 해결
•
결제까지 주문의 전 과정을 자동화
결론
AI를 실제 비즈니스 문제 해결에 적용하는 것은 도전적이지만 매우 가치 있는 과정입니다. 그런 의미에서 이번 아티클에서는 STORM Fooding 서비스에서 Semantic RAG 추천을 통해 AI가 일상적인 업무 프로세스를 어떻게 개선할 수 있는지를 살펴보았습니다. 몇 가지 실패 사례도 있었지만, 초기에 목적했던 가성비와 다양한 메뉴 추천 등은 어느 정도 보장되게 되었습니다. 이후에는 축적된 데이터를 바탕으로 시스템을 지속적으로 개선하려고 합니다.
참고 자료
cleanUrl: "/storm-fooding"
floatFirstTOC: right
title: "STORM Fooding"
description: "STORM Fooding 사례를 통해서 AI 회사에서 AI를 활용하는 방법을 알아봅니다. "
YAML
복사
항상 해당 설정값(YAML)은 페이지 최상단에 있어야 합니다.