Chipa’s text-to-vector service: spacy.io with gRPC API.

Multistage docker build.

Acceptance testing on gitlab ci pipelines.

Going open-source.

Сервис text-to-vector

Несколько слов про word embeddings

Сервис chipa позволяет создать FAQ бота из таблицы вопросов и ответов.

Когда пользователь задает вопрос, бот ищет наиболее похожий по смыслу вопрос из таблицы FAQ.

“Смысл” текста для бота представлен в виде вектора.

Вектор получается из текста при помощи Word Embeddings (NLP модели). Word embedding трансформирует текст в векторное представление так, что векторы 2-х похожих по смыслу текстов будут рядом.

Лучше про Word embeddings рассказано в этой лекции » курса Яндекс по NLP.

Выбор библиотеки NLP: spacy.io

Существуют разные NLP библиотеки для перевода текста в вектор.

До Chipa, я экспериментировал с Deep Pavlov в проекте ivoice. У DeepPavlov есть готовая реализация FAQ бота с использованием fasttext (пост про DeepPavlov autofaq »).

Для Chipa я выбрал библиотеку spacy.io:

  1. Множество готовых моделей для разных языков, с разным требованиям к ресурсам (чем точнее модель, тем требовательнее).
  2. Удобное и простое API для трансформации текста в вектор.

Требуемый от text-to-vector функционал опробован в jupyter-notebook »:

Jupyter notebook

Ноутбук демонстрирует как для 2-х похожих текстов модель генерирует 2 близких вектора (cosine similarity для одинаковых фраз равен 1; чем ближе по смыслу тексты, тем ближе к 1 cosine similarity).

Векторы несвязанных текстов находятся далеко: Jupyter notebook

Проектирование text-to-vector

Text-to-vector участвует в 2-х процессах:

  1. Перевод вопросов из FAQ CSV в векторы при построении индекса, для дальнейшего поиска похожих вопросов.
  2. Перевод нового вопроса от пользователя в вектор.

Text-to-vector проектировался исходя из ограничений:

  1. Перед началом использования модель spacy должны быть загружена в память: nlp = spacy.load (занимает несколько секунд) => модель необходимо загрузить до начала использования (serverless решение не получается)
  2. Модель NLP для 1 языка занимает несколько сотен мегабайт => 1 загруженная модель должна делиться на множество задач трансляции текста в вектор на одном языке.
  3. Chipa является SaaS-решением: параллельно может работать множество ботов на разных языках, нет потолка возможной нагрузки (scalable)
    • SaaS => обеспечить максимальную скорость ответа бота (основная ценность продукта)
    • Cost effective SaaS => требуется обеспечить минимальные требования системы к ресурсам (уменьшить стоимость работы системы)
    • Scalable => обсепечить возможность масштабировать систему горизонтально.

Ограничения продиктовали следующую архитектуру:

  1. Для каждой NLP модели запускается свой сервер (в случае масштабирования несколько)
  2. При запуске, spacy NLP модель загружается в память, что позволяет после запуска транслировать текст в вектор без задержки на загрузку, практически моментально.
  3. 1 сервис text-to-vector обслуживает все задачи трансляции текста в вектор на 1 языке (задачи связанные с построением индекса и новые вопросы из чатов с ботами).
  4. Система горизонтально масштабируется запуском дополнительных text-to-vector сервисов.

Role in system

gRPC сервер для spacy NLP

В spacy.io из коробки доступен HTTP сервер на fastapi.

В chipa межсервисное взаимодействие реализовано на gRPC.

Причины данного выбора заслуживают отдельной статьи, и выгода от данного выбора, против использования готового сервера от spacy требует отдельной проверки.

Если бы я делал прототип, я бы нераздумывая взял готовый сервер spacy. Однако Chipa это пет-проект: возможность удовлетворить потребность в перфекционазме и поэкспериментировать.

Рассуждения о выборе между http и grpc можно послушать в этой лекции »

Реализация сервиса с использованием gRPC тривиальна:

Описание протокола в proto:

syntax = "proto3";

// Accepts text and transforms it to vector using spacy.io nlp
service TextToVector {
  rpc Transform(Text) returns (Vector);
}

// Text to transform to vector
message Text {
  string text = 1;
}

// Vector in spacy NLP model space
message Vector {
  repeated double vector = 1;
}

Реализация сервера на python (source »):

class TextToVector(text_to_vector_pb2_grpc.TextToVectorServicer):
    async def Transform(self, request: text_to_vector_pb2.Text, context):
        logging.debug(f'Transforming: {request.text}')
        vector = nlp(request.text).vector

        return text_to_vector_pb2.Vector(vector=vector)


async def serve() -> None:
    server = grpc.aio.server()
    text_to_vector_pb2_grpc.add_TextToVectorServicer_to_server(TextToVector(), server)
    listen_addr = f'[::]:{settings.port}'
    server.add_insecure_port(listen_addr)
    logging.info("Starting server on %s", listen_addr)
    await server.start()
    await server.wait_for_termination()


if __name__ == '__main__':
    logging.basicConfig(level=logging.INFO)
    settings = Settings()
    start_time = time.time()
    nlp = spacy.load(settings.nlp_model)
    logging.info("nlp initialized in %s sec" % (time.time() - start_time))
    asyncio.run(serve())

Poetry, сборка в Docker образ

Для packaging и dependency management system в python-сервисах Chipa используется Poetry.

Чтобы не тащить сборщик в production образ, сборка реализована как multistage build.

Stage 1: это временная фаза, в рамках которой устанавливается poetry (который мы не хотим в prod образе), который занимается подготовкой зависимостей requirements.txt.

FROM python:3.10-slim AS requirements

...

# install poetry
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && curl -sSL https://install.python-poetry.org | python3 -

WORKDIR /src
COPY pyproject.toml ./
RUN poetry export -f requirements.txt --no-ansi --without dev --without-hashes -o /src/requirements.txt

Stage 2: Данный stage запускается с чистого образа python:3.10-slim и в него копируются requirements.txt, полученные на stage 1. Poetry на данном этапе уже не устанавливается!

FROM python:3.10-slim

WORKDIR /app

# Install requirements
COPY --from=requirements /src/requirements.txt .
RUN pip install --upgrade pip
RUN pip install --no-cache-dir --user -r requirements.txt

COPY text_to_vector_pb2.py ./text_to_vector_pb2.py
COPY text_to_vector_pb2_grpc.py ./text_to_vector_pb2_grpc.py

pip устанавливает requirements из requirements.txt - готов базовый образ (source для stage1,stage2 »)

Финальный образ для языка содержит модель spacy nlp (source »)

FROM registry.gitlab.com/archertech-chipa/spacy-grpc-text-to-vector/base

# models: https://spacy.io/usage/models
ARG NLP_MODEL
ENV NLP_MODEL=$NLP_MODEL
RUN echo "Downloading nlp model=$NLP_MODEL"
RUN python -m spacy download $NLP_MODEL

COPY service.py ./service.py

EXPOSE 50051

CMD [ "python", "service.py" ]

Acceptance testing on gitlab CI

Одной из мантр проекта является максимальная автоматизация (я его делаю один по выходным) и уверенность что все работает без меня (хочу сделать и забыть): человеческий QA в проекте непозволителен => требуются автотесты.

Критерий примеки text-to-vector сервиса:

  1. Text-to-vector запущен в виде docker контейнера для каждого доступного языка (на данный момент я включил русский, английский и польский).
  2. При трансляции 2-х похожих текстов, клиент получит 2 близких (cosine similarity) вектора.

Данный acceptance test полностью автоматизирован с использованием gitlab ci.

Tester script

Написан тестовый клиент, который транслирует 2 текста в векторы с использованием сервиса, и вычисляет cosine similarity полученных векторов (source »):

async def run(dimension: int, min_cos_sim: float, text1: str, text2: str) -> None:
    async with grpc.aio.insecure_channel(settings.text_to_vector_service) as channel:
        stub = text_to_vector_pb2_grpc.TextToVectorStub(channel)
        print(f"Transforming text1: {text1}")
        vec1: text_to_vector_pb2.Vector = await stub.Transform(text_to_vector_pb2.Text(text=text1))

        print(f"Transforming text2: {text2}")
        vec2: text_to_vector_pb2.Vector = await stub.Transform(text_to_vector_pb2.Text(text=text2))

    assert len(vec1.vector) == dimension
    assert len(vec2.vector) == dimension
    print(f"Correct vector dimension = {dimension}")

    # https://stackoverflow.com/a/1401828/827704
    v1 = np.array(vec1.vector)
    v2 = np.array(vec2.vector)
    cos_sim = dot(v1, v2) / (norm(v1) * norm(v2))
    print(f"cos_sim = {cos_sim}")
    assert cos_sim >= min_cos_sim
    print(f"Cosine similarity >= minimum threshold {min_cos_sim}")

Тестер проверяет:

  1. Сервер отвечает по gprc
  2. Векторы ожидаемой размерности
  3. Векторы достаточно близки

Ожидаемая близость текстов на разных языках получена в jupyter ноутбуке.

Gitlab CI

Gitlab CI позволяет сделать следующее:

  1. В рамках pipeline job запустить сервисы docker для тестового окружения (в данном тесте будет всего один, но можно запускать несколько, и они могут общаться между собой)
  2. Обратиться к данному образу из скрипта job

Acceptance test для английского образа, например, выглядит так source »:

test-image-en:
  image: $CI_REGISTRY_IMAGE/base:latest
  stage: acceptance
  variables:
    LANG: en
  services:
    # сервис на базе собранного ранее docker-образа, его тестируем
    - name: $CI_REGISTRY_IMAGE/$LANG:latest
      alias: text-to-vec-en
  script:
    - |
      # скрипт вызывает API сервиса
      GRPC_DNS_RESOLVER=native TEXT_TO_VECTOR_SERVICE=text-to-vec-$LANG:50051 \
          python tester-client.py dim=300 min_similarity=0.71 "how old are you?" \
          "what is your age?"
  • В разделе services запускается образ text-to-vec с английской моделью, собранный ранее в рамках данного pipeline. Alias = text-to-vec-en делает сервис доступным по домену text-to-vec-en.
  • В разделе script запускается tester, который проводит тест, используя запущенный сервис для трансформации текста в вектор (настройка EXT_TO_VECTOR_SERVICE=text-to-vec-$LANG:50051).

Теперь после каждого коммита, я могу быть уверенным, что всё работает как ожидается, без ручных проверок:

gitlab ci job » Acceptance test passed

Если же что-то сломается, то pipeline не пройдет после коммита. Для примера, я увеличил ожидаемую близость векторов до 0.9 в английском тесте.

test-image-en:
  image: $CI_REGISTRY_IMAGE/base:latest
  stage: acceptance
  variables:
    LANG: en
  services:
    - name: $CI_REGISTRY_IMAGE/$LANG:latest
      alias: text-to-vec-en
  script:
    - |
      GRPC_DNS_RESOLVER=native TEXT_TO_VECTOR_SERVICE=text-to-vec-$LANG:50051 \
          python tester-client.py dim=300 min_similarity=0.90 "how old are you?" \
          "what is your age?"

Pipeline завершился ошибкой: Acceptance test failed

Исходный код

Сервис text-to-vector полностью доступен на gitlab: https://gitlab.com/archertech-chipa/spacy-grpc-text-to-vector