Curso de busca híbrida no PostgreSQL — busca por palavras-chave (BM25) + recuperação vetorial
Autoria: freeCodeCamp.org · Assistir no YouTube
Vídeo de autoria do canal indicado, não da IANIA; todos os direitos pertencem ao criador. Transcrição, resumo e tradução gerados automaticamente; o conteúdo no idioma original é preservado na versão em idioma original.
Idioma original: inglês
Vídeo original: PostgreSQL Hybrid Search Course – Keyword (BM25) + Vector Retrieval · Canal: freeCodeCamp.org · 1:00:01 · Assistir no YouTube
Tópicos: Aplicações empresariais de IA, Dados, bancos de dados e embeddings, RAG, busca e recuperação de informação
Neste curso, Beau Carnes constrói um aplicativo de busca na Wikipédia usando PostgreSQL, combinando busca por palavras-chave com BM25 e busca semântica por vetores. O projeto carrega artigos da Wikipédia em inglês simples, cria índices e compara os resultados dos métodos nativo, BM25, semântico e híbrido. A busca híbrida combina as listas com Reciprocal Rank Fusion (RRF), sem comparar diretamente as pontuações de BM25 e dos vetores. O curso também aborda a implementação de uma interface web, a inserção de artigos e os limites dos testes de desempenho apresentados.
Pontos-chave
Seção intitulada “Pontos-chave”- A busca por palavras-chave usa PG text search para calcular pontuações BM25 e recuperar artigos em ordem de relevância.
- BM25 considera a frequência dos termos, o tamanho dos documentos e estatísticas do conjunto de documentos.
- A busca semântica compara embeddings de consultas e artigos; como usa busca aproximada, pode deixar de encontrar alguns vizinhos mais próximos.
- O projeto cobre cerca de 242 mil artigos com busca por palavras-chave e aproximadamente 25 mil com embeddings.
- Reciprocal Rank Fusion combina as posições dos resultados de cada método, sem somar pontuações incompatíveis.
- A busca híbrida usa até 20 candidatos de cada método e seleciona os 10 melhores resultados combinados.
- O aplicativo mantém artigos e índices no PostgreSQL e grava o texto e o vetor juntos em uma transação.
- A qualidade e o desempenho devem ser avaliados com as mesmas consultas e com os dados e requisitos de cada aplicação.
Capítulos
Seção intitulada “Capítulos”| Início | Capítulo | Resumo |
|---|---|---|
| 00:00 | Demonstração e objetivos do curso | O instrutor apresenta a busca por palavras-chave, a busca semântica e a busca híbrida no aplicativo da Wikipédia. |
| 04:06 | Busca no PostgreSQL e fundamentos do BM25 | O curso explica a diferença entre correspondência e ranking e apresenta as vantagens e os limites do BM25. |
| 11:37 | Configuração do banco e do ambiente | O instrutor cria um serviço no Tiger Cloud, configura a conexão e verifica as extensões necessárias. |
| 15:22 | Conjunto de dados e importação dos artigos | O curso apresenta a origem dos dados da Wikipédia, a estrutura da tabela e o carregamento dos artigos no PostgreSQL. |
| 20:34 | Busca textual nativa do PostgreSQL | O instrutor testa consultas com operadores AND e OR, examina resultados e interpreta planos de execução. |
| 27:23 | Índice e consultas BM25 | O curso cria um índice BM25, ordena os resultados pela pontuação e verifica o uso do índice no plano. |
| 34:09 | Busca por título e corpo e prévias destacadas | O aplicativo passa a indexar título e corpo, gera prévias com termos destacados e apresenta consultas com restrições. |
| 37:15 | Embeddings e busca semântica | O instrutor gera embeddings para um subconjunto dos artigos, armazena os vetores e executa buscas semânticas. |
| 41:25 | Índice vetorial e testes semânticos | O curso cria o índice vetorial e testa consultas sobre animais bioluminescentes e a missão Apollo 13. |
| 43:29 | Combinação de resultados com RRF | O instrutor explica como Reciprocal Rank Fusion combina as posições dos resultados BM25 e vetoriais. |
| 49:43 | Pesos da busca híbrida e avaliação | O curso ajusta o peso da busca semântica e compara resultados, observando a diferença de cobertura entre os métodos. |
| 52:18 | API e interface do aplicativo | O instrutor percorre a API FastAPI, inicia o aplicativo e testa os modos de busca na interface. |
| 55:25 | Inserção de artigos e transações | O curso insere um artigo com texto e vetor e verifica sua disponibilidade nos três modos de busca. |
| 57:26 | Benchmark, limitações e próximos passos | O instrutor contextualiza um benchmark, ressalta os limites da comparação e sugere melhorias e experimentos. |
| 59:30 | Recursos e experimentos futuros | O curso indica arquivos de referência e ideias para ampliar a cobertura dos embeddings ou dividir artigos em trechos. |
Citações
Seção intitulada “Citações”Para este projeto, manteremos os índices de busca junto aos artigos. — 04:38
A pontuação não é uma porcentagem. — 29:28
As pontuações BM25 e a distância vetorial não são diretamente comparáveis. — 43:29
Um artigo sem vetor não pode receber pontos semânticos. — 51:14
Conceitos e entidades
Seção intitulada “Conceitos e entidades”- PostgreSQL (ferramenta): Banco de dados relacional usado para armazenar os artigos, os índices de busca e os embeddings do projeto.
- PG text search (ferramenta): Extensão do PostgreSQL usada no curso para busca textual e ranking BM25.
- BM25 (conceito): Função de ranking que combina frequência dos termos, tamanho dos documentos e estatísticas do conjunto.
- PG vector (ferramenta): Extensão do PostgreSQL usada para armazenar e consultar vetores.
- PG vector scale (ferramenta): Extensão usada no projeto para criar um índice de busca vetorial.
- Reciprocal Rank Fusion (conceito): Método que combina listas de resultados com base nas posições dos itens em cada lista.
- TigerData (empresa): Empresa apresentada no curso como responsável por TimescaleDB e PG text search e apoiadora do vídeo.
- Tiger Cloud (ferramenta): Serviço de nuvem usado no curso para criar e configurar o banco PostgreSQL.
- FastAPI (ferramenta): Framework usado para criar a API que recebe consultas e retorna resultados ao aplicativo.
- Beau Carnes (pessoa): Instrutor que apresenta o curso e constrói o aplicativo de busca.
Tradução completa (pt-BR)
Seção intitulada “Tradução completa (pt-BR)”00:00 Neste vídeo, você vai aprender a adicionar busca a aplicativos criando um app de busca na Wikipédia com PostgreSQL, busca textual do PG e Tiger data. Veja o que acontece quando pesquiso animais que produzem a própria luz. Isso descreve uma ideia, mas talvez não use as palavras do artigo de que preciso. Aqui estão os resultados por palavras-chave. Agora, vamos mudar para a busca semântica, que compara o significado da consulta com os embeddings dos artigos. E aqui está a busca híbrida, que combina as duas listas
00:31 . Nossos artigos, índice de palavras-chave e vetores ficam todos em um único banco de dados PostgreSQL. Estamos usando uma extensão chamada PG Text Search para o ranking BM25 e PG Vector com PG Vector Scale para a busca semântica. Sou Beau Carnes e, neste curso, vamos construir este aplicativo juntos. Vamos carregar artigos reais da Wikipédia, inspecionar o SQL e conectar tudo a uma pequena interface web. Você vai aprender o que cada método de busca
01:02 faz bem, onde tem dificuldades e como decidir se combiná-los realmente melhora seus resultados. Nosso projeto se chama Wiki Search. A busca por palavras-chave abrangerá cerca de 242.000 artigos da Wikipédia em inglês simples. Para manter o trabalho de geração de embeddings gerenciável, a busca semântica abrangerá aproximadamente 25.000 desses artigos. A interface mostra as duas contagens porque essa diferença importa quando comparamos os resultados.
01:32 Você vai precisar de conhecimentos básicos de SQL, saber o suficiente de Python para executar um script e ter PostgreSQL com as extensões que estamos usando. Vou usar o Tiger Cloud, e o repo também explica a opção local. Todo o código está incluído. Você pode pausar em um capítulo, executar o arquivo SQL correspondente e inspecionar o que mudou. Vamos dedicar nosso tempo a entender as decisões que fazem o aplicativo funcionar. Nosso projeto se chama Wiki Search. A busca por palavras-chave abrangerá cerca de 242.000
02:02 artigos da Wikipédia em inglês simples. Para manter o trabalho de geração de embeddings gerenciável, a busca semântica abrangerá aproximadamente 25.000 desses artigos. A interface mostra as duas contagens porque essa diferença importa quando comparamos os resultados. Você pode usar o link do código na descrição para acompanhar. Vai precisar de conhecimentos básicos de SQL, saber o suficiente de Python para executar um script e ter PostgreSQL com as extensões que estamos usando. Vou usar o Tiger Cloud, e o repo também explica a opção local. Todo o código está incluído, então você pode
02:33 pausar em um capítulo, executar o arquivo SQL correspondente e inspecionar o que mudou. Vamos dedicar nosso tempo a entender as decisões que fazem o aplicativo funcionar. Certo, aqui está nossa URL e, em seguida, {barra} health. E você pode ver que ela vai nos fornecer algumas informações. Estamos usando como alvo o PG Text Search versão 1.5.1. Vamos verificar a versão instalada antes de começar, para que os exemplos de Boolean e de frases mais adiante no curso correspondam ao banco de dados que você está realmente executando.
03:03 Agora, este curso é possível graças a uma bolsa da TigerData, a empresa por trás do TimescaleDB e do PG Text Search. Então, obrigado à TigerData por apoiar o freeCodeCamp. E eles estão oferecendo US$ 1.000 em créditos do Tiger Cloud por 30 dias por meio do link na descrição. Vamos usar o Tiger Cloud durante a demonstração porque as extensões do banco de dados estão disponíveis lá, e o serviço cuida da configuração do servidor. Ainda vamos habilitar as extensões no nosso próprio banco de dados, o que faremos juntos
03:36 daqui a pouco. Mas tudo isso também pode ser feito localmente, sem usar o Tiger Cloud. O PG Text Search é open source sob a licença do PostgreSQL, então você também pode executá-lo no seu próprio servidor PostgreSQL compatível. O código de busca que você está aprendendo é SQL comum mais operadores da extensão. Você pode abrir o link da amostra na descrição e, mais adiante, vamos criar o serviço, verificar a versão e conectar nosso projeto.
04:06 Vamos começar pelo problema que estamos resolvendo. Imagine um aplicativo que já armazena seus artigos no Postgres. No começo, a caixa de busca talvez use um filtro de texto simples. Com o tempo, os usuários querem um ranking melhor, e a equipe adiciona um serviço de busca separado. Um artigo muda no Postgres, e um processo ou fluxo de alterações o copia para o sistema de busca. Então, o aplicativo lê os resultados da busca desse segundo sistema. Agora precisamos decidir o que acontece quando a cópia atrasa, quando um evento
04:38 falha ou quando reconstruímos o índice de busca. Um usuário pode salvar uma edição e ainda ver a versão antiga na busca. Neste projeto, vamos manter os índices de busca junto dos artigos. Isso nos dá um único limite de transação para os dados e o índice, e nos permite combinar a busca com as consultas relacionais que já conhecemos. Há duas tarefas diferentes dentro de uma solicitação de busca. A correspondência decide quais documentos são elegíveis. O ranking decide
05:09 quais documentos elegíveis devem aparecer primeiro. Suponha que eu pesquise “erupção vulcânica cinzas nuvem aviação”. Um filtro que exige todos os termos verifica se cada termo restante da consulta aparece no documento. Um ranking de relevância avalia o quanto o documento parece útil para essa consulta. Uma página sobre interrupção de voos pode ser útil mesmo que nunca use a palavra “aviação”. A busca textual integrada ao PostgreSQL oferece suporte a consultas booleanas e frases.
05:43 Vamos usá-la como nossa referência inicial e ver o que o BM25 acrescenta. O PG Text Search adiciona pontuação BM25 que leva em conta o corpus e um índice projetado para recuperação eficiente por ranking. Mais adiante, vamos combinar esse ranking com regras explícitas de correspondência quando nosso aplicativo precisar delas. A função ts_rank integrada ao PostgreSQL considera os termos correspondentes dentro de um documento. Ela não tem as frequências de documentos da coleção inteira que o BM25 usa.
06:16 Essas estatísticas ajudam a distinguir um termo que aparece em quase todos os lugares de outro que identifica um tema muito mais específico. O PostgreSQL oferece opções de normalização por tamanho, e sua função de ranking já lida com termos repetidos com efeitos cada vez menores. O BM25 nos dá um modelo ajustável que combina a frequência do termo com o tamanho médio dos documentos e as frequências de documentos na coleção inteira. Esse modelo combinado é o que estamos adicionando aqui.
06:47 Um índice gin pode encontrar documentos correspondentes, mas não fornece a ordenação por pontuação BM25 que vamos usar. Para classificar muitas correspondências, talvez seja necessário calcular as pontuações e ordená-las antes de retornar 10 linhas. Vamos inspecionar o plano real, em vez de atribuir um tempo de execução universal à busca nativa. A quantidade de correspondências, o tamanho dos documentos, a memória e o hardware afetam essa medição. BM25 é uma função de ranking.
07:19 Primeiro, um termo da consulta traz mais informação quando aparece em menos documentos. Segundo, ver um termo repetidas vezes ajuda, mas cada menção adicional deve contribuir menos. Terceiro, o tamanho do documento importa porque um artigo muito longo tem mais oportunidades de mencionar praticamente qualquer assunto. Essas ideias funcionam em conjunto. BM25 não se limita a contar quantas palavras da consulta encontra. Ele usa estatísticas da coleção e uma contribuição controlada de cada termo
07:52 correspondente. Você vai encontrar BM25 em sistemas de busca como Lucene e Elasticsearch. Compartilhar uma família de ranking não torna dois produtos de busca idênticos, no entanto. Tokenização, recursos, configuração e implementação continuam sendo importantes. No PG Text Search, escolhemos uma configuração de busca textual e criamos um índice BM25. O índice fornece as estatísticas necessárias para a pontuação. Vamos detalhar as três
08:22 ideias com exemplos e, depois, vamos testá-las em artigos reais. Imagine uma coleção de mil documentos. 900 mencionam database, mas apenas 10 mencionam pooling. Na consulta database pooling, pooling restringe muito mais a coleção. Essas contagens são ilustrativas, para que possamos entender bem a ideia. A frequência inversa de documentos, ou IDF, captura essa diferença. Um termo que aparece em menos documentos contribui com mais evidência de relevância.
08:54 BM25 combina essa evidência com a frequência do termo e o tamanho do documento. A raridade ajuda, mas não é a pontuação inteira. A coleção também importa. Neste subconjunto, pooling aparece em oito de 20 documentos especializados. Era raro na coleção completa, mas aqui é comum. Índices parciais e partições podem ter estatísticas diferentes. Uma pontuação BM25 pertence à sua consulta e ao seu índice, então compare rankings dentro desse
09:27 contexto. >> [fungada] >> Agora imagine dois artigos que falam sobre pooling. Um menciona o termo uma vez. Outro o menciona 10 vezes. A repetição pode indicar que o assunto é central, então faz sentido que ela contribua. Mas a 50ª menção não deveria ter o mesmo efeito que a primeira. O componente de frequência do termo no BM25 se estabiliza. O parâmetro chamado K1 controla essa saturação. O padrão no PG Text Search é 1.2.
10:01 Pense nisso como uma forma de controlar a rapidez com que menções adicionais deixam de fazer muita diferença. Em geral, um valor maior permite que a frequência continue contribuindo em uma faixa mais ampla. Também precisamos levar em conta o tamanho do documento. Essa é a função de B, cujo valor padrão é 0.75. Ele ajusta a contribuição da frequência usando o tamanho do documento em relação à média da coleção. Vamos manter os valores padrão até termos evidências de que alterá-los ajuda nas nossas consultas.
10:33 Com B igual a zero, esse ajuste por tamanho fica desativado. Aumentar B o torna mais forte. Isso não significa que documentos curtos sempre vencem. Os termos correspondentes e sua frequência ainda importam, e um documento longo pode perfeitamente ser o melhor resultado. Agora, como evitamos pontuar tudo só para mostrar 10 artigos? O PG Text Search usa um índice invertido, que associa termos aos documentos que os contêm. A otimização block max wand consegue
11:05 pular blocos de postings que não podem melhorar os resultados atuais no topo. É por isso que a combinação de uma pontuação ordenada e um limite é importante. Estamos pedindo ao índice um pequeno conjunto de candidatos fortes. A extensão armazena seu índice em páginas do PostgreSQL e participa dos mecanismos de armazenamento e recuperação do PostgreSQL. Internamente, ela gerencia novos postings e segmentos em disco. Não precisamos verificar se nosso plano de consulta usa o caminho de acesso pretendido. Então, vamos criar o
11:37 banco de dados. Então, acesse esta URL. Temos a URL na descrição, e você pode se cadastrar. Isso vai dar a você US$ 1.000 em créditos de nuvem por 30 dias. Agora, podemos prosseguir e escolher nosso serviço depois de fazer login. Há sempre uma versão gratuita, mas, como temos vários créditos, vou escolher uma maior. Vou escolher a versão com uma GPU. Teremos 4 GB de RAM, e então
12:08 podemos escolher nossa região. Geralmente, basta escolher a região mais próxima de você. Depois, clique em continuar. Agora, vamos selecionar que estamos usando nosso ambiente de desenvolvimento e continuar. Em seguida, você pode dar um nome ao serviço ou simplesmente usar o nome padrão. O que você vai querer fazer é copiar a string de conexão. Depois, crie uma cópia do arquivo .env.example e adicione sua string de conexão
12:40 diretamente na URL do banco de dados. Em seguida, você pode usar um token aleatório, e o dispositivo do modelo é CPU. E lembre-se: o nome deve ser .env. Agora, abra o arquivo de código do link do repo. E o README vai mostrar tudo o que precisamos fazer para configurar isso. Haverá comandos de início rápido, e usaremos o console para configurar o banco de dados e, depois, executaremos o código Python localmente. Agora, o que
13:12 vou fazer é voltar ao painel aqui. Vou acessar meu projeto e, na parte inferior, temos nosso editor SQL. É aqui que podemos testar o banco de dados. Vou executar apenas select current_date. Certo, temos a data aqui. Então, tudo está configurado corretamente. Agora, vou inserir outro código aqui. Ele vai verificar as versões de tudo o que temos. Certo, agora executei esse código no editor SQL
13:44 e ele inspeciona as versões disponíveis do PG text search. Nosso objetivo é a versão 1.5.1, que vemos estar marcada como true. E ela deve estar disponível no TigerGraph. Agora, podemos continuar executando comandos no editor SQL, mas também há um método mais fácil. Vou mostrar para você. Basicamente, basta seguir o guia de início rápido aqui. Primeiro, execute todos esses comandos no terminal para criar um ambiente virtual e instalar o
14:16 requirements.txt. Talvez você já tenha feito isso: criar seu arquivo .env. Depois, você vai executar cada um destes comandos aqui. Pode executá-los diretamente no terminal. Eu já os executei. Leva um tempinho. Alguns podem demorar um pouco, mas vou explicar o que eles fazem para você entender como isso funciona. Esses comandos vão configurar seu banco de dados. Primeiro, o arquivo de extensões. Ele habilita o PG text search e o vector
14:49 scale. A opção cascade também instala a dependência vector, caso ela ainda não esteja habilitada. A consulta de versão deve mostrar PG text search, vector e vector scale. O script também verifica se o PG text search é recente o bastante para este curso. Se um serviço existente estiver em uma versão mais antiga, siga as instruções de atualização do serviço da Tiger. Talvez seja necessário fazer a atualização de manutenção do serviço para que a versão mais recente da extensão fique disponível. Depois, atualize a extensão no banco de dados conforme descrito no README.
15:22 Não continue se a verificação de versão falhar. Corrigir isso agora é muito mais fácil do que diagnosticar um exemplo de consulta de frase em uma instalação antiga. Já falamos sobre como criar e ativar um ambiente virtual Python e instalar os requisitos. Mas, só para você saber, as bibliotecas de embedding podem demorar para instalar. Neste projeto, estamos usando a Wikipédia em inglês simples, do conjunto de dados da Wikimedia no Hugging Face. O sha- O snapshot se chama
15:52 2023-11-01.simple. Ele contém 241.787 artigos. O arquivo parquet baixado tem cerca de 157 MB. Esse é o tamanho do download compactado, não o tamanho final do nosso banco de dados e dos índices. Cada registro inclui um ID, uma URL, um título e o texto do artigo. Então, se eu fizer uma busca aqui, posso clicar em um desses links, e ele vai abrir na Wikipédia em inglês simples.
16:23 E o cartão do banco de dados… O cartão do conjunto de dados lista a Creative Commons Attribution-ShareAlike 3.0 e a GNU Free Documentation License. Nosso README e nossa interface incluem atribuições e links para as licenças, além de informar que encurtamos e destacamos trechos. Agora vou abrir este arquivo load.py, que vamos executar em breve para carregar o banco de dados. O carregador fixa uma revisão específica do conjunto de dados e verifica a soma de verificação do arquivo baixado.
16:56 Isso ajuda a manter uma execução futura deste curso vinculada ao mesmo material de origem, em vez de usar o conteúdo que estiver em um branch sujeito a alterações. Também vamos criar um schema com este arquivo schema.sql. A tabela começa com cinco colunas: ID, URL, título, corpo e contagem de palavras. O ID da Wikipédia será nossa chave primária. O corpo armazena o texto, e a contagem de palavras nos dá um campo simples para filtros e para escolher um subconjunto para embedding.
17:27 Então, de volta ao load.py, podemos ver que ele baixa o arquivo parquet pelo Hugging Face, lê os dados em lotes e envia as linhas ao PostgreSQL com copy. Copiamos os dados para uma tabela temporária de staging e, depois, inserimos os dados em articles. Se um ID já estiver presente, o carregador deixa aquela linha como está. Isso torna seguro executar novamente o carregamento inicial, sem substituir acidentalmente o trabalho que adicionamos depois. A contagem de palavras aqui é uma estimativa baseada em espaços em branco. Ela não é igual à contagem de tokens usada pela configuração de pesquisa de texto
17:57 ou pelo tokenizador de embedding. É útil para um filtro aproximado, mas não devemos confundir essas medidas diferentes. Então, basicamente, agora vamos executar este comando no terminal: load.py. Na primeira execução, ele baixa o conjunto de dados, verifica-o e copia os registros. O tempo vai depender da sua rede, dos recursos do banco de dados e da latência da conexão. O carregador informa quantos registros leu, quantos inseriu e o total de linhas que há agora na tabela. Se você executá-lo novamente, a contagem de registros inseridos
18:29 deve ser zero para os registros já presentes. Mas ainda não criamos os índices de busca. Por isso, na importação inicial, carregar os dados primeiro e criar os índices depois evita ter que atualizá-los a cada inserção. Antes de passar ao próximo comando, podemos conferir. Vou abrir este arquivo recording/17 counts, e ele vai me dar o comando para tentar executar. Desta vez, vou mostrar a execução dos outros comandos que eu já havia executado
19:00 antes. Vou executar python db.py e, em seguida, passar o comando SQL. Assim, podemos ver quantos artigos… Então, em um carregamento completo feito do zero, esperamos que o conjunto de dados tenha 241.787 linhas. A contagem exclusiva da fonte permanece igual quando adicionamos artigos de demonstração originais com IDs negativos. Agora, vou executar outro teste. Então,
19:32 basicamente, estamos ordenando pela contagem de palavras em ordem decrescente, vendo os 10 maiores artigos e abrindo alguns registros para conferir se títulos, corpos e URLs foram parar nas colunas esperadas. Se o corpo estiver vazio ou a importação carregar apenas uma amostra, um índice não vai corrigir isso. Então, estamos apenas verificando se os dados estão sendo importados corretamente. Uma coisa interessante é que o load.py executa analyze
20:04 depois da importação. Isso atualiza as estatísticas da tabela usadas pelo planejador. Essas estatísticas do planejador são diferentes das estatísticas do corpus dentro do índice BM25. Também vamos verificar se os títulos planejados para a demonstração existem no snapshot. O snapshot final deve mostrar resultados reais desses dados, não resultados inventados para a introdução. Então, aqui está o arquivo text queries.txt. Vamos fazer algumas consultas de teste.
20:34 Então, aqui estão alguns itens diferentes que vamos testar, e vamos usar as mesmas consultas ao longo do curso. “Apollo 13” busca um assunto específico, enquanto “animais que produzem a própria luz” descreve uma ideia sem nomeá-la. Ao experimentarmos cada método de busca, vamos verificar se os resultados respondem à pergunta e quanto tempo a busca leva. Por enquanto, estamos apenas escolhendo nossas buscas de teste. Então, vamos começar com a consulta sobre vulcões. Vamos executar
21:06 este SQL aqui e usar esta consulta: “erupção vulcânica, nuvem de cinzas, aviação”. Isso é antes de adicionar a busca de texto do PG. Vamos experimentar as ferramentas de busca já incorporadas ao PostgreSQL. Isso nos dá um ponto de partida para comparação. Primeiro, vamos pesquisar o corpo dos artigos e, depois, adicionar os títulos. Então, antes de executar essa consulta, vamos executar isto aqui. Basicamente, esta execução executou este comando. Ah,
21:38 e ele vai criar um índice para ajudar o PostgreSQL a encontrar artigos que contenham nossos termos de busca. Um índice é uma estrutura, uma estrutura extra que o banco de dados mantém para encontrar dados com mais eficiência. A função to_tsvector converte o texto do artigo em uma representação pesquisável. A configuração English aplica regras do idioma, como reduzir formas relacionadas de palavras
22:08 e ignorar palavras comuns como “the”. Usar gin aqui seleciona um tipo de índice que pode localizar esses termos pesquisáveis. Então, executamos o comando e o índice foi criado. Agora, finalmente, vamos executar esta consulta aqui. Vamos lê-la na ordem que importa para entender a busca. Plain to_tsquery, que é esta seção aqui, converte nosso texto de busca em
22:39 regras de correspondência. A linha where usa o operador @@ para verificar se um artigo corresponde à busca. Para os artigos correspondentes, TS rank calcula uma pontuação de relevância. Order by rank descending coloca as pontuações mais altas primeiro, e limit 10 retorna os 10 primeiros resultados. Certo, vou executar agora. Podemos ver que este termo de busca não retornou nenhuma correspondência. Isso acontece porque ele exige que todos os termos da busca apareçam no corpo do artigo. Vamos ver como essa regra de correspondência funciona e, depois, flexibilizá-la para aceitar qualquer um dos
23:11 termos. Agora, vamos experimentar esta opção de corresponder a todas as palavras ou a qualquer palavra. Quando executo este comando, vemos que os sinais de “e” significam que todos os termos são obrigatórios. Nossa frase de exemplo não contém todos eles, então a correspondência é falsa. As barras verticais mudam a regra para aceitar qualquer termo. Basicamente, esta frase não contém esses termos. Portanto, ela não passa no teste de todos os termos. Agora, vamos experimentar essa regra de “ou” nos nossos artigos.
23:44 Então, reparem nas barras verticais entre os termos de busca. Elas significam “ou”. Vamos executar estes comandos SQL agora. Certo, finalmente estamos obtendo alguns resultados porque estamos usando “ou” aqui. Então, flexibilizamos o requisito de correspondência. Mais artigos podem ser incluídos, e TS rank ainda determina a ordem deles. Isso demonstra uma escolha que podemos fazer com a busca integrada ao PostgreSQL.
24:16 Então, mantenham estas duas perguntas separadas. Quais artigos podem aparecer nos resultados e quais desses artigos aparecem primeiro? “E” e “ou” alteram a primeira pergunta. A função de classificação cuida da segunda. Na próxima verificação, voltaremos à consulta original com “e”. Mais tarde, BM25 vai mudar a forma como pesquisamos e classificamos, então uma lista de resultados diferente não será causada apenas pela pontuação. Agora, temos um novo SQL que vou executar aqui. Ele nos mostra o plano, o plano da consulta. Basicamente, mostra como o SQL encontrou
24:47 os resultados. O explain aqui mostra o plano de execução do banco de dados, ou seja, o que ele escolheu para responder a uma consulta. E adicionar analyze de fato executa a consulta e informa medições. Buffers acrescenta informações sobre as páginas de dados que foram acessadas. Você não precisa entender cada linha aqui. Vamos nos concentrar no trabalho que leva aos nossos 10 resultados. Primeiro, temos limit 10.
25:19 É isso que faz o PostgreSQL limitar a saída a 10 linhas. Basicamente, no plano, podemos acompanhar o recuo para ver as etapas que alimentam essa operação. Podemos observar as etapas que encontram artigos correspondentes e, se houver, uma etapa de ordenação dos resultados. Os valores de linhas reais descrevem o que aconteceu durante esta execução. O PostgreSQL pode examinar e classificar muitos artigos correspondentes antes de retornar 10. Limit 10 controla o tamanho da saída. Ele não limita automaticamente todo o trabalho a 10 artigos.
25:49 Vou executar outro comando. Basicamente, tenho este T aqui. T mostra a saída no terminal e também a grava em um arquivo de texto. Também podemos encontrar o tempo de execução perto do fim. A segunda execução talvez seja mais rápida porque… bem, vamos ver se é mais rápida. Sim, 0,323 milissegundos. Mas acho que não é mais rápida. Então, deixa pra lá. Mas às vezes pode ser mais rápida porque o PostgreSQL já tem alguns dados na memória. Agora, vamos experimentar outra busca
26:21 com o mesmo método aqui. Estamos pesquisando “jazz, trompete, Nova Orleans”. Agora, vamos olhar os primeiros títulos. Desta vez, estamos mesmo obtendo alguns títulos e classificações. Estes dois parecem úteis. Vou salvar estes dois para mais tarde. Basicamente, vamos compará-los depois que mudarmos a forma de fazer a busca. E então vou pesquisar “Apollo 13”. A sequência usa os algarismos um e três,
26:52 assim como nossa consulta salva. Reparem se os resultados tratam da missão que estamos procurando. Estamos apenas comparando o que aparece aqui. Basicamente, podemos comparar isso com BM25. Mais tarde, vamos mudar a forma como fazemos a busca e queremos ver como BM25 se compara ao outro método. Até agora, testamos a busca integrada e salvamos o plano da consulta. Em seguida, vamos adicionar BM25 à mesma coluna do corpo dos artigos,
27:23 e ver como isso muda a busca. BM25 usa estatísticas de palavras de toda a coleção que discutimos antes, incluindo o quão raro é um termo. Usar o mesmo texto dos artigos e as mesmas palavras de busca facilita perceber o que muda. Agora, vamos criar nosso índice BM25. É isso que este código está fazendo. articles_body_bm25 é o nome que damos ao índice.
27:53 On articles identifica uma tabela, using BM25 seleciona o método de busca e body identifica a coluna a ser pesquisada. Esta configuração de texto, English, aplica regras de processamento de texto em inglês. É aqui que a busca de texto do PG prepara os artigos existentes para a busca BM25. Os dois comandos set no topo, acima do comando de criação do índice, controlam os recursos da sessão. Um permite até quatro workers de manutenção; o outro define um limite de memória de manutenção de 256 megabytes. Agora,
28:25 vamos experimentar algumas consultas usando as chamadas de consulta BM25. A consulta parece semelhante à nossa primeira busca, mas o cálculo da pontuação mudou. O operador é <@>. Ele aparece aqui entre o corpo do artigo e a consulta BM25. Pense nessa expressão como: calcular a pontuação deste artigo para esta busca. Usamos a expressão em select para exibir a pontuação e em order by para ordenar os resultados. Podemos ver as pontuações mais negativas perto do topo.
28:57 A ordenação de order by é crescente. Portanto, os números negativos são esperados. A busca de texto do PG retorna o negativo da pontuação BM25, então um número mais negativo representa uma correspondência mais forte. Uma pontuação de -20 aparece antes de -5. É por isso que a consulta usa ordem crescente. Nossa consulta anterior com TS rank usava ordem decrescente. Ao alternar entre esses métodos, verifique a direção da ordenação.
29:28 Agora, vamos examinar a consulta BM25. Passamos nosso texto de busca e o nome articles_body_bm25. Nomear o índice informa à busca de texto do PG quais estatísticas de índice usar. A pontuação ajuda a ordenar os artigos para esta busca específica. Ela não é uma porcentagem. -20 não significa que um artigo seja 20% relativo. Avaliem os artigos retornados, assim como
30:00 a ordem deles, quando olharmos os resultados. Mais tarde, nossa aplicação vai excluir candidatos por palavra-chave com pontuação zero. Certo, temos nossos resultados BM25. Agora, vamos verificar se o PostgreSQL realmente usou o índice que criamos. Esta verificação analisa como a consulta é executada, não é um novo recurso de busca. Então, executamos este comando para explicar a busca. Vemos articles_body_bm25 no plano.
30:32 Então, isso vai mostrar os resultados recuperados em ordem de pontuação. O plano e o tempo podem variar conforme os dados e o tamanho do serviço. Certo, este é o nosso plano nativo em plan.txt. Basicamente, executamos este plano, que criamos e salvamos em um arquivo de texto. Agora podemos compará-lo ao plano BM25. Vamos comparar esses planos. Vemos esta varredura de índice bitmap em
31:04 articles body gin. O Postgres está usando nosso índice gin para procurar artigos que contenham os cinco termos de busca. Os “e” comerciais significam “e”: todos os termos são obrigatórios. Neste conjunto de dados, nenhum corpo de artigo satisfaz a condição, então obtivemos zero resultados. Também temos a chave de ordenação TS rank. Este plano é uma etapa separada para ordenar as correspondências por relevância, mas esta busca específica não encontrou nada, então não há artigos correspondentes para classificar ou ordenar. Agora podemos ver a varredura de índice usando
31:36 articles body BM25 e sua linha de ordenação. Com a busca de texto do PG, o índice BM25 permite retornar artigos em ordem de relevância. Observe que não há uma etapa de ordenação separada. Esta consulta não exige que todos os cinco termos apareçam e retorna 10 resultados classificados. Se observarmos o resultado real e, em seguida, as linhas, vemos 10 aqui; depois, temos estes tempos de execução. A busca nativa terminou em cerca de um terço de milissegundo, enquanto o BM25 levou
32:06 cerca de 9 milissegundos, mas eles fazem trabalhos diferentes. Uma não encontrou nenhum artigo que satisfizesse a condição de todos os termos; a outra retornou 10 artigos classificados. Esses tempos não nos dizem qual abordagem é, em geral, mais rápida. O que verificamos é que nossa consulta BM25 usa o índice pretendido para produzir resultados classificados. Em seguida, vamos transformar nossa consulta de demonstração na consulta usada pelo app. Vou abrir o diretório SQL e o arquivo search keyword.sql. Nosso app precisa aceitar uma busca digitada
32:37 por um usuário. Vamos abrir a consulta de palavras-chave para ver como passaremos esse texto ao SQL. Isto é apenas uma prévia. Em seguida, criaremos a coluna de busca de texto e o índice. O espaço reservado para Q contém o texto de busca do usuário. O nome fixo article search MBM25 identifica o índice que fornece as estatísticas de pontuação. Agora, vamos a um dos arquivos reais da nossa
33:07 aplicação: query.py. Podemos ver este tipo de chamada execute, com o SQL e, em seguida, os params. Basicamente, estamos vendo esta chamada em Python. Ela obtém o SQL e um conjunto separado de valores de parâmetros. Um parâmetro é um valor fornecido quando uma consulta é executada. Aqui, isso inclui o texto de busca do usuário. Este psycopg2, que é nosso driver do PostgreSQL, envia o valor ao banco de dados com segurança. Não queremos montar o SQL inserindo o texto do usuário em uma
33:38 string. Então, se voltarmos a esta busca por palavras-chave, por exemplo, um usuário pode buscar um nome que contenha um apóstrofo. Esse apóstrofo deve continuar fazendo parte do texto de busca. A vinculação de parâmetros trata esse conteúdo como dados. O nome fixo do índice tem outra função: escolher o índice de busca. Mantenha esse nome no arquivo de consulta da aplicação. O usuário fornece uma frase de busca, e nosso código fornece a estrutura do SQL.
34:09 Até agora, só buscamos nos corpos dos artigos, mas o título de um artigo também é uma informação útil para a busca. Então, vamos incluir os dois. Este comando cria o texto de busca, que combina o título e o corpo. O PostgreSQL o mantém atualizado automaticamente quando um dos dois muda. Então, vou executá-lo no terminal aqui embaixo. Basicamente, alteramos a tabela. Agora vamos criar o índice. Aqui está o próximo
34:39 comando. Vamos criar um índice BM25 sobre esse texto combinado. Esse será o índice de busca por palavras-chave do nosso app. Vou executar este SQL. Agora, nossa busca por palavras-chave pode usar palavras tanto do título quanto do corpo. Em seguida, vamos ajudar os usuários a entender por que um artigo apareceu, destacando as palavras correspondentes nas prévias. Temos o TS headline e os principais resultados aqui. Só o título talvez não permita saber se um resultado é útil.
35:09 Vamos adicionar uma prévia curta com as palavras correspondentes destacadas. Na consulta por palavras-chave, TS headline cria essa prévia. Primeiro escolhemos os principais artigos e, depois, preparamos as prévias apenas para esses resultados. Não há motivo para preparar milhares de prévias quando estamos mostrando 10. Agora, em safety.py, o banco de dados marca as palavras a destacar. Esta função em Python separa essas palavras do restante da prévia. Isso permite que a página exiba o texto do artigo
35:41 com segurança, mesmo que o artigo contenha algo que pareça código HTML. Agora, abra index.html aqui. A página insere as palavras como texto e adiciona o destaque ao redor dos trechos marcados. Esse é o fluxo completo. O banco de dados escolhe o excerto, o Python identifica os destaques e o navegador os exibe. Veremos essas prévias quando abrirmos o app pronto.
36:13 E lembre-se: você sempre pode conferir o código por conta própria usando o link na descrição. Agora, vamos olhar este código SQL. Gravação. Às vezes, classificar artigos relacionados não é suficiente. Um usuário pode querer artigos que mencionem jazz, mas não mencionem rock. Este exemplo acrescenta esse requisito. O “e” comercial significa “e”, e o ponto de exclamação significa “não”. Vamos executar a consulta e inspecionar os títulos. Esta é uma opção extra de consulta, então ela não
36:45 altera a caixa de busca do nosso app. Outra coisa que podemos fazer é exigir uma expressão exata. Aqui, estamos pedindo que “New Orleans” apareça junto, em vez de permitir que essas palavras apareçam em qualquer lugar do artigo. O PostgreSQL aplica suas regras de processamento de linguagem, então isso não verifica se a capitalização ou a pontuação são idênticas. E vocês veem o at aqui. Há também uma cláusula order by separada para BM25.
37:15 Essas consultas fazem duas coisas. A condição de correspondência decide quais artigos são aceitos. O BM25 coloca esses artigos em ordem de relevância. Essa combinação pode exigir mais trabalho do que uma única varredura de índice combinada. Nosso app vai manter a busca classificada mais simples. Em seguida, vamos ajudar as pessoas a encontrar um artigo mesmo quando não sabem quais palavras digitar. Imagine buscar animais que produzem a própria luz. Você talvez queira um artigo sobre bioluminescência sem saber essa palavra.
37:46 É aí que a busca semântica pode ajudar. Um modelo transforma texto em números que representam padrões e significado. Podemos então procurar artigos com representações semelhantes, mesmo quando usam palavras diferentes. Significados semelhantes nem sempre levam à resposta certa. Uma busca sobre uma missão espacial pode retornar outra missão, por exemplo. Por isso, também vamos manter a busca por palavras-chave. Primeiro, criaremos a busca semântica; depois, combinaremos as duas listas de resultados e veremos se isso ajuda.
38:17 Para comparar significados, primeiro precisamos transformar o texto em números. Essa lista de números é chamada de embedding ou vetor. Nosso modelo local produz 348 números para cada entrada. Vamos preparar cerca de 25.000 artigos para manter o exercício administrável. A busca por palavras-chave continuará cobrindo a coleção inteira. Vamos fornecer ao modelo o título e o começo de cada artigo. O código pega os primeiros 1.500 caracteres do corpo
38:47 e o modelo pode encurtá-los ainda mais para caber no limite de entrada. Portanto, esta busca representa as introduções dos artigos. Ela pode não encontrar uma ideia que apareça apenas perto do fim de um artigo longo. Agora, vamos dar ao banco de dados um lugar para armazenar esses vetores. Este comando adiciona uma coluna com espaço para os 384 números que nosso modelo produz. Isso cria o armazenamento; a próxima etapa o preenche. Vamos usar a mesma versão fixa do modelo para os artigos e as consultas de busca, para que seus vetores possam ser comparados.
39:19 Agora, eu já executei isto. Só para vocês saberem, tenho executado várias coisas diferentes, mas tudo o que realmente precisam executar para fazer este app funcionar está no read me. Basicamente, em vez de executar esses arquivos, executei a versão dividida em partes para poder explicá-los melhor. Mas este aqui vocês podem executar diretamente: aquele que eu estava vendo agora há pouco. Em seguida, vamos executar embed.py. Vamos dar uma olhada nele. Estamos vendo o arquivo embed.py.
39:52 Antes de começar, observem quais artigos o script escolhe. Se rolarmos para baixo, veremos que ele seleciona os 25.000 artigos mais longos e inclui volcano e bioluminescence nos nossos exemplos. Isso pode resultar em até 25.002 artigos. Se reiniciarmos o script, ele retorna o mesmo grupo. Assim, se uma execução for interrompida, ela termina o trabalho original em vez de
40:23 iniciando outro grupo. Então, basicamente, você vai querer executar este comando para iniciar o processo de geração de embeddings. É assim que vou gerar os embeddings. Eu já o executei. Na primeira execução, o modelo é baixado e, depois disso, os vetores são calculados neste computador e salvos no banco de dados em lotes. Se você já concluiu esta etapa, o script informará que não há mais nada a fazer. Essa etapa pode demorar.
40:54 Se a conexão falhar e o script parar depois de tentar novamente, basta executar o mesmo comando outra vez. Os lotes salvos permanecem no banco de dados, e o script retoma o trabalho que falta. Aguarde a conclusão antes de prosseguir. Agora, vamos falar sobre o armazenamento de vetores e o índice. Executar este comando SQL garante que o processo terminou. Queremos verificar se faltam embeddings. Queremos que não falte nenhum vetor no grupo que selecionamos.
41:25 O restante da coleção ainda pode não ter vetores, e isso é esperado. Só preparamos uma parte dela para a busca semântica, certo? Agora, vamos executar este código SQL. Temos os vetores, mas também precisamos de uma forma rápida de pesquisá-los. Este comando cria um índice vetorial usando o PG vector scale. Pense nele como a estrutura de consulta da nossa busca semântica, assim como o índice BM25 dá suporte à busca por palavras-chave. Precisamos aguardar a conclusão da criação e da atualização das estatísticas.
41:56 Agora, vou abrir o arquivo SQL/search_semantic.sql. Esta consulta semântica compara o vetor da busca com os vetores dos artigos. Uma distância menor significa uma correspondência mais próxima, então o resultado mais próximo vem primeiro. O índice usa uma busca aproximada para reduzir o trabalho, o que significa que pode deixar passar algumas das correspondências mais próximas. E uma correspondência próxima ainda pode ser uma resposta ruim. Então, vamos testar e ler os resultados.
42:27 Certo, vamos testar alguns exemplos de busca semântica. Vou executar a consulta query.py animals that make their own light e definir o modo como semantic. O script transforma essas palavras em um vetor e pede ao PostgreSQL artigos com vetores próximos. E vejam os títulos. Qual deles ajuda a responder à pergunta? Se estiver faltando um artigo que você esperava encontrar, lembre-se de que a busca semântica só consegue encontrar artigos
42:58 para os quais geramos um embedding. A primeira consulta também pode demorar mais enquanto o modelo carrega. Agora, vamos testar Apollo 13. Desta vez, estamos procurando uma missão específica. Certo, podemos ver se os resultados continuam relacionados a essa missão ou se derivam para outros assuntos sobre o espaço. Isso nos ajuda a perceber em que situações a correspondência de significado funciona bem e em quais as palavras exatas ainda são importantes. Agora temos resultados por palavras-chave e resultados semânticos. Vamos combiná-los em uma única lista.
43:29 Como combinamos duas buscas que usam sistemas de pontuação diferentes? Uma pontuação BM25 e uma distância vetorial não são diretamente comparáveis, então somar os valores brutos não produziria uma classificação útil. Em vez disso, vamos usar a posição de cada artigo em cada lista: primeiro, segundo, terceiro e assim por diante. Esse método é chamado de fusão recíproca de posições, ou RRF. Um artigo ganha pontos pela posição que ocupa em cada lista, e posições mais altas valem mais pontos. Se ambas as buscas encontrarem o mesmo artigo, ele
44:00 recebe uma contribuição de cada uma. Vamos ver isso com dois artigos de exemplo. O artigo A está em segundo lugar na busca por palavras-chave e em terceiro na busca semântica. A fórmula aqui converte cada posição em pontos: 1 dividido por 60 mais a posição. Para A, somamos 1/62 e 1/63. Isso dá aproximadamente 0,032.
44:31 Não é preciso decorar os cálculos. Basta notar que A ganha pontos das duas listas. O artigo B está em primeiro lugar na busca por palavras-chave, mas não aparece na busca semântica. Ele ganha pontos de apenas uma lista, totalizando aproximadamente 0,0164. Então, neste exemplo, A fica à frente. O fato de ambos os métodos encontrarem A lhe dá pontos combinados suficientes para superar B. O número 60 na fórmula controla o quanto as posições diferem entre si.
45:02 Esse número não é a quantidade de resultados. Nosso app começa atribuindo o mesmo peso aos dois métodos. Ele multiplica cada contribuição por 1/2. Isso reduz esses totais, mas mantém A à frente de B. Mais tarde, podemos ajustar o equilíbrio se um método for mais útil para nossos usuários. Veja como vou fazer isso no app. A busca por palavras-chave nos dá 20 resultados possíveis, e a busca semântica nos dá outros 20. Chamamos esses resultados de candidatos. Combinamos os pontos deles e mantemos os 10 melhores. Assim, trabalhamos com duas listas pequenas
45:34 em vez de combinar a coleção inteira. Então, é basicamente assim que a busca híbrida os combina. O SQL segue essa mesma sequência. Essas seções nomeadas reúnem os candidatos da busca por palavras-chave e os candidatos da busca vetorial. As duas seções seguintes atribuem a cada artigo sua posição na lista. Dá para entender como uma série de etapas: obtemos duas listas e, depois, numeramos os resultados.
46:05 A cláusula WITH nos permite nomear essas etapas, deixando a consulta mais fácil de acompanhar. Para aparecer nos resultados finais, um artigo precisa estar em pelo menos uma dessas listas de candidatos. Começamos com 20 candidatos por método. Mais tarde, você pode testar uma lista maior e ver se isso melhora os resultados o suficiente para justificar o trabalho extra. Aqui, atribuímos pontos a cada artigo de acordo com suas posições. Reunimos as contribuições dos dois conjuntos e somamos os pontos dos artigos com o mesmo ID. Depois, mantemos os 10 maiores totais
46:36 e buscamos os títulos e as prévias deles. Essa é a nossa busca híbrida: duas listas combinadas em uma classificação. Vou executar o comando híbrido. Certo, já explicamos isso; então, deixe-me explicar o resultado. Primeiro, podemos olhar os candidatos da CTE de palavras-chave. Podemos ver a busca semântica… podemos ver que a busca semântica usa o índice BM25 para selecioná-los; são 20 linhas.
47:07 Ou seja, são 20 artigos candidatos para Apollo 13. São resultados possíveis antes de combinarmos os dois métodos de busca. Depois, temos os candidatos vetoriais da CTE. Os embeddings dos artigos, o índice e as linhas reais: 20. Portanto, a busca semântica também seleciona 20 candidatos usando o índice vetorial. Aquela longa lista de números são os
47:37 embeddings do nosso texto de busca. Podemos ignorar os números individuais. O importante é que ambas as buscas estão usando esses índices. Aqui, podemos ver 40 linhas reais no append. Depois, temos a agregação hash, com 32 linhas. Isso reúne as duas listas. 20 mais 20 dão 40 entradas, mas algumas se referem ao mesmo artigo. Depois de agrupar por ID do artigo, temos 32 artigos distintos. Isso nos diz que oito
48:09 artigos apareceram nas duas listas. Cada artigo recebe pontos com base em sua posição, e os artigos encontrados pelos dois métodos recebem contribuições de ambos. Também podemos ver a CTE dos melhores resultados e o limite de 10 linhas reais. Ordenamos as pontuações combinadas e mantemos os 10 artigos mais bem classificados. Em seguida, a consulta busca os detalhes deles e prepara as prévias para a página de resultados. Também podemos ver o tempo de execução
48:40 de 71 milissegundos. Nesta execução, o banco de dados concluiu a consulta em cerca de 71 milissegundos. Isso não inclui o carregamento do modelo Python nem a criação dos embeddings da busca. O plano confirma que nossas duas buscas alimentam uma única classificação combinada. Agora, vamos conferir os títulos efetivos para avaliar se essa classificação é útil. Vou executar a consulta novamente com o modo hybrid. Estes são os resultados da combinação de tudo isso. E
49:11 na verdade, vamos executar cada modo, um de cada vez. Então, os modos keyword, semantic e hybrid. Vamos ver o que eles retornam. Executei a consulta de todas as formas, e estamos vendo os diferentes resultados. Aqui estão os resultados por palavras-chave. Aqui estão os resultados semânticos. E aqui estão os resultados híbridos. Combinar métodos pode ajudar, mas precisamos verificar, em vez de presumir que isso melhora
49:43 todas as consultas. E se a busca semântica for mais útil para as consultas que as pessoas fazem? Podemos dar mais influência a ela. Um peso semântico de 0,7 dá a ela 70% do peso, e deixa 30% para a busca por palavras-chave. Ainda estamos usando as posições nas listas. Só estamos mudando quanto cada método contribui. Se eu abrir o arquivo app.py, posso descer até a configuração do peso semântico. Essa configuração na API controla isso.
50:13 equilíbrio. Zero usa apenas busca por palavras-chave. Um usa apenas busca semântica. E os valores entre eles combinam as duas. Então, você pode examinar este código para ver todos os diferentes endpoints, os diferentes endpoints de URL e como eles funcionam. Para avaliar se isso ajuda, você pode usar as mesmas consultas salvas todas as vezes. Você pode alterar o peso e, então, observar o que sobe e o que desce. E, se mantiver a consulta igual, fica mais fácil ver o efeito de
50:43 da configuração. Vamos voltar às nossas três listas da busca por Apollo 13. Então, a busca híbrida aproximou do topo um artigo útil? Ela empurrou para baixo algum artigo útil? Basicamente, isso depende um pouco de nós. Mas, se olharmos, podemos ver que Apollo 13 aparece mais alto na lista híbrida do que na semântica. E
51:14 Apollo 13 aparece no topo na busca por palavras-chave, mas aí deixamos passar outras coisas que também são importantes. Então, eu diria que o resultado híbrido é o melhor quando analisamos esses termos de busca. Mas há uma limitação na nossa comparação. A busca por palavras-chave cobre toda a coleção, mas a busca semântica cobre os artigos para os quais criamos embeddings. Um artigo sem um vetor não pode receber pontos semânticos. Então, para comparar os métodos com a mesma cobertura, precisamos usar o mesmo
51:44 conjunto de artigos para os dois métodos ou criar embeddings para o restante da coleção. Conforme você aprimora o app, teste uma mudança por vez. Você poderia fornecer mais candidatos ou adicionar embeddings para mais artigos. Repita as mesmas consultas, verifique se os resultados são mais úteis e veja quanto tempo as buscas levam. Por enquanto, nossos três métodos de busca estão prontos. Então, vamos colocá-los por trás de uma página da web. Bem, foi isso que fizemos neste app.py que já analisamos.
52:18 A página da web precisa de uma forma de pedir resultados de busca ao Python, e essa é a função da nossa API, criada com FastAPI. Então, o endpoint de busca bem aqui recebe as palavras a buscar, o modo escolhido e o número de resultados. Ele verifica essas entradas antes de fazer a busca. E aqui podemos ver o mapeamento das consultas. O mapeamento escolhe o arquivo SQL para cada modo. O modo por palavras-chave pode buscar diretamente; os modos semântico e híbrido primeiro
52:49 transformam as palavras do usuário em um vetor. E veja, ele vai usar isto para codificar. O app reutiliza o modelo carregado, então não precisa carregá-lo novamente a cada solicitação. Depois, temos o con.execute. E então recebemos uma resposta. O app envia o SQL e os valores dos parâmetros ao PostgreSQL e, em seguida, retorna os artigos e o tempo decorrido à página. Esse tempo inclui a criação do vetor da consulta, quando necessário.
53:22 É uma medição mais ampla do que o tempo de execução do banco de dados que vimos antes, então mantenha esses dois tempos separados ao comparar o desempenho. Então, vamos ver esta configuração e o lifespan aqui. Toda busca precisa de uma conexão com o banco de dados. Então, em vez de abrir uma nova a cada vez, o app mantém um pequeno pool que pode reutilizar. Aqui, o pool permite até quatro conexões. Cada conexão está preparada para lidar com vetores e busca de texto em inglês. Um temporizador também
53:53 impede que uma instrução do banco de dados seja executada indefinidamente. Então, agora vamos iniciar o app. Basicamente, vamos usar este comando aqui do README, e vou usá-lo para iniciar o app. Ele já está iniciado e podemos conferi-lo novamente. Então, vamos testá-lo. Basicamente, ele tem tudo aquilo para o qual vínhamos trabalhando. Agora, ele tem este exemplo de animais que produzem a própria luz, que vamos simplesmente colar e usar, porque usamos esse exemplo o tempo todo, e podemos alternar entre híbrida,
54:24 por palavras-chave e semântica. Estes são os modos para os quais vínhamos trabalhando. Então, usamos o híbrido, e podemos mudar para palavras-chave. Podemos mudar para semântico. As palavras destacadas ajudam a explicar as correspondências por palavras-chave. Você pode seguir os links das fontes para ler os artigos originais. Mas vamos ver o que mais podemos fazer. Vou abrir um novo terminal e executar este comando curl. Basicamente, estamos testando a configuração do peso. Certo, vamos voltar. Vemos este
54:54 comando. Obtivemos os resultados. Há muitos resultados aqui. Basicamente, esta solicitação dá à busca semântica 70% do peso e à busca por palavras-chave 30%, e a resposta mostra os artigos retornados. Então, poderíamos comparar os títulos com os resultados híbridos de pesos iguais e ver se o equilíbrio alterado ajuda na consulta. Nosso app também tem uma forma de inserir artigos. Então, se eu abrir app.py,
55:25 posso ver aqui a função add article. Vou abrir este demo insert.py, que vai usar essa função para adicionar um artigo. Nosso app pode buscar artigos existentes, e este auxiliar envia um título, um corpo e uma URL de exemplo para a API. Para adicionar conteúdo, é necessário um token de administrador. O auxiliar lê esse token do nosso arquivo de ambiente, então não precisamos digitá-lo nem mostrá-lo na tela. Então, esta é a sequência de gravação do artigo. Queremos que o artigo esteja pronto para
55:55 ambos os métodos de busca. Primeiro, o app cria o vetor. Depois, salva o texto e o vetor juntos em uma transação do banco de dados. Uma transação permite salvar a alteração como uma unidade ou desfazê-la se algo falhar. Se a geração do vetor falhar primeiro, nenhum artigo será inserido. A API informa que a operação foi bem-sucedida depois que o banco de dados confirma a gravação, incluindo as atualizações dos índices. Então, vou executar o comando, o comando demo insert. Ele foi inserido.
56:25 Agora vou procurar isso no nosso navegador. Certo, acabei de buscar por isso e apareceu. Então, na busca semântica, está em segundo lugar. Se eu usar a busca por palavras-chave, vai aparecer em primeiro lugar. Na híbrida, vai aparecer em primeiro lugar. Então, ele tem tanto texto quanto vetor, e os três modos podem usá-lo. Não exportamos o artigo para um serviço de busca separado.
56:55 O texto e os índices de busca estão no PostgreSQL. Certo, posso buscar outras coisas. Posso buscar plantas que comem insetos, e podemos ver que o primeiro resultado é planta carnívora. Então, o app funciona. À medida que a coleção cresce, também precisamos acompanhar como o banco de dados lida com as buscas e atualizações. Na importação inicial, carregamos os artigos antes de criar os índices. Isso evitou atualizar cada índice para cada linha recebida. Criar um índice ainda consome recursos, então planeje esse trabalho em um serviço movimentado. Então, como isso se compara em uma escala muito
57:26 maior? A TigerGraph publicou um benchmark que usou cerca de 138 milhões de trechos para comparar o PG text search 1.0 com o ParadeDB 0.21.6. Nesse teste feito pelo fornecedor, com os dados já em cache, o PG text search respondeu mais rápido a consultas curtas e lidou com mais consultas simultâneas. O ParadeDB criou seu índice cerca de duas vezes mais rápido. Esses resultados descrevem aquele experimento específico.
57:56 Essas não são medições do nosso app nem da versão 1.5.1. Então, use-as como referência e teste seus próprios dados e consultas. Verifique a qualidade dos resultados, quanto tempo as buscas levam e o que acontece quando várias pessoas fazem buscas enquanto os artigos estão sendo atualizados. A metodologia do benchmark está indicada nos recursos. Agora temos busca por palavras-chave, semântica e híbrida. O que você adicionar a seguir deve depender das dificuldades dos usuários. Se eles digitam palavras incorretamente, você precisará de uma abordagem adicional para tolerância a erros de digitação. Se artigos relevantes não tiverem vetores, uma cobertura mais ampla de embeddings pode ajudar. O
58:28 README inclui filtragem opcional e ajustes para trabalhos futuros. Neste projeto, manter os artigos e as buscas no PostgreSQL nos permite trabalhar com SQL e salvar alterações por meio de transações do banco de dados. Uma plataforma de busca dedicada pode atender a outros requisitos. Agora temos um app funcional que podemos testar para decidir o que precisamos. E há muito mais que podemos fazer no nosso banco de dados, diretamente no TigerGraph Cloud. Então, podemos acessar o explorer se quisermos
58:58 explorar os diferentes artigos. Este é o esquema do nosso banco de dados. Podemos ver quais índices temos. Podemos ver as chaves estrangeiras. Podemos ver os logs do que aconteceu no nosso banco de dados. E também podemos fazer coisas para adicionar segurança, além de muitos outros recursos disponíveis quando usamos o TigerGraph Cloud. E a descrição contém links para os arquivos do projeto, a documentação de execução e o conjunto de dados. Você pode usá-los para repetir
59:30 esses exemplos ou criar uma busca para seus próprios artigos. E o README vai trazer muito mais informações sobre o que você pode fazer com isso. E você pode continuar trabalhando nisso. Para seu próximo experimento, talvez queira escolher uma coisa que gostaria de melhorar. Gere embeddings para mais artigos para ampliar a cobertura ou divida artigos longos em passagens menores, para que a busca possa ir além das introduções. Você também pode experimentar um reranqueador, um modelo que pega o primeiro resultado e o examina com mais atenção para reordenar os resultados. Repita as mesmas
1:00:01 consultas de teste e verifique se os resultados melhores valem o tempo extra. Agora você tem um aplicativo de busca funcional para experimentar. Tente suas próprias consultas e leia os resultados. Obrigado por aprender comigo e por assistir ao freeCodeCamp. Vejo você no próximo curso.
Transcrição original
[00:00] In this video, you’ll learn about adding search to apps by building a Wikipedia search app with PostgreSQL, PG text search, and Tiger data. Watch what happens when I search for animals that make their own light. That describes an idea, but it might not use the words in the article I need. Here are the keyword results. Now, let’s switch to semantic search, which compares the meaning of the query with the article embeddings. And here’s hybrid search, which combines both
[00:31] lists. Our articles, keyword index, and vectors all live in one PostgreSQL database. We’re using an extension called PG text search for BM25 ranking and PG vector with PG vector scale for semantic search. I’m Beau Carnes, and in this course, we’ll build this application together. We’ll load real Wikipedia articles, inspect the SQL, and connect everything to a small web interface. You’ll learn what each search
[01:02] method does well, where it struggles, and how to decide whether combining them actually improves your results. Our project is called Wiki search. Keyword search will cover about 242,000 simple English Wikipedia articles. To keep the embedding job manageable, semantic search will cover approximately 25,000 of those articles. The interface reports both counts because that difference matters when we compare results.
[01:32] You’ll need basic SQL, enough Python to run a script, and PostgreSQL with the extensions we’re using. I’ll use Tiger Cloud, and the repository also explains the local option. All the code is included. You can pause at a chapter, run its SQL file, and inspect what changed. We’ll spend our time understanding the decisions that make the application work. Our project is called Wiki Search. Keyword search will cover about 242,000
[02:02] simple English Wikipedia articles. To keep the embedding job manageable, semantic search will cover approximately 25,000 of those articles. The interface reports both counts because that difference matters when we compare results. You can use the code link in the description to follow along. You’ll need basic SQL, enough Python to run a script, and PostgreSQL with the extensions we’re using. I’ll use Tiger Cloud, and the repository also explains the local option. All the code is included, so you can
[02:33] pause at a chapter, run its SQL file, and inspect what changed. We’ll spend our time understanding the decisions that make our application work. Okay, here’s our URL and then {slash} health. And you can see it’s going to give us some information here. We’re targeting PG Text Search version 1.5.1. We’ll verify the installed version before starting, so the Boolean and phrase examples later in the course match the database you’re actually running.
[03:03] Now, this course is made possible with a grant from TigerData, which is the company behind TimescaleDB and PG Text Search. So, thanks to TigerData for supporting freeCodeCamp. And they’re offering $1,000 in Tiger Cloud credit for 30 days through the uh link in the description. We’ll use Tiger Cloud for the walk-through because the database extensions are available there, and the service handles the server configuration. We’ll still enable the extensions in our own database, which we’ll do together
[03:36] shortly. But, all this can be done locally as well, and not through the not through Tiger Cloud. PG Text Search is open-source under the PostgreSQL license, so you can also run it on your own compatible PostgreSQL server. The search code you’re learning is ordinary SQL plus extension operators. You can open up the sample link in the description, and then later we’ll create the service, check the version, and connect our project.
[04:06] Let’s start with the problem we’re solving. Imagine an application that already stores its articles in Postgres. At first, the search box might use a simple text filter. Eventually, users want better ranking, and the team adds a separate search service. An article changes in Postgres, a job or change stream copies it to the search system. The application then reads search results from that second system. Now we have to decide what happens when the copy is delayed, when an event
[04:38] fails, or when we rebuild the search index. A user might save an edit and still see the old version in search. For this project, we’ll keep the search indexes alongside the articles. That gives us one transaction boundary for the data and its index, and lets us combine search with the relational queries we already understand. There are two different jobs inside a search request. Matching decides which documents are eligible. Ranking decides
[05:09] which eligible documents should come first. Suppose I do a search for volcano eruption ash cloud aviation. An all terms filter asks whether every surviving query term appears in the document. A relevance ranking asks how useful the document looks for this query. A page about disrupting flights could be useful even if it never uses the word aviation. PostgreSQL’s built-in text search supports boolean queries and phrases.
[05:43] We’ll use it as our baseline and see what BM25 adds. PG text search adds corpus aware BM25 scoring and an index designed for efficient ranked retrieval. Later, we’ll combine that ranking with explicit explicit matching rules when our application needs them. The built-in TS rank function considers matching terms within a document. It doesn’t have the collection-wide document frequencies that BM25 uses.
[06:16] Those statistics statistics help distinguish a term that appears almost everywhere from one that identifies a much narrower topic. PostgreSQL offers length normalization options and its ranking function already handles repeated terms with diminishing effects. BM25 gives us a tunable model that combines term frequency with average document length and collection-wide document frequencies. That combined model is what we’re adding here.
[06:47] A gin index can find matching documents, but it doesn’t provide the BM25 score ordering we’re about to use. Ranking many matches may require calculating scores and sorting them before returning 10 rows. We’ll inspect our actual plan instead of assigning a universal runtime to native search. The number of matches, document sizes, memory, and hardware all affect that measurement. BM25 is a ranking function.
[07:19] First, a query term is more information when it appears in fewer documents. Second, seeing a term repeatedly helps, but each additional mention should contribute less. Third, document length matters because a very long article has more opportunities to mention almost any subject. Those ideas work together. BM25 isn’t simply counting how many query words it finds. It uses collection statistics and a controlled contribution from each
[07:52] matching term. You’ll encounter BM25 in search systems such as Lucene and Elasticsearch. Sharing a ranking family doesn’t make two search products identical, though. Tokenization, features, configuration, and implementation still matter. In PG text search, we choose a text search configuration and build a BM25 index. The index supplies the statistics needed for scoring. Let’s unpack the three
[08:22] ideas with examples, then we’ll run them against real articles. Imagine a collection of a thousand documents. 900 mention database, but only 10 mention pooling. For the query database pooling, pooling narrows down the collection much more. These counts are illustrative, so we can see the idea clearly. Inverse document frequency, or IDF, captures that difference. A term that appears in fewer documents contributes more evidence of relevance.
[08:54] BM25 combines that evidence with term frequency and document length. Rarity helps, but it isn’t the whole score. The collection matters, too. In this In this subset, pooling appears in eight of 20 specialist documents. It was rare in the full collection, but here it’s common. Partial indexes and partitions can have different statistics. A BM25 score belongs to its query and index, so compare rankings within that
[09:27] context. >> [snorts] >> Now imagine two articles that both discuss pooling. One mentions it once. Another mentions it 10 times. Repetition can indicate that the topic is central, so it makes sense for it to help. But the 50th mention shouldn’t have the same effect as the first. BM25’s term frequency component levels off. The parameter called K1 controls that saturation. The default in PG text search is 1.2.
[10:01] Think of this as controlling how quickly additional mentions stop making much difference. A larger value generally allows frequency to keep contributing over a wider range. We also need to account for document length. That’s the job of B, whose default is 0.75. It adjusts the frequency contribution using the document’s length relative to the collection’s average. We’ll keep the defaults until we have evidence that changing them helps our queries.
[10:33] With B at zero, that length adjustment is disabled. Increasing B strengthens it. This doesn’t mean short documents always win. Matching terms with their frequency still matter, and a long document can absolutely be the best result. Now, how do we avoid scoring everything just to show 10 articles? PG text search uses an inverted index, which connects terms with the documents containing them. It’s block max wand optimization can
[11:05] skip posting blocks that cannot improve the current top results. That’s why the combination of an ordered score and a limit is important. We’re asking the index for a small set of strong candidates. The extension stores its index in PostgreSQL pages and participates in PostgreSQL storage and recovery machinery. Internally, it manages new postings and disk segments. We do not need to check that our query plan uses the intended access path. So, let’s create the
[11:37] database. So, go to this URL. We have the URL in the description, and then you can sign up. And this will give you $1,000 in cloud credits for 30 days. Now, we can go through and choose our service after getting logged in. So, there’s always a free version, but since we have a bunch of credits, I’m going to choose a bigger one. I’m going to just choose the one GPU version. We’re getting 4 GB of RAM, and then we
[12:08] can choose our region. Just Usually, you just want to choose the region that’s closest to you. And then click continue. Now, we’re going to just choose that we’re using our development environment and continue. And then you can name your service or just use the default name. So, what you’re going to want to do is copy the connection string. And then you want to create a copy of the .env.example file, and then add your connection string
[12:40] right in the database URL. And then you can have a random token, and the model device is CPU. And you remember this is should be called the This is should be called .env. Now, make sure to open up the code file the code from the repository link. And we And the readme, it’s going to show everything we need to do to get this set up. There’s going to be quick start commands, and we’ll use the console to establish the database, then run the Python code locally. Now, what
[13:12] I’m going to do is go back to the dashboard here. I’m going to go into my project, and then we have our SQL editor at the bottom. So, this is where we can kind of test out the database. I’m just going to do select current date date and then run. Okay, we got the date here. So, this is all set up correctly. Now, I’m just going to put in some other code here. And this will just check the the version of everything we have. Okay, now I’ve ran this code in the SQL
[13:44] editor and this inspects available PG text search versions. We’re targeting 1.5.1 which we see is set as true. And this is should be available on TigerGraph. Now, we can keep running commands in the SQL editor, but there’s also an easier method. Let me show you. Basically, we just want to follow the quick start guide here. So, first you want to run all these commands in your terminal to create a virtual environment and then install the
[14:16] requirements.txt. And maybe you’ve already done this, create your .env file. And then we’re going then you’re basically going to run every one of these commands here. And you can run them right in your terminal. Now, I’ve already run them. It takes a little bit. Some of them can take a little while to run, but let me just go over them so you can understand how this works. These are the commands are going to set up your database. So, first it’s the extensions file. This enables PG text search and vector
[14:49] scale. The cascade option also installs the vector dependency if it isn’t already enabled. The version query should show PG text search, vector, and vector scale. The script also checks that PG text search is new enough for this course. If an existing service is on an older release, follow Tiger’s service update instructions. You may need the service maintenance update before the newer extension version becomes available. Then update the extension in the database as the readme describes.
[15:22] Don’t keep going past the failed version check. Fixing that now is much easier than diagnosing a a phrase query example against an older installation. Now, we’ve already discussed these about creating a Python virtual environment and activating it, installing the requirements. But, just so you know, the embedding libraries can take a while to install. For this project, we’re using simple English Wikipedia from Wikimedia’s dataset on Hugging Face. The sha- The snapshot is named
[15:52] 2023-11-01.simple. It contains 241,787 articles. The downloaded parquet file is about 157 MB. That is the compressed download size, not the final size of our database and indexes. Each record includes an ID, URL, title, and article text. So, if I do a search here, I can click one of these links, and it’s going to open on simple English Wikipedia.
[16:23] And the database card The dataset card lists Creative Commons Attribution-ShareAlike 3.0 and the GNU Free Documentation License. Our readme and interface include attribution and license links and identify that we shorten and highlight excerpts. So, now I’m going to open this load.py, which we are going to be running soon to load the database. The loader pans a specific dataset revision and checks the downloaded file’s checksum.
[16:56] That helps keep a future run of this course tied to the same source material instead of whatever a moving branch contains. We’re also going to create a schema with this uh schema.sql SQL file. The table has five columns to start. ID, URL, title, body, and word count. The Wikipedia ID becomes our primary key. The body stores our little text and word count gives us a simple field for filters and choosing an embedding subset.
[17:27] So, back on load.py, we can see it downloads the parquet file through hugging face, reads in batches, and sends rows to PostgreSQL with copy. We copy into a temporary staging table, then insert into articles. If an ID is already present, the loader leaves that row alone. That makes re-running the initial load safe without accidentally replacing work we’ve added later. Word count here is a white space based estimate. It isn’t the same as the token count used by the text search
[17:57] configuration or the embedding tokenizer. It’s useful for a coarse filter, but we shouldn’t confuse those different measurements. So, basically, we’re now going to run this command in your terminal. load.py The first run downloads the data set, checks it, and copies the records. Your time will depend on your network, database resources, and connection latency. The loader reports how many records it read, how many it inserted, and the total rows now in the table. If you rerun it, the inserted count
[18:29] should be zero for records already present. But, we haven’t created the search indexes yet. So, for the initial import, loading the data first and building the indexes afterward avoids maintaining those indexes one insertion at a time. Before we go into the next command, we can actually check. I’m going to open this a record this recording/17 counts file, and that’ll give me the the command to try running. So, this time I’m actually going to show you running the other ones I’d already run
[19:00] beforehand. So, I’m just going to run python db.py, and then I’m going to pass in the SQL command. And then we can see how many articles So, So, on a fresh full load, we expect the data site sets 241,787 rows. The source only count stays the same when we add original demo articles with negative IDs. Now, I’m going to run this as another test. So,
[19:32] basically, we’re sorting by work count descending and we’re looking at the 10 largest articles and then we’re opening a few record we open a few records and check that titles bodies and URLs landed in the expected columns. If the body’s empty or the import only loads a sample, basically, an index won’t repair that. So, this is just checking that things are being imported correctly. One thing interesting is that the the load.py runs analyze
[20:04] after the import. That refreshes the planner’s table statistics. Those planner statistics are distinct from the corpus statistics inside the BM25 index. We’ll also check whether our planned demonstration titles exist in the snapshot. The finished snapshot should show actual results from this data, not a result invented for the introduction. So, here’s the text queries.txt. So, we’re going to do some test queries.
[20:34] So, here’s some different items that we’re going to be testing and we’re going to use the same queries throughout the course. Apollo 13 asked for a specific subject while animals that make their own light describes an idea without naming it. As we try each search method, we’ll check whether the results answer the question and how long the search takes. For now, we’re just choosing our test searches. So, let’s start with the volcano query. So, we’re going to run
[21:06] the this SQL here and we’re going to use this query, which is a volcano eruption as cloud aviation. So, this is before adding PG text search. We’re going to try the search tools already built into PostgreSQL. This gives us a starting point to compare against. We’ll search the article bodies first and add the titles later. So, before we run that query, we’re going to run this here. Basically, this run ran this command. Uh
[21:38] and it’s going to create an index to help PostgreSQL find articles containing our search terms. An index is a structure an extra structure the database maintains so it can find data more efficiently. The function uh TS vector or says two TS vector, the function two TS vector converts article text into a searchable representation. The English setting applies language rules such as reducing related word
[22:08] forms and ignoring common words like the. Using gin, the using gin here, it selects an index type that can look up those searchable terms. So, we’ve run the command and it’s created the index. So, now we’re going to finally run this query here. We’ll read it in the order that matters for understanding the search. Plain two TS query, which is this section right here, it converts our search text into
[22:39] matching rules. The where line uses the double at operator to test whether an article matches. For the matching articles, TS rank calculates a relevance score. Order by rank descending puts higher scores first and limit 10 returns the first 10 results. Okay, so I’ll run this right now. We can see that this search term returned no matches. That’s because it requires every search term to appear in the article body. Let’s see how that matching rule works, then loosen it to allow any of the
[23:11] terms. So, now we’re going to try this uh matching every word or any word. And when I run this command, we can see the ampersands mean every term is required. Our example sentence doesn’t contain all of them, so the match is false. The vertical bars change the rule to to accept any term. Basically, this sentence doesn’t contain those terms. So, it fails the all terms test. Now, let’s try that or rule on our articles.
[23:44] So, notice the vertical lines between the search terms. That’s going to mean or. And we’ll run the we’ll run the these SQL commands right now. Okay, so now we’re finally getting some results because we’re doing the or here. So, we have loosened the matching requirement. More articles can qualify and TS rank still decides their order. This demonstrates a choice we can make with PostgreSQL’s built-in search.
[24:16] So, keep these two questions separate. Which articles are allowed into the result and which of those articles appear first? And and or change the first question. The ranking function handles the second. We’ll return to the original and query for next check. Later, BM25 will change how we search and rank, so different result list won’t be due to scoring alone. So, now we have some new SQL I’m going to run here. And this is showing us the plan, the query plan. This is basically showing how SQL found
[24:47] the results. The the explain here shows the database’s execution plan, the the it chose to answer a query. And adding analyze actually runs the query and reports measurements. Buffers here adds information about the data pages that accessed. You don’t need to understand every line here. We’ll just focus on the work that leads to our 10 results. So first we have the limit 10.
[25:19] That’s what post equal caps the output at 10 rows. So basically here in the plan, we can follow the indentation to the steps feeding it. We can look at for the steps that find matching articles and if present a sort step that orders them. The actual row rows values describe what happened during this run. Post equal may examine and rank many matching articles before returning 10. Limit 10 controls the output size. It doesn’t automatically limit all the work to 10 articles.
[25:49] I’m going to run another command. Basically I have this T here. T shows the output in the terminal and also writes it to a text file. We can also find the execution time near the bottom. The second run may be faster because well, let’s see if it’s faster. Yeah, 0.323 milliseconds. But I guess it’s not faster. So never mind. But sometimes it can be faster because post equal already has some data memory. So now we’re going to try another search
[26:21] with the same method here. And we’re doing jazz trumpet in New Orleans. Now let’s look at the first few titles here. When this time we’re actually getting some titles and ranks. So these two look useful. I’m just going to save these two for later. We’re basically going to compare these later once we change the the way we’re doing search. And then I’m just going to do the search for the Apollo 13. The sequel uses the digits one and three
[26:52] just like our saved query. Notice whether the results uh concern the mission we’re looking for. So, we’re just comparing what it what it comes with here. So, we can basically compare it with with BM 25. So, later we’re going to be changing the way we’re doing searching and we just want to see how the BM25 relates to the other method. So, so far we’ve tested built-in search and saved its query plan. Next we’ll add BM25 to the same article body column,
[27:23] and we’re going to see how that changes the search. BM25 uses the collection-wide word statistics we discussed earlier, including how rare a term is. Using the same article text and search words makes it easier to see what changes. So, now we’ll create our BM25 index. That’s That’s what this code is doing. Articles body BM25 is the name we give the index.
[27:53] On articles identifies a table using BM25 selects the search method and body identifies the column to search. This text config English applies English text processing rules. This is where PG text search prepares the existing articles for BM25 search. The two set commands at the top above the index scroll, they control resources for the session. One allows up to four maintenance workers, the other sets a maintenance memory budget of 256 megabytes. Now,
[28:25] let’s try some queries using the BM25 query calls. The query looks similar to our first search, but the score calculation has changed. The operator is less than at sign greater than. So, looks like here. It sits between the article body and the BM25 query. You can think of that expression as score this article for the search. We use the expression in select to display the score and an order by to sort the results. We can see the most negative
[28:57] scores near the top. Then it’s ascending the order by. So, the negative numbers are expected. PG text search returns a negative of the BM25 score, so a more negative number represents a stronger match. A score of -20 sorts before -5. That’s why the query uses ascending order. Our earlier TS rank query used descending order. When switching between these methods, you should check the sort direction.
[29:28] Now, let’s look inside the BM25 query. We pass our search text and the name articles body BM25. Naming the index tells PG text search which index’s statistics to use. The score helps order articles for this particular search. It isn’t a percentage. Negative -20 doesn’t mean an article is 20% relative. Judge the returned articles as well as
[30:00] their order when we look at the results. So, later our application will exclude keyword candidates with a zero score. Okay, we have our BM25 results. Now, let’s check whether PostgreSQL actually used the index we created. This check This is a check on how the query runs, not a new search feature. So, we run this command to explain the search. So, we see in the plan articles body BM25.
[30:32] So, this is going to show the results being retrieved in score order. Now, the plan and timing can differ with the data and service size. Okay, so here is our native plan.txt. We basically ran this plan we created this plan and put it to a text file. So now we can compare it to the BM25 plan. So let’s compare these plans. So we see this bitmap index scan on
[31:04] articles body gin. Postgres is using our gin index to look for articles containing all five search terms. The ampersands mean and every term is required. In this data set no article body meets the condition so we got zero results. We also have the sort key TS rank. This plan is a separate step for sorting matches by relevance but this particular search found nothing so there aren’t any matching articles to rank or sort. Now we can see the index scan using
[31:36] articles body BM25 and it’s order by line. With PG text search the BM25 index supports returning articles in relevance order. Notice that there’s no separate sort step. This query doesn’t require all five terms to appear and it returns 10 ranked results. If we see the actual and then rows 10 here and then we have these execution times. The native search finished in about a third of a millisecond while BM25 took
[32:06] about 9 milliseconds but they’re doing different jobs. One on no article satisfying an all terms condition and the other returned 10 ranked articles. These timings don’t tell us which approach is generally faster. What we verified is that our BM25 query uses the intended index to produce ranked results. Next we’ll turn our demonstration query into the query used by the app. So I’m opening the SQL directory the search keyword.sql file. Our app needs to accept a search typed
[32:37] by a user. Oh, open its open its keyword query to see how we’ll pass that text into SQL. This is a preview only. We create its text search column and index next. The placeholder for Q holds the user’s search text. The fixed named article search MBM25 identifies the index that supplies the scoring statistics. So, now we’ll go to one of the actual files in our
[33:07] application, query.py. And we can see this kind of execute and with the SQL and then params. We’re basically looking at this Python call. It retrieves the SQL and a separate set of parameter values. A parameter is a value supplied when a query runs. Here that includes the user’s search text. This psycopg2, which is our PostgreSQL driver, sends the value safely to the database. We don’t want to assemble SQL by inserting the user’s text into a
[33:38] string. So, if we go back over to this search keyword, for example, a user might search for a name containing an apostrophe. That apostrophe should remain part of the search text. Parameter binding handles it as data. The fixed index name has a different job, choosing the search index. Keep that name in the application’s query file. The user supplies a search phrase and our code supplies the SQL structure.
[34:09] So far, we’ve only searched article bodies, but the an article’s title is useful search information, as well. So, let’s include both. This command creates search text, which combines the title and body. PostgreSQL keeps it updated automatically when either one changes. So, I’m I’m to run it in my terminal down here. And we’ve basically altered the table. Now we’ll build, so here’s the next
[34:39] command. We’re going to build a B25 index on that combined text. This becomes the keyword search index for our app. So I’ll run this SQL. Our keyword search can now use words from both the title and body. Next, let’s help users understand why an article appeared by highlighting matching words in its previews. So we have the TS headline and we have the top results here. A title alone may not tell us whether a result is useful.
[35:09] Let’s add a short preview with the matching words highlighted. In the keyword query, TS headline creates that preview. We chose the top articles first, then prepare previews only for those results. There’s no reason to prepare thousands of previews when we’re showing 10. Now over in safety.py, the database marks which words to highlight. This Python function separates those words from the rest of the preview. That lets the page display article text
[35:41] safely even if the article contains something that looks like HTML code. Now open up the index.html here. Here the page inserts the words as text and highl- and adds highlighting around the marked pieces. That’s the whole path. The database chooses the excer- excerpt, Python identifies the highlights, and the browser displays it. We’ll see those previews when we open the finished app.
[36:13] And remember, you can always look at the code yourself using the link in the description. Now let’s look at this SQL code. Recording So sometimes ranking related articles isn’t enough. A user might want articles that mention jazz, but don’t mention rock. This example adds that requirement. The ampersand means and and the exclamation mark means not. We’ll run it and inspect the titles. Now, this is an extra uh query option, so it doesn’t actually
[36:45] change our app’s search box. One thing we can also do is require phrasing. Or here we’re asking for New Orleans together rather than letting those words appear anywhere in the article. PostgreSQL applies its language processing rules, so this isn’t a check for identical capitalization or punctuation. And you see this at at here. Um and there’s a separate BM25 order by.
[37:15] These queries do two jobs. The matching condition decides which articles qualify. The BM25 puts those articles in relevance order. That combination can require extra work rather than one combined index scan. Our app will keep the simpler ranked search. So, next let’s help find people find an article even when they don’t know the right words to type. Imagine searching for animals that make their own light. You might want an article about bioluminescence without knowing that word.
[37:46] That’s where semantic search can help. A model turns text into numbers that represent patterns and meaning. We can then look for articles with similar representations even when they use different words. Similar meaning doesn’t always mean the right answer. A search about one space mission might return another mission instead. That’s why we’ll keep keyword search also. We’ll build the semantic search first, then combine the two result lists and see whether that helps.
[38:17] To compare meanings, we first need to turn text into numbers. The that list of numbers is called an embedding or a vector. Our local model produces 348 numbers for each input. We’ll prepare roughly 25,000 articles to keep the exercise manageable. Keyword search will still cover the full collection. We’ll give the model the title and beginning of each article. The code takes the first 1,500 body characters
[38:47] and the model can shorten that further to fit its input limit. So, this search represents article introductions. It may miss an idea that only appears near the end of a long article. So, now let’s give the database somewhere to store those vectors. This command adds a column with room for the 384 numbers our model produces. It creates the storage, the next step fills it. We’ll use the same fixed model version for articles and search queries so their vectors can be compared.
[39:19] Now, I’ve already run this and just so you know, um I’ve been running a lot different things, but the only thing you really need to run to get this app working are all in the read me. So, basically, instead of running these files, I ran like the the the like the broken down version so I can explain them better. But, this one you can just run directly, the one that I was just looking at. And next we’re going to be running embed.py. So, let’s look at that one. We’re looking at the file embed.py.
[39:52] So, before we start, notice which article the scripts chooses. If we scroll down here, we’ll scroll down and then we can see that it takes the longest 25,000 articles and includes volcano and bioluminescence for our examples. That can give us up to 25,002 articles. It returns the same group if we restart it. That way an interrupted run finishes the original job instead of instead of
[40:23] starting another group. So, basically you are going to want to run this command to to start the embedding job. That’s how I’ll generate the embeddings. So, I’ve personally already run it. And on the first run the model downloads and after that it computes the vectors on this computer and saves them to the database in batches. If you’ve already completed this step, the script will report that there is nothing left to do. That step can take a while.
[40:54] If the connection fails and the script stops after retrying, you can just run the same command again. Saved batches stay in the database and the script picks up the missing work. Wait for a completion before moving on. Now, let’s talk about vector storage and the index. So, this running this command this SQL will make sure the job finished. And we want to look for missing embeddings. We want zero missing vectors in the group we selected.
[41:25] The rest of the collection can still have no vectors, that’s expected. We only we only prepared part of it for the semantic search. Okay? Now, we’re going to run this SQL code. We have the vectors, but we also need a fast way to search them. This command builds a vector index using PG vector scale. Think of it as the lookup structure for our semantic search. Just as the BM25 index supports our keyword search. We want to make sure to wait for the build and the statistics
[41:56] update to finish. Now, I’m going to look in the SQL/search_semantic.sql file. This semantic query compares the search vector with article vectors. A smaller distance means a closer match, so the closest one comes first. The index uses an approximate search to reduce the work, which means it can miss some nearest matches. And a close match can still be a poor answer. So, let’s try it and read the results.
[42:27] Okay, let’s try out some semantic search examples. So, I’m going to run a query query.py animals that make their own light and I’ll put mode semantic. The script turns those words into a vector and asks PostgreSQL for nearby article vectors. And look at the titles. Which one helps answer that question? If an article you expected is missing, remember that semantic search can only find articles
[42:58] where we’ve given an embedding. The first query may also take longer while the model loads. Now, let’s try the Apollo 13 next. This time we’re looking for a specific mission. Okay, we can see whether results stayed on that mission or drift towards other space topics. So, this helps us see where matching meaning works well and where exact words still matter. So, we now have keyword results and semantic results. So, let’s combine them into one list.
[43:29] How do we combine two searches that use different scoring systems? Well, a BM25 score and a vector distance aren’t directly comparable, so adding their raw numbers wouldn’t give us a useful ranking. Instead, we’ll use where each article appears in each list, first, second, third, and so on. This method is called reciprocal rank fusion or RRF. An article earns points for its position in each list, and higher positions earn more points. If both
[44:00] searches find the same article, it gets a contribution from both. Let’s see that with two example articles. Article A is second in keyword search and third in semantic search. The formula here converts each position into points. 1 / 60 plus the position. For A, we add 1 over 62 and 1 over 63. That gives about 0.032.
[44:31] You don’t need to memorize arithmetic. Just notice that the A gets points from both lists. Article B is the is first in keyword search, but missing from semantic search. It earns points from only one list for a total of about 0.0164. So, A finishes ahead in this example. Both message Both methods finding A gives it enough combined points to beat B. The 60 in the formula controls how strongly positions differ.
[45:02] If the num If it isn’t the number of results. Our app starts by giving the two methods equal weight. It multiplies each contribution by 1/2. That makes these totals smaller, but keeps A ahead of B. Later, we can adjust the balance if one method is more useful for our users. Here’s how I’ll do that in the app. Keyword search gives us 20 possible results and semantic search gives us 20. We call those candidates. We combine their points and keep the best 10. This lets us work with two small lists
[45:34] instead of combining the entire collection. So, this search hybrid is basically how they get combined. The SQL follows that same sequence. These named sections collect the keyword candidates and the vector candidates. The next The next two sections give each article its position in its list. You can read it as a series of steps. Get two lists, then number their results.
[46:05] The width clause lets us name those steps, so the query is easier to follow. An article has to reach at least one of these candidate lists to appear in the final results. We start with 20 candidates per method. Later, you could try a larger list to see whether they improve the results enough to justify the extra work. Here we give um each article points for its positions. We bring the two sets of contributions together and add the points for matching articles ID. Then we keep the 10 highest
[46:36] totals and fetch their titles and previews, and that’s our hybrid search. Two lists combined into one ranking. So, I’ll run the hybrid command. Okay, we run we explained it, so let me explain this explained to you. So, we first we can look at the CTE keyword candidates. We can see semantic search We can see the semantic search uses the BM25 exit uh
[47:07] index to select it says 20 rows. So, 20 candidate articles for Apollo 13. They’re possible results before These are possible results before we combine the two search methods. Then we have the CTE vector candidates. Article embeddings IDX and the actual rows 20. So, semantic search also selects 20 candidates using the vector index. That long list of numbers is the
[47:37] embeddings of our search text. We can skip the individual numbers. The important part is that both searches are using these indexes. Here we can see append actual rows 40. And then we have the hash aggregate rows 32. So, this brings the two lists together. 20 + 20 give us gives us 40 entries, but some refer to the same article. After grouping by article ID, we have 32 distinct articles. That tells us eight
[48:09] articles appeared in both lists. Each article receives points based on its position, and articles found by both methods receive contributions from both. We can also see CTE top results then limit actual rows 10. We sort those combined scores, and we keep the 10 highest-ranked articles. Then the query fetches their details and prepares the previews for the results page. And then we can see the execution time
[48:40] of 71 milliseconds. The database completed this query uh basically in about 71 milliseconds on this run. That doesn’t include loading the Python model or creating the search embeddings. The plan confirms that our two searches feed into one combined ranking. So, next let’s look at the actual titles to judge whether that ranking is useful. So, I’m going to run the query again with mode hybrid. And this is the results the result of combining all all of those. And
[49:11] actually, let’s just run all of them one at a time. So, basically mode keyword, mode mode semantic, and mode hybrid. And we’re going to see what they So, I basically ran it all the different ways, and we’re seeing the different results. So, here are the keyword results. Here the semantic results. And here are the hybrid results. So, combining methods can help, but we need to check rather than assume it improves
[49:43] every query. What if semantic search is more useful for the queries people type? We can give it more influence. A semantic weight of 0.7 gives it 70% of the weight with 30% for a keyword search. We’re still using list positions. We’re just changing how much each method method contributes. So, if I go to the app.py file here, we can go down and look at the semantic weight. This setting in the API controls that
[50:13] balance. Zero uses keyword search alone. One uses semantic search alone. And and the values between them combine the two. So, you can look through this code to see all the different endpoints, the different URL endpoints, and how they work. To judge whether that helps, you can use the same save queries each time. You can change the weight, then look to what look at what moves up and or down. And then if you keep the query the same, it makes it easier to see the effect of
[50:43] the setting. Let’s look back at our three lists from the Apollo 13 search. So, did hybrid search move a useful article closer to the top? Did it push a useful one down? Basically, it kind of is kind of up to us. But if we look, we can see that Apollo 13 appears higher on the list in the hybrid than in semantic. And
[51:14] Apollo 13 appears goes at the top for keyword, but then we miss some other things that are also important. So, I would say that the hybrid result is the best when we look at these search terms. But there is one limitation in our comparison. Keyword search covers the whole collection, but semantic search covers the articles we embedded. An article without a vector can’t earn semantic points. So, to compare the methods on equal coverage, we need to do the same
[51:44] set of we you need to use the same set of articles for both our both or embed the rest of the collection. As you improve the app, test one change at a time. You could give it more candidates or add embeddings for more articles. Repeat the same queries, check whether the results are more useful, and see how long the searches take. For now, our three search methods are ready. So, let’s put them behind a web page. Well, that’s what we’ve done in this app.py that we’ve already looked at.
[52:18] The web page needs a way to ask Python for search results, and that’s the job of our API, which is built with FastAPI. So, the search endpoint right here receives the words to search for, the chosen mode, and the number of results. It checks those inputs before doing the search. And here we can see the the queries mapping. The mapping chooses the SQL file for each mode. Keyword mode can search directly, semantic and hybrid mode first
[52:49] turn the user’s words into a vector. And see, it’s going to be using this to encode. The app reuses the loaded model, so it doesn’t have to load it again for every request. Then later, we have the con. execute. And then we get a response. The app sends the SQL and its parameter values to PostgreSQL, then returns our articles and an elapsed time to the page. That time includes creating the the query vector when needed.
[53:22] It’s a broader measurement than the database execution time we saw earlier, so keep those two timings separate when comparing performance. So, let’s look at this configure and the lifespan here. Every search needs a database connection. So, instead of opening a new one each time, the app keeps a small pool it can reuse. Here the pool allows up to four connections. Each connection is prepared to handle vectors and English text search. A timer also
[53:53] prevents a database statement from running indefinitely. So next we’re going to start the app. Basically, we’re going to use this command right here from the readme and I’ll just use that to start the app. It’s already started and we can actually check it out again. So we’re going to test it. It basically has everything we’ve been building toward. Now it has this example animals that make their own light, which we’re going to actually just paste in and use that example cuz we’ve been using it this whole time and we can change between hybrid,
[54:24] keyword, and semantic. These ones that we’ve been building toward. So hybrid and we can switch to keyword. We can choose switch to semantic. The highlighted words help explain the keyword matches. You can follow the source links to read the original articles. But let’s see what else we can do. I’m going to start a new terminal and I’m going to run this curl command. So we’re basically trying the weight setting. Okay, let’s go back. We see this
[54:54] command. We got the results. A lot of results here. Basically, we are this request gives semantic search 70% of the weight and keyword search 30% and then the response shows the returned articles. So we could compare the titles with the equal weight hybrid results and see whether they the the changed balance helps the query. Our app also has a way to insert articles. So if I go to app.py
[55:25] I can look at add article here. I’m going to open this demo insert.py, which is going to use that to add an article. Our app can search existing articles and this so this help helper sends a sample title, body, and URL to the API. Uh adding content requires an admin token. The helper reads that token from our environment file, so we don’t have to type or show it on screen. So here’s the article write sequence. We want the article to arrive ready for
[55:55] both search methods. First, the app creates its vector. Then it saves the text and vector together in a database transaction. A transaction lets us save the the change as a unit or undo it if it fails. If vector generation fails first, no article’s inserted. The API reports success after the database commits the write, including the index updates. So I’m going to run the command the demo insert command. So it’s been inserted.
[56:25] Now I’m going to search for that on our uh browser. Okay, I just searched for this and it appears. So uh for semantic it’s number two. If I do a keyword, it will appear as the type top one. In hybrid, it will appear as the top one. So it has both vector and it has both text and a vector, so all three modes can use it. We haven’t exported the article to a separate search service.
[56:55] Uh the text and search indexes are in PostgreSQL. Okay, I can search for other things. I can search for plants that eat bugs, and we can see that top one is carnivorous plant. So the app works. As the collection grows, we’ll also need to watch how the database handles the searches and updates. For the initial import, we loaded the articles before building the indexes. That avoided updating each index for every incoming row. Building an index still takes resources, so plan for that work on a busy service. So how does this compare at a much
[57:26] larger scale? TigerGraph published a benchmark using about 138 million passages comparing PG text search 1.0 with ParadeDB 0.21.6. In that vendor run test with data already cached, PG text search answered short queries faster and handled more concurrent queries. ParadeDB built its index about twice as fast. Those results describe that particular experiment.
[57:56] These are measurements of our app or of version 1.5.1. So, use them as background and test your own data and queries. Check result quality, how long searches take, and what happens when several people search while articles are being updated. The benchmark methodology is linked in the resources. We now have keyword, semantic, and hybrid search. What you add next should depend on what users struggle with. If they misspell words, you’ll need an additional approach for typo tolerance. If relevant articles have no vectors, wider embedding covers may help. The
[58:28] readme includes optional filtering and and tuning for further work. For this project, keeping articles and searches in PostgreSQL lets us work with SQL and save changes through database transactions. A dedicated search platform may suit other requirements. We now have a working app we can test to decide what we need. And there’s so much more we can do in our database right within TigerGraph Cloud. So, we can go to the explorer if we want
[58:58] to explore the different articles. So, this is our database schema. We can look at what indexes we have. We can look at the foreign keys. We can see logs from what’s been happening with our database. And there’s even stuff we can do to add security and just a lot of other features that we get when we’re using TigerGraph Cloud. And the description links to project files, execution documentation, and and data set. You You can use them to repeat
[59:30] these examples or build a search over your own articles. And the read me will just give a lot more information of what you can do with this. And you can continue with this. For your next experiment, maybe you want to pick one thing you want to improve. Embed more articles to expand coverage or split long articles into smaller passages so a search can reach beyond their introductions. You could also try a re-ranker, a model that takes the first result and examines them more closely to reorder them. Repeat the same
[1:00:01] test queries and check whether better results are worth the extra time. You now have a working search app to experiment with. Try your own queries and read the results. Thanks for learning with me and thanks for watching freeCodeCamp. I’ll see you in the next course.