단순하고 어리석음 유지: Postgres로 모든 도구에 전체 텍스트 검색 만들기


소개


처음에는 공구가 있었어요. 오, 그렇게 많은 공구가 있었어요.코드 관리 도구, 프로젝트 관리 도구, 메시지 전달 도구, 경보 도구, 무한한 도구가 있습니다.개발자에게 있어서 이 모든 도구는 임무를 포함한다. 그들이 심사해야 하는 코드, 그들이 보아야 할 버그, 대답해야 할 문제, 분류해야 할 사건이다.약 1년 전, 우리는'만약 도구가 있다면, 우리는 모든 다른 도구에서 우리의 모든 임무를 관리할 수 있다. 그러면 어떻게 해야 하는가'라고 물었고, 그래서 우리는 그것을 구축했다.
고객이 사용Monolist을 시작했을 때, 우리는 그들이 자주 흔히 볼 수 있는 질문을 하는 것을 발견했다.

"I know that Sarah made a comment about the marketing page designs, but was that in the Jira task, or in the spec Google Doc, or on my pull request where I merged the designs, or on Slack?"


물론 이 도구들 중 하나하나는 검색 기능이 있지만, 어디에 있는지 기억하거나, 어떤 문법을 지원하는지 기억하는 것은 큰 고통이다.이 모든 도구와 통합된 Monolist는 API를 통한 검색을 지원하기 때문에 Monolist에 전역 검색 표시줄을 구축했습니다.멋있어!이것은 사용자의 입력을 없애고 Monolist에 이미 존재하는 모든 항목을 검색하며 모든 통합 도구의 모든 API를 호출하여 중복 결과를 통합하고 제거하며 사용자에게 보여 줍니다.
하지만 느려요...이것은 이렇게 느려서 500밀리초에서 5초가 걸려야 어떤 결과도 전달할 수 있다. 이것은 페이지 불러오는 데 있어서 받아들일 수 없는 것이며, 자동 완성란은 말할 것도 없다."sea"를 입력했을 수도 있습니다. 자동으로 완성하면 "s"에 대한 요청만 완성됩니다.더 중요한 것은 너무 복잡해서 디버깅과 개선이 고통스럽다는 것이다.우리는 작업 시스템인 Sidekiq를 사용하여 API 호출을 하고 Redis를 사용하여 결과 흐름을 저장하며 WebSocket을 사용하여 결과 흐름을 사용자에게 비동기적으로 전달합니다.코드는 멋있지만 복잡성이 너무 커서 속도가 느리고 결함이 있어 최종 사용자 체험이 좋지 않다.
이 박문에서 우리는 우리가 두 번째로 검색을 시도한 기초를 소개할 것이다.우리는 첫 번째 시도 때의 지도 원칙을 따르려고 했다. 그것은 바로 오래된 케케묵은 말이다. 키스를 하든지, 간단하게 하든지, 어리석게 하든지.비록 우리는'어리석음'부분(KIS)에 대해 잘 알지 못하지만, 복잡성은 적이다. 너무 일찍 최적화되고 불필요한 추상과 쿨한 기술을 위한 쿨한 기술이 최종 사용자 체험에 해롭다는 것을 한 번 또 한 번 깨달았다.우리는 전체 여정에서 이 원칙을 사용할 것이다. 그러나 우선, 우리가 새로운 체험을 위해 설정한 다른 목표를 보자.

목표

  • 그것은 좀 빨리 필요하다.
  • 자동 완성 체험을 효과적으로 하기 위해서는 결과를 빠르게 렌더링해야 한다.전체 요청, 응답, 렌더링 시간 <100ms가 필요합니다. 이것은 사용자가 여전히'즉시'라고 생각하는 상황에서 가장 많은 시간을 소비할 수 있는 동작입니다.
  • 결과는 관련되고 (대부분) 전면적이어야 한다.
  • 사용자와 대화를 나누어 보니 검색의 폭이 더 넓기를 원하지만 (이들은 모든 도구의 결과가 필요하다) 깊이가 필요하지 않다. (이메일 본문의 용어, 구글 문서의 내용이나 Asana 임무의 설명을 자주 검색하지 않는다.)
  • 물론 KIS도 있다.
  • 우리는 복잡성에 대해 경각심을 유지하기를 바란다. 이것은 우리가 다음 절에 들어가도록 인도할 것이다.

    엘라스틱 검색은요?


    Elastic Search 좋아요.그것은 바로 이 목적을 위해 한 것이다.그것의 유연성은 믿기 어렵다.관리형 솔루션이 있습니다.하지만 ElasticSearch를 사용하는 것이 더 쉬울 뿐만 아니라 Algoliasimplicity != easiness와 같은 위탁 검색 서비스를 사용하는 것도 더 쉬울 것이다.
    우선, 그것은 우리의 구조를 위해 또 다른 시스템 의존성을 도입할 것이다.스스로 위탁 관리하는 세계에서 우리는 일어서서 Elastic Search 집단을 감시하고 유지해야 한다.위탁 관리 세계에서 이것은 더욱 쉬울 것이다. 그러나 우리는 여전히 고장 보호를 구축하고 의미를 재시험하여 데이터를 검색 서비스에 들어가게 해야 한다.
    그러나 우리가 위탁 관리 서비스를 사용하더라도 우리는 여전히 데이터 프라이버시를 고려해야 한다.우리의 사용자는 우리의 민감한 업무 데이터를 신뢰하기 때문에 권한이 부여되지 않은 접근을 방지해야 한다.우리는 또한 account deletion very seriously을 채택했다. 만약 그들이 그들의 계좌를 삭제한다면, 이것은 다른 곳으로 도입될 것이다. 우리는 반드시 정확하게 닦여야 한다.
    만약 서비스가 있다면, 우리는 이미 고도로 사용할 수 있도록 설정되었고, 이미 충분한 모니터링이 도착했으며, 우리의 응용 프로그램과 권한 수여 시스템에 연결되었다...

    데이터베이스만 사용(Postgres)


    다행히도, Postgres 본기는 전체 텍스트 검색을 지원하는데, 우리의 용례에 있어서는 매우 유연하다.(참고: 이 섹션의 코드 예는 실제 Ruby(on Rails) 코드에서 가져온 것이지만 모든 언어에 쉽게 적응할 수 있어야 함)
    우리가 검색하고 있는 내용부터 시작합시다.Monolist 검색은 Google Drive 파일, 연락처 및 다양한 유형의 작업을 포함한 다양한 결과 유형을 지원합니다.그러나 간단하게 말하자면, 우리는 단지 두 개의 역 모델, 연락처와 요청 요청만 있다고 가정한다.
    class Contact < Model
      attribute :user_id
      attribute :email
      attribute :display_name
      attribute :last_sent_at
    end
    
    class PullRequest < Model
      attribute :user_id
      attribute :title
      attribute :description
      attribute :repository_name
      attribute :created_at
    end
    
    이것들은 우리 자신의 모델 중의 일부 예이다.이 필드들은 대부분 말하지 않아도 안다.Contact#last_sent_at는 마지막으로 이 연락처가 우리 사용자의 전자 우편 수신자 또는 발송자로서 비규범화된 시간 스탬프입니다.이제 이 대상들이 검색할 수 있도록 합시다.
    우리는 Postgres에서 두 가지 주요 데이터베이스 기능을 사용하여 전체 텍스트 검색을 할 것이다.우선to_tsvector은 문자열을 해석하고 어소로 표시하는데 기본적으로 단어이다.
    monolist=> SELECT to_tsvector('simple', 'A quick brown fox jumped over a lazy dog');
                                   to_tsvector
    --------------------------------------------------------------------------
     'a':1,7 'brown':3 'dog':9 'fox':4 'jumped':5 'lazy':8 'over':6 'quick':2
    (1 row)
    
    tsvector를 조회하려면 @@ 작업 수와 tsquery 를 사용할 수 있습니다.
    monolist=> SELECT to_tsquery('simple', 'red') @@ to_tsvector('simple', 'A quick brown fox jumped over a lazy dog');
     ?column?
    ----------
     f
    (1 row)
    
    monolist=> SELECT to_tsquery('simple', 'fox') @@ to_tsvector('simple', 'A quick brown fox jumped over a lazy dog');
     ?column?
    ----------
     t
    (1 row)
    
    이 두 함수의 첫 번째 매개 변수는 Postgres 전문 검색 "config"입니다. 이 함수에 대한 더 많은 정보 here 를 읽을 수 있지만, 우리는 잠시 사용하지 않습니다.
    간단하죠?우리는 직접 속성상에서 결과를 조회할 수 있다. 예를 들어 SELECT * FROM contacts WHERE to_tsquery('simple', 'QUERY') @@ to_tsvector('simple', 'display_name').그러나 이것은 단지 하나의 표에만 적용된다.만약 우리가 여러 개의 시계 (pull_요청) 가 있다면, 우리도 그것들을 대외적으로 연결해야 한다.새로운 비규범화 표를 추가합시다.
    class TypeaheadResult < Model
      attribute :user
      attribute :searchable
      attribute :result_type
      attribute :object_id # contact or pull request id
      attribute :object_type # contact or pull request
    end
    
    여기, searchable은 색인 속성 (pull 요청의 제목 또는 연락처의 display_name) 을 가리킨다.
    잠깐만, 다른 책상?우리는 불필요한 데이터를 저장하고 있지 않습니까?KIS: 아직 스토리지 문제가 없습니다. 너무 빨리 최적화하지 마십시오.
    우리가 최적화할 수 있는 것은 초기 검색에 대한 학습을 바탕으로 하는 것이다.이 표들은 상당히 크다.색인 연결도 100ms 분배의 큰 부분을 차지한다.더욱 규범화하고 우리의 전체 기초 모델을 TypeaheadResult에 도입합시다.검색 가능한 열을 텍스트가 아닌 json으로 설정한 다음 사용자를 위한 서비스를 작성할 수 있습니다.
    class PopulateTypeaheadResultsForUser
      def call(user:)
        user.contacts.map do |contact|
          TypeheadResult.create!({
            user: user,
            result_type: :contact,
            searchable: { email: contact.email, display_name: contact.display_name },
          })
        end
        user.pull_requests.map do |pr|
          TypeheadResult.create!({
            user: user,
            result_type: :pr,
            searchable: { title: pr.title, description: pr.description, repo: pr.repo },
          })
        end
      end
    end
    
    잠깐만, 그런데 우리 to\u 벡터 함수는?우리는 JSON에서 그것을 사용할 수 없습니다. 괜찮겠습니까?그럼요, 박사후예요.
    monolist=> SELECT to_tsquery('simple', 'cat') @@ to_tsvector('simple', '{ "name": "fox", "type": "canine" }'::jsonb);
     ?column?
    ----------
     f
    (1 row)
    
    monolist=> SELECT to_tsquery('simple', 'fox') @@ to_tsvector('simple', '{ "name": "fox", "type": "canine" }'::jsonb);
     ?column?
    ----------
     t
    (1 row)
    
    사용자의 결과를 실제로 검색할 수 있는 서비스를 작성해 보겠습니다.
    class GetTypeaheadResultsForUser
      def call(user:, query:)
        TypeaheadResult.find_by_sql([
          <<SQL,
            SELECT * FROM typeahead_results
              WHERE user_id = ? AND to_tsquery('simple', ?) @@ to_tsvector('simple', searchable),
          SQL
          user_id,
          query,
        ])
      end
    end
    
    마지막으로, 우리는 검색 속도를 크게 높이기 위해tsvectors를 계산하고, 이 열에 색인을 추가할 수 있습니다.
    ALTER TABLE typeahead_results ADD COLUMN searchable_vector tsvector;
    UPDATE typeahead_results SET searchable_vector = to_tsvector('simple', searchable);
    CREATE INDEX ON typeahead_results USING GIN (user_id, searchable_vector);
    
    너무 좋아요.우리는 전면적이고 간단한 해결 방안을 가지고 있다.그러나 우리는 여전히 관련성 개념이 없다.사용자가 "a"를 입력하면 첫 번째 결과는 "[email protected]"또는 "[email protected]"일 수 있습니다.
    Postgres는 실제로handyts_rank 함수를 중심으로 관련 해결 방안이 많다.일치하는 색인 필드 (저장소 이름의 관련성은 요청한 제목보다 낮아야 함), 일치하는 길이와 결과의 유형에 따라 관련성 도량을 정의할 수 있습니다...
    아니면, 우리는 KIS를 할 수 있다.사용자와 더 많은 대화를 통해 우리는 (last_interactive_ 과 | updated_at | | created_at) 가 충분한 관련성 에이전트라고 결정합니다.다시 말하면 사용자가 최근에 상호작용한 항목이나 최근에 업데이트되거나 만든 항목을 찾습니다.연락처에 대해서는 last_sent_at,pull 요청에 대해서는 created at를 사용할 수 있습니다.typeahead_results에 새 열을 추가하면 다음과 같이 새 서비스가 제공됩니다.
    class PopulateTypeaheadResultsForUser
      def call(user:)
        user.contacts.map do |contact|
          TypeheadResult.create!({
            user: user,
            result_type: :contact,
            searchable: { email: contact.email, display_name: contact.display_name },
            last_referenced_at: contact.last_sent_at,
          })
        end
        user.pull_requests.map do |pr|
          TypeheadResult.create!({
            user: user,
            result_type: :pr,
            searchable: { title: pr.title, description: pr.description, repo: pr.repo },
            last_referenced_at: pr.created_at,
          })
        end
      end
    end
    
    class GetTypeaheadResultsForUser
      def call(user:, query:)
        TypeaheadResult.find_by_sql([
          <<SQL,
            SELECT * FROM typeahead_results
              WHERE user_id = ? AND to_tsquery('simple', ?) @@ searchable_vector
              ORDER BY last_referenced_at DESC
          SQL
          user_id,
          query,
        ])
      end
    end
    
    질문 하나 더 있습니다.현재 우리는 결과에 대해 정렬을 하고 있으며, 가장 빈번한 결과는 다른 결과를 도태시킬 것이다."a"조회에 대해 "analytics"저장소에 대량의pull 요청이 있을 수 있습니다. 이 요청들은 지난주에 우리에게 이메일로 보낸 "[email protected]"연락처를 잠길 수 있습니다.우리는 어떤 스마트 방안을 실현할 수 있다. 조회 길이에 따라 한 가지 또는 다른 결과 유형에 대해 우선순위 정렬을 하거나...
    KIS도 유형별로 결과를 제한할 수 있습니다.
    class GetTypeaheadResultsForUser
      def call(user:, query:)
        TypeaheadResult.find_by_sql([
          <<SQL,
            SELECT * FROM typeahead_results
              WHERE user_id = ? AND to_tsquery('simple', ?) @@ searchable_vector
              ORDER BY last_referenced_at DESC
          SQL
          user_id,
          query,
        ]).group_by(&:result_type)
          .map { |k, v| [k, v.take(5)]}
          .values
          .flatten
      end
    end
    
    지금 너희들(우리와)은 모두 기다릴 때다.이 상대적으로 간단한 해결 방안은 어떻게 집행됩니까?
    수백만 개의 typeahead\u 결과가 있어도 날아오를 수 있습니다. 연결되지 않은 색인 조회는 약 100ms의 결과를 줍니다.일부 교묘한 React 작업을 통해 전체 요청 -> 응답 -> 렌더링 중 값을 100ms(97ms) 이하로 정확하게 할 수 있습니다.

    저울질과 미래


    분명히 이 해결 방안에는 균형이 존재한다.우선, 비규범화 테이블은 우리가 매우 조심스럽게 업데이트하고 삭제하도록 요구한다.다행히도 Postgres는 JSON 키를 끼워 넣은 인덱스를 지원하기 때문에 연락처를 인덱스할 수 있습니다.예를 들어 전자 메일을 보내고 특정 연락처를 업데이트할 때 모든 결과를 삭제하고 다시 삽입합니다.
    또 다른 큰 균형은 유연성이다.Elastic Search는 분명히 Postgres보다 강하고, 축소된 측면에서 볼 때, 더 좋은 이야기가 있다.검색 인덱스의 증가는 실시간 데이터 양과 상대적으로 독립적일 수 있기 때문에 데이터베이스 수준에서 ES 실례를 확장할 수 있습니다.
    마지막으로, 우리의 검색은 더 빠를 수도 있지만, 더 얕을 수도 있다.e-메일 또는 드라이브 파일 컨텐트를 더 이상 검색하지 않지만 모든 결과 보기 링크가 필요할 때 사용자가 깊이 있는 검색 환경을 더 느리게 만들기 때문에 사용자에게 문제가 있음을 발견하지 못했습니다.
    결론적으로 검색 개선에 대한 반응은 일치하고 적극적이다. 우리는 이러한 평가가 우리가 신속하고 간단하며 유지 가능한 해결 방안을 추구하는 데 필요한 결정이라고 생각한다.
    직접 보고 싶으세요?'제품 규범'을 검색하기 위해 5가지 다른 도구를 사용하는 것이 지겹다고?Monolisthere를 무료로 시용합니다.
    이거 좋아요?우리의 메일 목록에 가입하세요here.

    좋은 웹페이지 즐겨찾기