Natural Language Processing Theory : 검색 증강 생성(Retrieval-Augmented Generation)
검색 증강 생성(Retrieval-Augmented Generation)
대규모 언어 모델(Large Language Model)은 방대한 지식을 가지고 있지만, 그 지식은 가중치 안에 흩어져 저장되어 있습니다. 모델이 무엇을 알고 무엇을 모르는지 겉으로 드러나지 않고, 학습 데이터를 수집한 시점 이후의 정보는 알지 못하며, 회사 내부 문서처럼 학습에 쓰이지 않은 자료는 처음부터 알 방법이 없습니다. 모르는 내용을 물어도 그럴듯한 답을 만들어 내는 환각(Hallucination)은 이러한 구조에서 비롯됩니다.
새로운 지식을 넣으려면 모델을 다시 학습시키는 방법이 떠오르지만, 이는 비용이 크고 느립니다. 문서가 하루에도 여러 번 바뀌는 환경이라면 매번 학습을 반복할 수도 없습니다. 더 근본적으로, 학습으로 넣은 지식도 결국 가중치에 흩어지므로 어느 문서에서 나온 답인지 확인할 수 없다는 문제가 그대로 남습니다.
사람은 모르는 내용을 질문받으면 기억을 쥐어짜지 않고 자료를 찾아본 뒤 답합니다. 검색 증강 생성(Retrieval-Augmented Generation, RAG)은 이 방식을 그대로 따릅니다. 질문이 들어오면 먼저 문서 저장소에서 관련 문서를 검색하고, 검색한 문서를 질문과 함께 프롬프트에 넣어 모델이 그 문서를 근거로 답하게 합니다. 2020년 루이스(Patrick Lewis) 연구진이 이 이름으로 체계화한 방법이며, 오늘날 대규모 언어 모델을 실제 서비스에 적용할 때 가장 널리 쓰이는 구조입니다.
검색 증강 생성은 모델이 아는 것과 찾아본 것을 분리합니다. 언어를 이해하고 문장을 만드는 능력은 모델의 가중치에 맡기고, 사실 정보는 언제든 교체할 수 있는 외부 문서에 맡기는 것입니다. 이 강좌에서는 그 핵심인 검색이 어떻게 동작하는지부터 차례대로 다룹니다.
문장 임베딩(Sentence Embedding)
검색은 질문과 문서가 얼마나 관련 있는지를 계산하는 일입니다. 단어 임베딩(Word Embedding)에서 단어를 벡터로 바꾸고 코사인 유사도로 의미의 가까움을 측정했듯, 질문과 문서도 각각 하나의 벡터로 바꿀 수 있다면 벡터 사이의 유사도로 관련성을 잴 수 있습니다. 문장이나 문단 전체를 하나의 벡터로 나타낸 것을 문장 임베딩(Sentence Embedding)이라 합니다.
가장 단순한 방법은 문장에 포함된 단어 벡터의 평균을 구하는 것입니다. 빠르고 학습이 필요 없지만, 순환 신경망에서 다룬 것처럼 순서를 버리므로 “개가 사람을 물었다”와 “사람이 개를 물었다”가 같은 벡터가 됩니다. 정적 임베딩의 중의성 문제도 그대로 남습니다.
사전 학습 언어 모델의 출력은 이미 문맥을 반영한 벡터이므로 더 나은 출발점이 됩니다. 문장의 모든 토큰 출력 벡터를 평균하거나, BERT의 [CLS] 토큰 출력을 문장 대표로 쓰는 방법이 있습니다. 그러나 이렇게 얻은 벡터를 그대로 코사인 유사도에 쓰면 성능이 기대보다 낮습니다. BERT는 빈칸을 맞히도록 학습되었을 뿐, 두 문장의 벡터가 의미에 따라 가깝거나 멀어지도록 학습된 적이 없기 때문입니다. 2019년 라이머스(Nils Reimers) 연구진은 BERT의 [CLS] 벡터를 그대로 사용한 유사도가 단어 벡터의 평균보다도 낮을 수 있다는 것을 보였습니다.
그래서 라이머스 연구진은 문장 유사도를 직접 목표로 BERT를 추가 학습한 Sentence-BERT를 제안했습니다. 두 문장을 같은 가중치의 인코더에 각각 통과시켜 벡터를 만들고, 의미가 비슷한 문장 쌍은 벡터가 가까워지도록, 다른 문장 쌍은 멀어지도록 학습합니다. 이렇게 같은 신경망을 두 입력에 나란히 적용하는 구조를 샴 네트워크(Siamese Network)라 하며, 이후 대부분의 문장 임베딩 모델이 이 방식을 따릅니다.
대조 학습(Contrastive Learning)
문장 임베딩 모델은 주로 대조 학습(Contrastive Learning)으로 학습합니다. 대조 학습은 정답을 직접 맞히는 대신, 관련 있는 쌍은 가깝게, 관련 없는 쌍은 멀게 벡터를 배치하도록 학습하는 방법입니다. 질문과 그 질문에 답하는 문서, 같은 뜻의 두 문장처럼 관련 있는 쌍을 양성 쌍(Positive Pair), 관련 없는 쌍을 음성 쌍(Negative Pair)이라 합니다.
질문 \(q\)에 대해 양성 문서 \(p^{+}\) 하나와 음성 문서 여러 개가 있을 때, 널리 쓰이는 손실 함수는 다음과 같습니다.
\[\mathcal{L} = -\log \frac{\exp \left( \text{sim}(q, p^{+}) / \tau \right)}{\sum_{j} \exp \left( \text{sim}(q, p_{j}) / \tau \right)}\]분모는 양성 문서를 포함한 모든 후보 문서에 대한 합입니다. 즉, 이 손실은 유사도를 점수로 삼아 여러 후보 중 양성 문서를 고르는 분류 문제에 소프트맥스를 적용한 것과 같습니다. 이 형태를 InfoNCE 손실이라 부르며, 양성 문서의 확률이 1에 가까워질수록 손실이 0에 가까워집니다.
\(\tau\)는 온도(Temperature)로, 유사도의 차이를 얼마나 증폭할지 정합니다. 질문과 세 문서의 코사인 유사도가 0.8, 0.3, 0.1이고 첫 번째가 양성 문서라고 하겠습니다. \(\tau = 1\)이면 소프트맥스 확률이 0.475, 0.288, 0.236으로 차이가 작아 손실이 약 0.743입니다. \(\tau = 0.1\)이면 유사도를 0.1로 나누므로 유사도가 10배로 커지는 효과가 생겨 확률이 0.992, 0.007, 0.001로 크게 벌어지고 손실은 약 0.008이 됩니다. 온도가 낮을수록 작은 유사도 차이에도 민감하게 반응하므로, 문장 임베딩 학습에서는 보통 0.05에서 0.1 정도의 낮은 온도를 사용합니다.
음성 문서를 매번 따로 준비하는 것은 번거롭습니다. 그래서 같은 미니배치에 들어 있는 다른 질문의 양성 문서를 음성 문서로 재사용하는 방법을 널리 사용하며, 이를 배치 내 음성 샘플(In-batch Negatives)이라 합니다. 배치 크기가 256이면 질문 하나당 255개의 음성 문서를 추가 비용 없이 얻을 수 있으므로, 배치가 클수록 학습에 유리합니다. 질문과 관련 있어 보이지만 실제로는 답이 아닌 어려운 음성 샘플(Hard Negative)을 섞으면, 모델이 겉보기의 단어 일치가 아니라 실제 의미를 구분하도록 학습됩니다.
- Tip : 대조 학습은 단어 임베딩의 네거티브 샘플링과 같은 발상에서 출발합니다. 함께 등장한 쌍을 정답으로, 무작위 쌍을 오답으로 구분하도록 학습한다는 점은 같으며, 대상이 단어에서 문장으로 바뀌었을 뿐입니다.
바이 인코더와 크로스 인코더
질문과 문서의 관련성을 계산하는 신경망 구조는 크게 두 가지입니다.
바이 인코더(Bi-Encoder)는 질문과 문서를 각각 따로 인코더에 넣어 벡터를 만들고, 두 벡터의 내적이나 코사인 유사도로 점수를 계산합니다. 앞에서 다룬 Sentence-BERT가 이 구조입니다. 가장 큰 장점은 문서 벡터를 미리 계산해 둘 수 있다는 점입니다. 문서가 백만 개라도 벡터는 한 번만 만들어 저장하고, 질문이 들어오면 질문 하나만 인코딩한 뒤 저장된 벡터와 비교하면 됩니다.
크로스 인코더(Cross-Encoder)는 질문과 문서를 하나로 이어 붙여 인코더에 넣고, 관련성 점수를 직접 출력합니다. 트랜스포머의 셀프 어텐션이 질문의 토큰과 문서의 토큰을 서로 직접 비교할 수 있으므로, 바이 인코더보다 훨씬 정확합니다. 바이 인코더는 문서 전체를 벡터 하나로 압축한 뒤에야 질문과 만나지만, 크로스 인코더는 압축하기 전에 모든 토큰이 서로를 봅니다. 대신 질문이 들어올 때마다 모든 문서와 쌍을 이루어 인코더를 다시 실행해야 하므로, 많은 문서에 적용하기에는 너무 느립니다.
그래서 실제 검색 시스템은 두 구조를 단계적으로 사용합니다. 먼저 빠른 바이 인코더로 수백만 개의 문서 중 상위 수십에서 수백 개의 후보를 고르고, 그 후보에 대해서만 정확한 크로스 인코더로 점수를 다시 매겨 순서를 정합니다. 두 번째 단계를 재순위화(Reranking)라 하며, 적은 비용으로 검색 품질을 크게 높일 수 있습니다.
희소 검색과 밀집 검색
문장 임베딩을 이용한 검색을 밀집 검색(Dense Retrieval)이라 합니다. 질문과 문서를 수백 차원의 밀집 벡터로 바꾸어 비교하므로, “자동차 보험료”로 검색해도 “차량 보험 비용”이라는 표현의 문서를 찾을 수 있습니다. 단어가 일치하지 않아도 의미가 비슷하면 찾아낸다는 것이 가장 큰 장점입니다.
반면 단어 임베딩에서 다룬 TF-IDF처럼 단어의 일치를 세어 점수를 매기는 방식을 희소 검색(Sparse Retrieval)이라 합니다. 어휘 크기만큼의 희소 벡터를 사용하기 때문에 붙은 이름이며, 그 대표가 BM25입니다. BM25는 TF-IDF를 개선한 점수 함수로, 질문의 각 단어 \(t\)에 대해 다음 점수를 계산해 더합니다.
\(f(t, D)\)는 문서 \(D\)에서 단어 \(t\)의 등장 횟수이고, \(\lvert D \rvert\)는 문서의 길이, \(\text{avgdl}\)은 전체 문서의 평균 길이입니다. TF-IDF와 비교해 두 가지가 달라졌습니다.
첫째, 등장 횟수의 효과가 포화됩니다. TF-IDF는 단어가 20번 나오면 1번 나올 때보다 점수가 20배가 되지만, BM25는 그렇지 않습니다. \(k_{1} = 1.2\), \(b = 0.75\)이고 평균 길이의 문서라면, 등장 횟수에 따른 점수는 1번일 때 1.0, 2번일 때 1.375, 5번일 때 1.774, 20번일 때 2.075로 늘어나며, 횟수를 아무리 늘려도 \(k_{1} + 1 = 2.2\)를 넘지 않습니다. 같은 단어를 반복해서 점수를 올리는 문서가 과도하게 유리해지지 않습니다.
둘째, 문서 길이를 보정합니다. 긴 문서는 단지 길다는 이유로 많은 단어를 포함하므로, 같은 등장 횟수라도 긴 문서에서는 의미가 약합니다. 위와 같은 조건에서 길이가 평균의 세 배인 문서라면 1번 등장한 점수가 1.0에서 0.55로 줄어듭니다. \(b\)는 길이 보정의 강도를 정하며, 0이면 보정하지 않고 1이면 길이에 완전히 비례해 보정합니다.
희소 검색과 밀집 검색은 서로의 약점을 보완합니다. 밀집 검색은 의미가 비슷한 표현을 잘 찾지만, 제품 코드 “XR-4520”이나 사람 이름, 오류 메시지처럼 정확히 일치해야 하는 단어에는 약합니다. 학습 때 보지 못한 드문 단어는 벡터 공간에서 의미 있는 위치를 갖지 못하기 때문입니다. BM25는 정확한 단어 일치에 강하고 학습이 필요 없지만, 표현이 다르면 찾지 못합니다.
하이브리드 검색(Hybrid Search)
두 검색 방식을 함께 쓰는 것을 하이브리드 검색(Hybrid Search)이라 합니다. 문제는 두 점수의 척도가 다르다는 것입니다. BM25 점수는 문서와 질문에 따라 0에서 수십까지 변하고, 코사인 유사도는 -1에서 1 사이이므로 그대로 더할 수 없습니다.
간단하고 널리 쓰이는 해결책은 점수 대신 순위를 사용하는 역순위 융합(Reciprocal Rank Fusion, RRF)입니다. 각 검색 결과에서 문서의 순위를 \(r\)이라 할 때, 문서의 최종 점수는 다음과 같습니다.
\(k\)는 상위 순위 사이의 차이를 완화하는 상수로, 보통 60을 사용합니다. 세 문서 A, B, C의 순위가 BM25에서 1, 2, 3위, 밀집 검색에서 3, 1, 2위였다고 하겠습니다. A의 점수는 \(1/61 + 1/63 \approx 0.03227\), B는 \(1/62 + 1/61 \approx 0.03252\), C는 \(1/63 + 1/62 \approx 0.03200\)입니다. 어느 한 방식에서만 1위인 A보다, 두 방식 모두에서 상위권인 B가 최종 1위가 됩니다. 점수의 척도를 맞출 필요가 없고, 한 방식이 이상한 결과를 내도 다른 방식이 보완해 줍니다.
근사 최근접 이웃 검색
밀집 검색은 질문 벡터와 가장 가까운 문서 벡터를 찾는 문제입니다. 가장 확실한 방법은 모든 문서 벡터와 하나씩 유사도를 계산하는 것이지만, 문서가 \(N\)개이고 벡터 차원이 \(d\)라면 질문마다 \(N \times d\)번의 곱셈이 필요합니다. 문서가 수천만 개라면 질문 하나에 수백억 번의 연산이 필요하므로 실시간으로 응답할 수 없습니다.
그래서 가장 가까운 벡터를 정확히 찾는 대신, 아주 높은 확률로 가까운 벡터를 빠르게 찾는 근사 최근접 이웃(Approximate Nearest Neighbor, ANN) 검색을 사용합니다. 정확도를 조금 양보하는 대신 속도를 수백에서 수천 배 높이는 방법입니다.
클러스터 기반(IVF): 문서 벡터를 미리 수천 개의 군집으로 나눠 둡니다. 질문이 들어오면 질문과 가까운 군집 몇 개만 골라 그 안의 벡터만 비교합니다. 정답이 고르지 않은 군집에 있으면 놓치므로, 살펴볼 군집 수를 늘릴수록 정확해지고 느려집니다.그래프 기반(HNSW): 각 벡터를 가까운 벡터들과 간선으로 연결한 그래프를 만들어 둡니다. 검색할 때는 임의의 점에서 출발해 질문에 더 가까운 이웃으로 계속 이동합니다. 2016년 말코프(Yury Malkov) 연구진이 제안한HNSW(Hierarchical Navigable Small World)는 그래프를 여러 층으로 쌓아, 위층에서 먼 거리를 크게 건너뛰고 아래층에서 세밀하게 찾아갑니다. 속도와 정확도가 모두 뛰어나 현재 가장 널리 쓰이는 방법입니다.양자화(Quantization): 벡터를 더 적은 비트로 압축해 저장합니다. 수천만 개의 벡터를 메모리에 올릴 수 있게 해 주며, 위의 방법들과 함께 쓰입니다.
근사 검색의 품질은 정확한 검색으로 찾은 상위 \(k\)개 중 근사 검색이 몇 개를 찾았는지로 측정하며, 이를 재현율(Recall)이라 합니다. 대부분의 응용에서는 재현율 95% 이상을 유지하면서 검색 시간을 수 밀리초로 줄이는 설정을 찾습니다. 이러한 색인과 검색 기능을 제공하는 저장소를 벡터 데이터베이스(Vector Database)라 합니다.
검색 증강 생성의 구성
이제 지금까지의 요소를 모아 검색 증강 생성의 전체 흐름을 살펴보겠습니다. 검색 증강 생성은 문서를 미리 준비해 두는 색인 단계와, 질문이 들어올 때마다 실행되는 질의 단계로 나뉩니다.
색인 단계에서는 다음 과정을 거칩니다.
- 문서 수집 : PDF, 웹 페이지, 사내 위키 등에서 텍스트를 추출합니다. 표나 머리말, 반복되는 바닥글처럼 검색에 방해가 되는 요소를 정리하는 일도 여기에 포함됩니다.
- 분할(Chunking) : 긴 문서를 수백 토큰 정도의 조각으로 나눕니다. 문장 임베딩은 문서 전체를 하나의 벡터로 압축하므로, 문서가 길면 여러 주제가 뒤섞여 어느 질문과도 뚜렷하게 가까워지지 않습니다.
- 임베딩과 저장 : 각 조각을 문장 임베딩 모델로 벡터로 바꾸어 벡터 데이터베이스에 저장합니다. 원문, 문서 제목, 작성일 같은 정보도 함께 저장해 두면 검색 결과를 걸러 내거나 출처를 표시할 때 사용할 수 있습니다.
질의 단계에서는 다음 과정을 거칩니다.
- 검색 : 질문을 같은 임베딩 모델로 벡터로 바꾸고, 벡터 데이터베이스에서 가까운 조각을 찾습니다. 필요하면 BM25 검색과 결합합니다.
- 재순위화 : 찾은 후보를 크로스 인코더로 다시 평가해 가장 관련 있는 몇 개만 남깁니다.
- 프롬프트 구성 : 남은 조각과 질문을 함께 넣어 “다음 문서를 근거로 질문에 답하고, 문서에 없는 내용이면 모른다고 답하라”와 같은 지시를 붙입니다.
- 생성 : 대규모 언어 모델이 문서를 근거로 답을 생성하고, 어느 조각을 참고했는지 출처를 함께 제시합니다.
- Tip : 질문에 사용하는 임베딩 모델과 문서에 사용한 임베딩 모델은 반드시 같아야 합니다. 서로 다른 모델이 만든 벡터는 서로 다른 공간에 놓이므로 유사도를 비교하는 것 자체가 의미가 없습니다. 임베딩 모델을 바꾸면 모든 문서를 다시 임베딩해야 합니다.
분할 전략(Chunking Strategy)
검색 증강 생성의 품질은 모델보다 분할 방식에서 더 크게 갈리는 경우가 많습니다. 조각의 크기에는 분명한 상충 관계가 있습니다.
조각이 너무 작으면 문맥이 잘려 나갑니다. “이 제도는 2025년부터 폐지되었다”라는 문장만 떼어 내면 “이 제도”가 무엇인지 알 수 없으므로, 질문과 관련이 있어도 검색되지 않거나 검색되어도 답의 근거가 되지 못합니다. 조각이 너무 크면 여러 주제가 섞여 벡터가 흐려지고, 관련 없는 내용이 프롬프트를 차지해 모델이 핵심을 놓치기 쉽습니다.
그래서 몇 가지 방법을 함께 사용합니다. 조각 사이에 일정 부분을 겹치게 나누면 경계에서 문장이 잘려도 앞뒤 조각 중 하나에는 온전히 남습니다. 고정된 토큰 수로 자르는 대신 문단이나 제목 같은 구조를 따라 나누면 의미 단위가 보존됩니다. 작은 조각으로 검색한 뒤 실제 프롬프트에는 그 조각이 속한 더 큰 문단을 넣는 방법도 있습니다. 검색에는 정밀한 단위가, 답변에는 충분한 문맥이 유리하기 때문입니다.
검색한 조각을 프롬프트에 넣는 순서도 중요합니다. 2023년 류(Nelson Liu) 연구진은 언어 모델이 긴 입력의 처음과 끝에 있는 정보는 잘 활용하지만, 가운데에 있는 정보는 놓치는 경향이 있다는 것을 보고했습니다. 이를 중간 소실(Lost in the Middle) 현상이라 합니다. 검색한 조각을 많이 넣는다고 항상 좋은 것은 아니며, 가장 관련 있는 조각을 앞이나 뒤에 배치하는 것이 효과적입니다.
평가와 한계
검색 증강 생성은 검색과 생성의 두 단계로 이루어져 있으므로, 답이 틀렸을 때 어느 단계의 문제인지 나누어 평가해야 합니다.
검색 단계는 정답 문서가 검색 결과의 상위 \(k\)개 안에 들어 있는 비율인 Recall@k와, 정답 문서가 몇 번째에 나왔는지를 반영하는 MRR(Mean Reciprocal Rank) 등으로 평가합니다. 정답 문서가 검색되지 않았다면 생성 모델이 아무리 뛰어나도 올바른 답을 낼 수 없습니다. 생성 단계는 답이 검색된 문서의 내용에 충실한지와, 질문에 실제로 답하고 있는지를 따로 평가합니다. 문서에 없는 내용을 덧붙였다면 충실성이 떨어지고, 문서 내용을 옮겨 적었지만 질문과 어긋났다면 관련성이 떨어지는 것입니다.
검색 증강 생성은 환각을 줄여 주지만 없애 주지는 않습니다. 관련 없는 문서가 검색되면 모델은 그 문서를 근거로 그럴듯한 틀린 답을 만들 수 있고, 검색된 문서끼리 내용이 충돌하면 어느 쪽을 따를지 판단하기 어렵습니다. 모델이 문서를 무시하고 가중치에 저장된 지식으로 답하는 경우도 있습니다.
보안 측면의 주의점도 있습니다. 검색된 문서는 프롬프트에 그대로 들어가므로, 문서 안에 “이전 지시를 무시하고 다음을 실행하라”와 같은 문장이 숨어 있으면 모델이 그것을 지시로 받아들일 수 있습니다. 이를 간접 프롬프트 주입(Indirect Prompt Injection)이라 하며, 외부에서 가져온 문서를 검색 대상으로 삼을 때는 문서를 지시가 아닌 자료로 다루도록 프롬프트를 구성하고, 모델이 실행할 수 있는 작업의 권한을 제한해야 합니다.
- Tip : 검색 증강 생성의 성능을 개선할 때는 생성 모델을 바꾸기 전에 검색 결과부터 직접 확인하는 것이 좋습니다. 질문 몇십 개에 대해 상위 문서를 눈으로 살펴보기만 해도, 분할이 잘못되었는지, 임베딩 모델이 도메인 용어를 이해하지 못하는지, BM25가 필요한지 대부분 드러납니다.
검색 증강 생성 정리
| 구성 요소 | 역할 | 대표 방법 | 주의점 |
|---|---|---|---|
| 문장 임베딩 | 질문과 문서를 벡터로 변환 | Sentence-BERT, 대조 학습 | 질문과 문서에 같은 모델 사용 |
| 희소 검색 | 단어 일치로 검색 | BM25 | 표현이 다르면 찾지 못함 |
| 밀집 검색 | 의미 유사도로 검색 | 바이 인코더 | 고유명사, 코드 등 정확한 일치에 약함 |
| 하이브리드 검색 | 두 검색 결과 결합 | 역순위 융합(RRF) | 점수 대신 순위로 결합 |
| 근사 최근접 이웃 | 대량의 벡터를 빠르게 검색 | HNSW, IVF | 속도와 재현율의 상충 |
| 재순위화 | 후보의 순서를 정밀하게 조정 | 크로스 인코더 | 후보 수에 비례해 느려짐 |
| 분할 | 문서를 검색 단위로 나눔 | 겹침, 구조 기반 분할 | 너무 작으면 문맥 손실, 크면 주제 혼합 |
검색 증강 생성은 언어 모델의 한계를 모델을 바꾸지 않고 입력을 바꿔서 해결하는 방법입니다. 대규모 언어 모델에서 다룬 인컨텍스트 학습 능력 덕분에, 모델은 프롬프트에 넣어 준 문서를 그 자리에서 읽고 활용할 수 있습니다. 지식을 가중치에서 문서로 옮기면 정보를 쉽게 갱신할 수 있고, 답의 출처를 확인할 수 있으며, 접근 권한에 따라 검색 대상을 달리할 수도 있습니다.
검색 증강 생성의 내부를 들여다보면 이 강좌에서 다룬 거의 모든 개념이 다시 등장합니다. BM25는 TF-IDF의 후손이고, 문장 임베딩은 단어 임베딩과 사전 학습 언어 모델을 이어받았으며, 크로스 인코더는 트랜스포머의 셀프 어텐션을 그대로 활용합니다. 다음 강좌에서는 이렇게 거대한 모델을 제한된 자원으로 학습하고 실행하는 방법을 다룹니다.
- Writer by : 윤대희
댓글 남기기