#
🤖 벡터 검색 에이전트: 허깅페이스 허브를 백엔드로 하는 지능형 검색 엔진

참조: Martin Elstner 작성자: 안정

검색 엔진은 크게 키워드 검색과 벡터 검색으로 나눌 수 있습니다. 키워드 검색과 달리, 벡터 검색을 사용할 때는 두 가지를 고려해야 합니다.

1. 적합한 임베딩 모델로 데이터셋과 쿼리를 임베딩하는 작업
2. 임베딩 데이터를 처리할 수 있는 DB

하지만 임베딩 값을 기반으로 한 벡터 검색만으로는 ‘사용자가 원하는 답변’을 보장하기 어렵습니다.

그래서 검색 과정에서 에이전트가 자율적으로 판단하고 최적화한다면, 사용자 의도에 더 가까운 검색 결과를 얻을 수 있습니다.

###
Agentic 접근 방식의 차별점

기존 벡터 검색 워크플로우

    데이터 ➡ 데이터 임베딩(고정 모델) ➡ 인덱스 생성 ➡ 사용자 질의 ➡ 유사도 검색 ➡ 답변

똑똑한 Agentic 방식

    사용자 질의 분석(검색 전략 세움) ➡ 최적 임베딩 모델 선택 ➡ 데이터 임베딩 ➡ 인덱스 생성 ➡ 유사도 검색 ➡ 검색결과 바탕으로 답변 정제

###
DuckDB?

허깅페이스의 데이터셋은 파켓(parquet) 파일을 기반으로 동작하는데, 이때 빠른 인메모리 데이터베이스 시스템인 DuckDB를 사용하면 이 파일들과 상호작용할 수 있습니다. 또한 DuckDB의 기능 중 하나는 벡터 유사도 검색으로, 인덱스 유무에 관계없이 사용할 수 있습니다.

이번 노트북에서는 단일 Agent에 여러가지 도구를 주어 수행하는 간단한 Agentic 벡터 검색 엔진을 만들어 보겠습니다.

필요한 의존성을 설치합니다 :

    # 본 예제 파일은 Python 3.10 이상 버전에서만 실행할 수 있습니다.
    !pip install -U smolagents datasets sentence-transformers duckdb openai

HuggingFace의 추론 API를 사용하기 위해 로그인합니다 :

    from huggingface_hub import notebook_login

    notebook_login()

    from smolagents import tool
    from datasets import Dataset
    import os

    # 도구 사용을 위해 OPENAI 키를 발급 받아야합니다.
    os.environ["OPENAI_API_KEY"] = "YOUR KEY"

#
도구 정의

정의할 도구는 아래와 같습니다.

- 임베딩 생성 도구
- 인덱스 생성 도구
- 유사도 검색 도구
- 답변 생성 도구

###
도구1 : 임베딩 생성

일반적으로, 임베딩 작업에서는 작은 배치 사이즈로 데이터를 청킹하지만 여기서는 단순히 데이터셋을 임베딩으로 변환하는 과정만 수행하겠습니다.

    @tool
    def create_embeddings(
        dataset: Dataset,
        model_id: str,
        column_name: str,
        ) -> Dataset:
      """
        주어진 데이터셋에 대해 임베딩을 생성합니다.

        Args:
            dataset: 임베딩을 생성할 대상 데이터셋
            model_id: 임베딩에 사용할 모델
            column_name: 임베딩할 열 이름

        Returns:
            임베딩이 추가된 데이터셋
      """
      from sentence_transformers import SentenceTransformer

      model = SentenceTransformer(model_id)

      def embed_batch(batch):
        embeddings = model.encode(batch[column_name], convert_to_numpy=True)
        batch["embeddings"] = embeddings.tolist()
        return batch

      dataset = dataset.map(embed_batch, batched=True)

      return dataset

##
DuckDB로 벡터 검색 수행하기

`duckdb`를 사용하여 데이터셋에서 벡터 검색을 수행할 수 있습니다. 인덱스는 사용하거나 사용하지 않을 수 있습니다. 인덱스를 활용하지 않고 검색하는 것은 더 느리지만 더 정확하고, 인덱스를 활용하여 검색하는 것은 더 빠르지만 덜 정확합니다.

###
인덱스를 활용하지 않고 검색하기

인덱스를 활용하지 않고 검색하는 것은 느린 작업지만 일반적으로 약 10만 행까지의 작은 데이터셋에서는 충분히 빠르게 동작합니다. 하지만 이번 노트북에서는 DuckDB의 인덱스를 활용해서 검색해보겠습니다.

###
인덱스를 활용하여 검색하기

이 접근법은 데이터셋의 로컬 복사본을 생성하고 이를 사용하여 인덱스를 생성합니다. 약간의 오버헤드가 있지만 한번 인덱스를 생성한 후에는 검색 속도가 개선될 것입니다.

###
도구2 : DuckDB 인덱스 만들기

    @tool
    def create_db_index(
        dataset_with_embeddings: Dataset,  # 임베딩이 포함된 데이터셋
        table_name: str,
        embedding_column: str = "embeddings"
    ) -> None:
        """
        임베딩이 포함된 데이터셋에 대해 DuckDB 인덱스를 생성합니다.

        Args:
            dataset_with_embeddings: 이미 임베딩이 포함된 데이터셋
            table_name: 생성할 테이블 이름
            embedding_column: 임베딩 열 이름

        Returns:
            None
        """
        import duckdb

        # VSS 확장 설치 및 로드
        duckdb.sql("INSTALL vss; LOAD vss;")
        duckdb.sql(f"DROP TABLE IF EXISTS {table_name};")

        # 데이터셋을 pandas DataFrame으로 변환
        df = dataset_with_embeddings.to_pandas()

        # DuckDB에 DataFrame 등록
        duckdb.register(f"{table_name}_temp", df)

        # 모델에서 임베딩 차원 가져오기
        embedding_dim = len(df[embedding_column].iloc[0])
        # embedding_dim = model.get_sentence_embedding_dimension()

        # 테이블 생성 (임베딩을 FLOAT 배열로 변환)
        duckdb.sql(f"""
            CREATE TABLE {table_name} AS
            SELECT *, {embedding_column}::FLOAT[{embedding_dim}] AS {embedding_column}_float
            FROM {table_name}_temp;
        """)

        # HNSW 인덱스 생성
        duckdb.sql(f"""
            CREATE INDEX idx_{embedding_column} ON {table_name}
            USING HNSW ({embedding_column}_float) WITH (metric = 'cosine');
        """)

        # 임시 테이블 정리
        duckdb.sql(f"DROP VIEW IF EXISTS {table_name}_temp;")

이 도구를 통해 인덱스를 사용하여 벡터 검색을 수행할 수 있으며, 결과는 즉시 반환됩니다.

###
도구3 : 벡터 검색 수행하기

    @tool
    def similarity_search_with_duckdb_index(
        query: str,
        table_name: str,
        model_id: str,
        k: int = 5,
        embedding_column: str = "embeddings"
    )-> dict:
        """
        DuckDB 인덱스를 이용해 벡터 검색을 수행합니다.

        Args:
            query: 검색할 쿼리 문자열
            model_id: 임베딩에 사용할 모델
            k: 반환할 결과 수
            table_name: 검색할 테이블 이름
            embedding_column: 임베딩 컬럼 이름

        Returns:
            dict: 검색 결과
        """
        from sentence_transformers import SentenceTransformer
        import duckdb

        model = SentenceTransformer(model_id)
        embedding = model.encode(query).tolist()
        return duckdb.sql(
            query=f"""
            SELECT *, array_cosine_distance({embedding_column}_float, {embedding}::FLOAT[{model.get_sentence_embedding_dimension()}]) as distance
            FROM {table_name}
            ORDER BY distance
            LIMIT {k};
        """
        ).to_df()

무거운 벡터 검색 엔진을 따로 배포할 필요 없고, 저장소는 허브에서 처리됩니다.

###
도구4 : 답변 생성 도구

유사도 검색 결과 청크를 기반으로, LLM이 사용자가 원할 만한 답변을 생성합니다.

    @tool
    def generate_answer(chunks: list, query: str) -> str:
        """
        쿼리를 기반으로 주어진 텍스트 청크 목록에서 답변을 생성합니다.

        Args:
            chunks: 답변 생성에 사용할 텍스트 청크 목록
            query: 답변할 쿼리 문자열

        Returns:
            str: 생성된 답변
        """
        import openai   # OPENAI 키 발급이 필요합니다.

        context = "\n\n".join(chunks)
        prompt = f"Context:\n{context}\n\nQuestion: {query}\nAnswer:"
        response = openai.ChatCompletion.create(
            model="gpt-4",
            messages=[{"role": "user", "content": prompt}]
        )
        return response["choices"][0]["message"]["content"]

도구를 정의하였으니, 데이터셋을 로드하고 에이전트를 동작해보겠습니다.

사용한 데이터셋은 huggingface-KREW/KoCultre-Descriptions으로, 한국어 밈(meme)에 대한 데이터입니다.

    from datasets import load_dataset

    dataset = load_dataset("huggingface-KREW/KoCultre-Descriptions")

에이전트를 정의합니다. 사용할 모델은 Qwen/Qwen2.5-Coder-32B-Instruct 입니다.

    from smolagents import CodeAgent, InferenceClientModel

    model = InferenceClientModel(
        "Qwen/Qwen2.5-Coder-32B-Instruct",
        provider="together",
        max_tokens=2048
    )

    tools = [
        create_embeddings,                    # 임베딩 생성 도구
        create_db_index,                      # 인덱스 생성 도구
        similarity_search_with_duckdb_index,  # 유사도 검색 도구
        generate_answer                       # 답변 생성 도구
    ]

    agent = CodeAgent(
        model=model,
        tools=tools,
        verbosity_level=2,
        max_steps=15
    )

에이전트가 자율적으로 임베딩 모델을 택할 수 있도록 프롬프트를 구성하겠습니다.

    def agentic_prompt(query: str):
      return f"""
          당신은 지능형 검색 전문가입니다. {query}:

          검색을 위해 먼저 쿼리와 데이터셋을 분석하고, 분석 결과에 따라 가장 적합한 임베딩 모델을 선택하세요
            - sentence-transformers/all-MiniLM-L6-v2 (fast, lightweight, good for simple queries)
            - sentence-transformers/all-mpnet-base-v2 (balanced performance)
            - intfloat/e5-large-v2 (high quality for complex tasks)
            - minishlab/potion-base-8M (very efficient)
          """

데이터셋에 대한 설명과, 검색어를 입력하고 검색엔진 에이전트를 실행합니다.

    result = agent.run(
        agentic_prompt(query="아이돌 관련 밈 알려주세요"),
        additional_args={"dataset": dataset,
                         "dataset_description": "한국어 밈(meme)에 대한 데이터셋으로, meme과(title), meme의 뜻(content)을 알려준다.",
                         "column_name": "content"}
    )

    >>> print(f"Final result: {result}")

    Final result: 엄마가되는 밈은 최근 SNS와 온라인 커뮤니티에서 유행하는 표현으로, 주로 누군가가 매우 귀엽거나 사랑스러워서 모성애적 감정을 느낄 때 사용됩니다. 이 표현은 아이돌 팬덤 문화에서 자주 사용되며, '엄마가 돼'가 올바른 표기이지만 의도적인 맞춤법 파괴를 통해 밈으로서의 독특한 성격을 갖게 되었습니다.

    Final result: 엄마가되는 밈은 최근 SNS와 온라인 커뮤니티에서 유행하는 표현으로, 주로 누군가가 매우 귀엽거나 사랑스러워서 모성애적 감정을 느낄 때 사용됩니다. 이 표현은 아이돌 팬덤 문화에서 자주 사용되며, '엄마가 돼'가 올바른 표기이지만 의도적인 맞춤법 파괴를 통해 밈으로서의 독특한 성격을 갖게 되었습니다.

###
Conclusion

단순히 검색 결과만을 가져오는 것이 아니라, 검색결과를 바탕으로 쿼리에 따른 답변을 정제해서 보여주었습니다.✌🏻

지금까지 간단한 에이전트 시스템을 만들어보았습니다. 여기에 품질 평가, 분석 등을 추가한다면 진정한 Agentic 검색 엔진을 구현할 수 있습니다.

Update on GitHub