서버리스 시맨틱 검색

비용을 한 푼도 들이지 않고 웹사이트나 온라인 앱에 시맨틱 검색 기능을 넣고 싶다면? 이 예시에서는 Rust 기반 AWS Lambda 함수가 임베딩 서비스와 Qdrant를 호출해 무료 프로토타입 검색 엔진을 구성하는 방법을 배워요. 코드와 CLI 명령은 원문 그대로 따라 하시면 됩니다.

출처: 공식문서

웹사이트나 온라인 앱에 시맨틱 검색 기능을 넣고 싶으신가요? 이제 돈을 한 푼도 들이지 않고 할 수 있어요! 이 예시에서 여러분 자신의 비상업적 목적을 위한 무료 프로토타입 검색 엔진을 만드는 방법을 배우게 됩니다.

재료 (Ingredients)

무엇을 만들 것인가

여러분은 임베딩 제공자와 Qdrant 인스턴스를 결합해 깔끔한 시맨틱 검색을 만들고, 작은 Lambda 함수에서 두 서비스를 모두 호출하게 될 거예요.

연결하기 전에 각 재료를 어떻게 다루는지 먼저 살펴볼게요.

Rust와 cargo-lambda

함수가 빠르고, 가볍고, 안전하길 원한다면 Rust를 쓰는 건 당연한 선택이에요. Lambda 함수 안에서 쓸 Rust 코드를 컴파일하기 위해 cargo-lambda 서브커맨드가 만들어졌어요. cargo-lambda는 Rust 코드를 AWS Lambda가 꾸밈없는 provided.al2 런타임에 배포할 수 있는 zip 파일로 묶어 줍니다.

AWS Lambda와 연동하려면 Cargo.toml에 다음 의존성들이 있는 Rust 프로젝트가 필요해요.

[dependencies]
tokio = { version = "1", features = ["macros"] }
lambda_http = { version = "0.8", default-features = false, features = ["apigw_http"] }
lambda_runtime = "0.8"

이것은 Lambda 런타임을 시작하는 진입점과 HTTP 호출을 위한 핸들러를 등록하는 방법으로 구성된 인터페이스를 제공해요. 다음 스니펫을 src/helloworld.rs에 넣으세요.

use lambda_http::{run, service_fn, Body, Error, Request, RequestExt, Response};

/// This is your callback function for responding to requests at your URL
async fn function_handler(_req: Request) -> Result<Response<Body>, Error> {
    Response::from_text("Hello, Lambda!")
}

#[tokio::main]
async fn main() {
    run(service_fn(function_handler)).await
}

클로저를 사용해 함수 핸들러에 다른 인자를 바인딩할 수도 있어요(그러면 service_fn 호출이 service_fn(|req| function_handler(req, ...))가 돼요). 또한 요청에서 파라미터를 추출하려면 Request 메서드(예: query_string_parameters 또는 query_string_parameters_ref)를 사용하면 돼요.

바이너리를 정의하려면 Cargo.toml에 다음을 추가하세요.

[[bin]]
name = "helloworld"
path = "src/helloworld.rs"

AWS 쪽에서는 함수에 사용할 Lambda와 IAM 역할을 설정해야 해요.

함수 이름을 선택하고, "Provide your own bootstrap on Amazon Linux 2"를 선택하세요. 아키텍처로는 arm64를 사용하세요. 또한 함수 URL을 활성화하세요. IAM으로 보호할지 열어둘지 여부는 여러분의 선택이에요. 하지만 열린 엔드포인트는 누구나 접근할 수 있고, 트래픽이 너무 많으면 비용이 발생할 수 있다는 점을 명심하세요.

기본적으로 기본 역할도 생성됩니다. 역할을 찾으려면 Function overview로 이동하세요.

"▸ Function overview" 제목 근처의 "Info" 링크를 클릭하고, 왼쪽의 "Permissions" 탭을 선택하세요.

Execution role 바로 아래에 "Role name"이 있을 거예요. 나중을 위해 적어 두세요.

"Hello, Lambda" 서비스가 동작하는지 테스트하려면 함수를 컴파일하고 업로드하면 됩니다.

$ export LAMBDA_FUNCTION_NAME=hello
$ export LAMBDA_ROLE=<role name from lambda web ui>
$ export LAMBDA_REGION=us-east-1
$ cargo lambda build --release --arm --bin helloworld --output-format zip
  Downloaded libc v0.2.137
# [..] output omitted for brevity
    Finished release [optimized] target(s) in 1m 27s
$ # Delete the old empty definition
$ aws lambda delete-function-url-config --region $LAMBDA_REGION --function-name $LAMBDA_FUNCTION_NAME
$ aws lambda delete-function --region $LAMBDA_REGION --function-name $LAMBDA_FUNCTION_NAME
$ # Upload the function
$ aws lambda create-function --function-name $LAMBDA_FUNCTION_NAME \
    --handler bootstrap \
    --architectures arm64 \
    --zip-file fileb://./target/lambda/helloworld/bootstrap.zip \
    --runtime provided.al2 \
    --region $LAMBDA_REGION \
    --role $LAMBDA_ROLE \
    --tracing-config Mode=Active
$ # Add the function URL
$ aws lambda add-permission \
    --function-name $LAMBDA_FUNCTION_NAME \
    --action lambda:InvokeFunctionUrl \
    --principal "*" \
    --function-url-auth-type "NONE" \
    --region $LAMBDA_REGION \
    --statement-id url
$ # Here for simplicity unauthenticated URL access. Beware!
$ aws lambda create-function-url-config \
    --function-name $LAMBDA_FUNCTION_NAME \
    --region $LAMBDA_REGION \
    --cors "AllowOrigins=*,AllowMethods=*,AllowHeaders=*" \
    --auth-type NONE

이제 _Function Overview_로 가서 Function URL을 클릭하면 다음 같은 화면을 볼 수 있을 거예요.

Hello, Lambda!

짜잔! Rust로 Lambda 함수를 설정했어요. 다음 재료로 넘어갑시다.

임베딩

대부분의 제공자는 API 키와 함께 쓸 수 있는 간단한 https GET 또는 POST 인터페이스를 제공해요. API 키는 인증 헤더에 넣어야 합니다. 비상업적 목적으로 사용한다면, Cohere의 요율 제한 평가판 키는 몇 번의 클릭만으로 얻을 수 있어요. 그들의 환영 페이지에 가서 등록하면 대시보드로 갈 수 있는데, "API keys" 메뉴 항목이 아래 페이지로 안내할 거예요.

거기서 API 키 옆의 ⎘ 기호를 클릭해 클립보드에 복사할 수 있어요. API 키를 코드에 넣지 마세요! 대신 Lambda 환경에서 설정할 수 있는 환경 변수에서 읽어와야 해요. 이렇게 하면 키가 실수로 공개 저장소에 들어가는 것을 피할 수 있어요. 이제 임베딩을 얻는 데 필요한 건 약간의 코드뿐이에요. 먼저 의존성에 reqwest를 추가하고, 더 쉬운 오류 처리를 위해 anyhow도 추가해야 해요.

anyhow = "1.0"
reqwest =  { version = "0.11.18", default-features = false, features = ["json", "rustls-tls"] }
serde = "1.0"

이제 위의 API 키가 주어지면, 임베딩 벡터를 얻기 위한 호출을 만들 수 있어요.

use anyhow::Result;
use serde::Deserialize;
use reqwest::Client;

#[derive(Deserialize)]
struct CohereResponse { outputs: Vec<Vec<f32>> }

pub async fn embed(client: &Client, text: &str, api_key: &str) -> Result<Vec<Vec<f32>>> {
    let CohereResponse { outputs } = client
        .post("https://api.cohere.ai/embed")
        .header("Authorization", &format!("Bearer {api_key}"))
        .header("Content-Type", "application/json")
        .header("Cohere-Version", "2021-11-08")
        .body(format!("{{\"text\":[\"{text}\"],\"model\":\"small\"}}"))
        .send()
        .await?
        .json()
        .await?;
    Ok(outputs)
}

텍스트가 입력 차원을 넘어서면 여러 벡터가 반환될 수 있다는 점에 주의하세요. Cohere의 small 모델은 1024개의 출력 차원을 가져요.

다른 제공자들도 비슷한 인터페이스를 제공해요. 더 자세한 내용은 Embeddings 문서를 참고하세요. 임베딩을 얻는 데 이렇게 적은 코드가 들었다는 게 놀랍지 않나요?

그 김에, 임베딩이 동작하고 벡터가 예상 크기인지 확인하는 작은 테스트를 작성하는 것도 좋은 생각이에요.

#[tokio::test]
async fn check_embedding() {
    // ignore this test if API_KEY isn't set
    let Ok(api_key) = &std::env::var("API_KEY") else { return; }
    let embedding = crate::embed("What is semantic search?", api_key).unwrap()[0];
    // Cohere's `small` model has 1024 output dimensions.
    assert_eq!(1024, embedding.len());
}

API_KEY 환경 변수를 설정해 실행하면 임베딩이 동작하는지 확인할 수 있어요.

Qdrant 검색

이제 임베딩이 생겼으니 Qdrant에 넣을 차례예요. 물론 curl이나 python으로 컬렉션을 설정하고 점들을 업로드할 수도 있지만, 이미 Rust로 임베딩을 얻는 코드가 있으니 qdrant-client를 섞어 Rust에 머무를 수 있어요.

use anyhow::Result;
use qdrant_client::prelude::*;
use qdrant_client::qdrant::{VectorsConfig, VectorParams};
use qdrant_client::qdrant::vectors_config::Config;
use std::collections::HashMap;

fn setup<'i>(
    embed_client: &reqwest::Client,
    embed_api_key: &str,
    qdrant_url: &str,
    api_key: Option<&str>,
    collection_name: &str,
    data: impl Iterator<Item = (&'i str, HashMap<String, Value>)>,
) -> Result<()> {
    let mut config = QdrantClientConfig::from_url(qdrant_url);
    config.api_key = api_key;
    let client = QdrantClient::new(Some(config))?;

    // create the collections
    if !client.has_collection(collection_name).await? {
        client
            .create_collection(&CreateCollection {
                collection_name: collection_name.into(),
                vectors_config: Some(VectorsConfig {
                    config: Some(Config::Params(VectorParams {
                        size: 1024, // output dimensions from above
                        distance: Distance::Cosine as i32,
                        ..Default::default()
                    })),
                }),
                ..Default::default()
            })
            .await?;
    }
    let mut id_counter = 0_u64;
    let points = data.map(|(text, payload)| {
        let id = std::mem::replace(&mut id_counter, *id_counter + 1);
        let vectors = Some(embed(embed_client, text, embed_api_key).unwrap());
        PointStruct { id, vectors, payload }
    }).collect();
    client.upsert_points(collection_name, points, None).await?;
    Ok(())
}

데이터를 효율적으로 필터링하고 싶다면 인덱스도 추가할 수 있어요. 간결함을 위해 여기서는 생략할게요. 또한 이 코드는 청킹(여러 요청으로 나눠 업로드해 타임아웃 오류를 피하는 것)을 구현하지 않아요.

적절한 main 메서드를 추가하면 이 코드를 실행해 점들을 삽입할 수 있어요(아니면 예시의 바이너리를 쓰거나요). qdrant_url에 포트를 포함하는 것을 꼭 잊지 마세요.

이제 점들이 삽입되었으니, 임베딩으로 점들을 검색할 수 있어요.

use anyhow::Result;
use qdrant_client::prelude::*;
pub async fn search(
    text: &str,
    collection_name: String,
    client: &Client,
    api_key: &str,
    qdrant: &QdrantClient,
) -> Result<Vec<ScoredPoint>> {
    Ok(qdrant.search_points(&SearchPoints {
        collection_name,
        limit: 5, // use what fits your use case here
        with_payload: Some(true.into()),
        vector: embed(client, text, api_key)?,
        ..Default::default()
    }).await?.result)
}

SearchPointsfilter: ... 필드를 추가해 필터링할 수도 있어요. 그리고 결과를 더 처리하고 싶을 텐데, 예시 코드가 이미 그걸 처리하니 필요하다면 거기서 시작하면 돼요.

모두 한데 모으기

이제 모든 부품이 갖춰졌으니 조립할 차례예요. 위 스니펫들을 복사해 연결하는 것은 독자의 몫으로 남겨 둘게요.

main 메서드를 시작 부분에서 Client와 한 번 연결하도록 약간 확장하고, 코드에 컴파일하지 않도록 환경에서 API 키를 가져오길 원할 거예요. 그렇게 하려면 Rust 코드에서 std::env::var(_)로 가져오고 AWS 콘솔에서 환경을 설정하면 됩니다.

$ export QDRANT_URI=<qour Qdrant instance URI including port>
$ export QDRANT_API_KEY=<your Qdrant API key>
$ export COHERE_API_KEY=<your Cohere API key>
$ export COLLECTION_NAME=site-cohere
$ aws lambda update-function-configuration \
    --function-name $LAMBDA_FUNCTION_NAME \
    --environment "Variables={QDRANT_URI=$QDRANT_URI,\
        QDRANT_API_KEY=$QDRANT_API_KEY,COHERE_API_KEY=${COHERE_API_KEY},\
        COLLECTION_NAME=${COLLECTION_NAME}"

어떤 경우든 데이터를 삽입하는 하나의 커맨드라인 프로그램과 하나의 Lambda 함수에 도달하게 될 거예요. 전자는 cargo run으로 컬렉션을 설정하면 되고, 후자는 다시 cargo lambda와 AWS 콘솔을 호출하면 됩니다.

$ export LAMBDA_FUNCTION_NAME=search
$ export LAMBDA_REGION=us-east-1
$ cargo lambda build --release --arm --output-format zip
  Downloaded libc v0.2.137
# [..] output omitted for brevity
    Finished release [optimized] target(s) in 1m 27s
$ # Update the function
$ aws lambda update-function-code --function-name $LAMBDA_FUNCTION_NAME \
     --zip-file fileb://./target/lambda/page-search/bootstrap.zip \
     --region $LAMBDA_REGION

토론 (Discussion)

Lambda는 URL이 호출되면 함수를 스핀업하는 방식으로 동작하므로, 실제로 사용되지 않는 한 컴퓨팅 자원을 계속 확보해 둘 필요가 없어요. 이는 첫 호출이 함수 로딩을 위해 약 12초의 지연 시간을 부담하고, 이후 호출은 더 빨리 해결된다는 뜻이에요. 물론 임베딩 제공자와 Qdrant를 호출하는 지연 시간도 있어요. 반면 무료 티어는 비용이 전혀 들지 않으니, 정말로 "돈 주고 산 만큼" 얻는 거죠. 그리고 많은 유스케이스에서 12초 안에 결과가 나오는 건 수용 가능해요.

Rust는 파일 크기와 런타임 모두에서 함수의 오버헤드를 최소화해요. 임베딩 서비스를 사용한다는 건 세부 사항을 신경 쓸 필요가 없다는 뜻이에요. URL, API 키, 임베딩 크기를 아는 것만으로 충분해요. 마지막으로 Lambda와 Qdrant 모두 무료 티어가 있고 임베딩 제공자에서도 무료 크레딧을 받을 수 있으니, 유일한 비용은 설정하는 데 드는 여러분의 시간뿐이에요. 공짜에 반박할 사람이 있을까요?

더 알아보기 (Learn more)