Google EmbeddingGemma + Ollama + sqlite-vec으로 간단한 로컬 RAG 구현하기

OpenAI의 gpt-oss 모델에 이어, RAG (검색 증강 생성)에서 매우 중요한 역할을 하는 임베딩 모델을 Google에서 새롭게 오픈 소스로 공개헀습니다. EmbeddingGemma라는 이름의 임베딩 모델로, 고성능 하드웨어 없이도 RAG를 구현할 수 있으면서, 한국어, 중국어, 일본어를 포함한 수많은 언어를 지원하도록 개발된 모델이어서 의미가 있습니다.

그래서 재빨리 File-based App과 Semantic Kernel용으로 개발된 sqlite-vec 확장 모듈을 붙여서 프로토타입 코드를 만들어봤는데, 잘 작동하는 것 같네요! :smiley:

참고로, | 기호나 query: 같은 라벨은 엄격한 의미의 문법은 아니고 EmbeddingGemma가 동작하는 방식에 맞춰주기 위한 부분이라고 합니다. (https://ai.google.dev/gemma/docs/embeddinggemma/inference-embeddinggemma-with-sentence-transformers 참조)

#:property PublishAot=False
#:package Microsoft.SemanticKernel.Connectors.SqliteVec@1.65.0-preview
#:package OllamaSharp@5.4.4

using System.Text.Encodings.Web;
using System.Text.Json;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.SqliteVec;
using OllamaSharp;
using OllamaSharp.Models;

using var cts = new CancellationTokenSource();
var cancellationToken = cts.Token;

// NOTE:
// | 기호를 써서 문자열 내부에서 시맨틱 필드를 나누는 것, 그리고 task:/query:/title:/text: 같은 라벨을 따로 쓰는 이유는 EmbeddingGemma의 동작 방식에 기인합니다.
// 상세한 내용 보기: https://ai.google.dev/gemma/docs/embeddinggemma/inference-embeddinggemma-with-sentence-transformers

var hotels = new[]
{
    new Hotel {
        HotelId = 1,
        HotelName = "코엑스 비즈니스 호텔",
        Description =
        "title: none | text: 삼성동 코엑스몰과 실내로 연결된 비즈니스 특화 호텔. 24시간 셀프 체크인, 조용한 업무 라운지, 소규모 회의실 제공. 지하철 2·9호선 접근성 우수."
    },
    new Hotel {
        HotelId = 2,
        HotelName = "강남 사우나 & 스파 호텔",
        Description =
        "title: none | text: 남성 사우나와 건식 사우나, 온수풀을 갖춘 도심형 웰니스 호텔. 심야 항공편 손님을 위한 레이트 체크인/아웃 제공. 코엑스까지 차량 10분."
    },
    new Hotel {
        HotelId = 3,
        HotelName = "봉은사 전망 호텔",
        Description =
        "title: none | text: 봉은사와 무역센터가 보이는 객실. 조용한 야경과 아침 산책 코스가 장점. 전 객실 업무용 데스크와 고속 와이파이."
    },
    new Hotel {
        HotelId = 4,
        HotelName = "테헤란로 이코노미 호텔",
        Description =
        "title: none | text: 합리적 가격의 깔끔한 비즈니스 객실. 간단 조식과 세탁실 제공. 선릉역/삼성역 도보권으로 출퇴근 수요에 적합."
    },
    new Hotel {
        HotelId = 5,
        HotelName = "무역센터 컨퍼런스 호텔",
        Description =
        "title: none | text: 대형 컨퍼런스룸과 소규모 미팅룸 다수 보유. 전층 방음 설계, 팀 단위 숙박에 최적화. 코엑스 컨벤션과 보행 연결."
    },
    new Hotel {
        HotelId = 6,
        HotelName = "도심 휴식 부티크",
        Description =
        "title: none | text: 소규모지만 조용한 객실과 편안한 침구. 인근에 카페와 레스토랑 밀집. 심야 체크인 가능."
	},
};

var queries = new[]
{
    "task: search result | query: 코엑스와 실내로 연결된 비즈니스 호텔 추천",
    "task: search result | query: 남성 사우나 시설이 좋은 강남 호텔",
    "task: search result | query: 조용한 업무 공간과 회의실이 있는 호텔",
    "task: search result | query: 합리적인 가격의 비즈니스 호텔 (선릉역/삼성역 도보)",
	"task: search result | query: 팀 단위로 회의와 숙박을 동시에 하기 좋은 곳",
};

var embeddingModelName = "embeddinggemma:300m";
using var client = new OllamaApiClient("http://localhost:11434");
await foreach (var eachState in client.PullModelAsync(embeddingModelName, cancellationToken))
{
    if (eachState == null) continue;
	await Console.Out.WriteLineAsync(eachState.Status.AsMemory(), cancellationToken).ConfigureAwait(false);
}

var cs = "Data Source=skhotels;Mode=Memory;Cache=Shared";
using var keepAlive = new SqliteConnection(cs);
keepAlive.Open(); // 이 커넥션이 살아있는 동안만 DB가 유지됩니다.

using var collection = new SqliteCollection<string, Hotel>(cs, "skhotels");
await collection.EnsureCollectionDeletedAsync(cancellationToken).ConfigureAwait(false);
await collection.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false);

// 1) 호텔 설명 → 문서 임베딩
foreach (var h in hotels)
{
	var resp = await client.EmbedAsync(new EmbedRequest
	{
		Model = embeddingModelName,
		Input = [h.Description!],   // 접두사 포함된 Description
	}, cancellationToken);

	var vec = L2Normalize(resp.Embeddings[0]);     // float[] 768D 가정
    h.DescriptionEmbedding = new ReadOnlyMemory<float>(vec);

    // 예: SK SqliteVectorStore에 업서트 (메서드명은 사용 SDK에 맞춰 조정)
    await collection.UpsertAsync(h, cancellationToken).ConfigureAwait(false); 
}

// 2) 쿼리 → 쿼리 임베딩 (검색 시)
foreach (var userQuery in queries)
{
    await Console.Out.WriteLineAsync($"Q: {userQuery}".AsMemory(), cancellationToken).ConfigureAwait(false);
    var qResp = await client.EmbedAsync(new EmbedRequest
    {
        Model = embeddingModelName,
        Input = [userQuery],
    }, cancellationToken).ConfigureAwait(false);
    var qVec = L2Normalize(qResp.Embeddings[0]);

    await foreach (var eachResult in collection.SearchAsync(qVec, 3, cancellationToken: cancellationToken))
    {
        var serializedJson = JsonSerializer.Serialize(eachResult, new JsonSerializerOptions() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, });
        await Console.Out.WriteLineAsync($">> {serializedJson}".AsMemory(), cancellationToken).ConfigureAwait(false);
    }
}

static float[] L2Normalize(float[] v)
{
	double n2 = 0; for (int i = 0; i < v.Length; i++) n2 += v[i] * v[i];
	var n = (float)Math.Sqrt(n2);
	if (n > 0f) for (int i = 0; i < v.Length; i++) v[i] /= n;
	return v;
}

public sealed class Hotel
{
	[VectorStoreKey]
	public long HotelId { get; set; }

	[VectorStoreData(StorageName = "hotel_name")]
	public string? HotelName { get; set; }

	[VectorStoreData(StorageName = "hotel_description")]
	public string? Description { get; set; }

	[VectorStoreVector(Dimensions: 768, DistanceFunction = DistanceFunction.CosineDistance)]
	public ReadOnlyMemory<float>? DescriptionEmbedding { get; set; }
}

8개의 좋아요

우오오오!!! 잘 됩니다! 속도도 빠르고 훌륭하네요!

6개의 좋아요

더 재미있게 실행하는 방법은 아래와 같이 실행하는 겁니다. :smiley:

curl -L -o - https://shorturl.at/BVlhj | dotnet run -
4개의 좋아요