Uma das atividades mais comuns de pessoas que escrevem software fazem é buscar por trechos de código existentes e funcionais, com o objetivo de reutilizar o máximo possível desse código.
Com a grande quantidade de código aberto disponível e o fato de que a maioria das aplicações não são completamente novas, pode-se imaginar que uma quantidade significativa do código que está sendo escrito hoje já foi escrito antes e está disponível em um repositório de código aberto.
Infelizmente, muito pouco código é reutilizado dessa maneira. Existem várias razões para isso. Uma das quais é que é difícil encontrar um código que seja equivalente ao que gostaríamos de implementar. Os motores de busca de código atuais, como o GitHub, geralmente oferecem apenas uma pesquisa baseada em palavras-chave e, por sua vez, têm utilidade limitada na busca por fragmentos de código apropriados para uma aplicação específica.
Vamos supor, no entanto, que queremos buscar um trecho de código específico. Digamos que foi descoberta uma nova vulnerabilidade em uma determinada biblioteca de código, e queremos identificar se o padrão que representa a vulnerabilidade está presente em algum dos commit. O que podemos fazer?
Em um texto anterior, começamos a estudar os mecanismos por trás de uma busca de código. Nesse texto, vamos implementar alguns desses mecanismos e criar um pequeno engenho de busca de código.
📚 Você é dev e quer aprender um pouco mais sobre a criação de aplicações baseadas em LLM?
Eu criei um curso que aborda aspectos teóricos e práticos do desenvolvimento de aplicações baseadas em LLMs. Alguns dos tópicos cobertos:
🟠 O que são e como criar embeddings
🟡 Como selecionar partes relevantes nos seus documentos
🔵 Como integrar essas partes documentos com uma LLM
Embeddings como base para busca de código
No primeiro texto sobre busca de código apresentando a ideia de embeddings e como estes podem ser utilizados para representar conteúdo em linguagem natural e de trechos de código, além de apresentar alguns métodos simples para criação dos embeddings.
De maneira resumida, embeddings são técnicas que representam textos como números.
Pode-se pensar em um embedding como uma maneira de tentar representar a “essência” de um conteúdo por meio de uma série de números — com a propriedade de que “coisas próximas” são representadas por números próximos.
Por exemplo, podemos pensar em um embedding de um conteúdo como uma tentativa de dispor as palavras em uma espécie de “significado” no qual palavras que têm de alguma forma um “significado próximo” aparecem próximas uma das outras no embedding.
De maneira geral, a ideia é examinar grandes quantidades de texto (por exemplo, 5 bilhões de palavras da web) e, em seguida, verificar “quão semelhantes” são os “contextos” nos quais diferentes palavras aparecem. Por exemplo, “jacaré” e “crocodilo” frequentemente aparecerão quase que de forma intercambiável em frases semelhantes, o que significa que eles serão colocados próximos no embedding. No entanto, “mesa” e “águia” não costumam aparecer em frases semelhantes, então eles serão posicionados distantes no embedding.
Não somente para conteúdo textual, mas é também possível criar embeddings de audio, videos e até mesmos imagens.
A partir do momento que conseguimos converter nossa entrada em um vetor numérico, podemos realizar buscas de similaridades em uma base de dados de vetores numéricos. Realizar busca em vetores numéricos é mais barato e mais assertivo do que realizar buscas textuais:
Mais barato: Os vetores numéricos permitem cálculos de similaridade, como o cosseno ou a distância euclidiana, que medem o quão próximos são dois vetores. Isso facilita a recuperação de itens semelhantes de forma mais rápida e eficiente do que a busca textual, que depende de correspondências exatas.
Mais assertivo: Vetores numéricos podem capturar relações semânticas e contextuais entre elementos, permitindo que a busca leve em consideração o significado subjacente das palavras ou itens. Isso melhora a precisão da recuperação de informações relevantes, mesmo quando palavras diferentes são usadas para expressar conceitos semelhantes.
Imagine, por exemplo, você precisar encontrar nos repositórios de código da sua empresa, todos os commits que tenham um padrão similar ao deste trecho de código:
static void addWSNamedAttrs(Operation *op, ArrayRef<mlir::NamedAttribute> attrs) {
for (const NamedAttribute attr : attrs)
if (attr.getName() == "async_agent" || attr.getName() == "agent.mutex_role") {
op->setAttr(attr.getName());
}
}
Há alguns desafios nessa tarefa:
Primeiro pois sem exatamente entender qual a intenção do trecho de código, é difícil buscar mensagens do commit que talvez manifestem essa intenção.
Segundo, pode ser que este trecho de código tenha sido ligeiramente adaptado, por desenvolvedores, por exemplo, alterando o nome de variáveis para melhorar a compreensão do código, ou através de pequenas mudanças de estilos e padrões de codificação, tornando técnicas de busca/sobreposição de strings ineficazes.
Em um cenário como esse, técnicas que utilizam embeddings tendem a ser soluções mais interessantes, pois estes tendem a capturar o contexto semântico das palavras.
Como criar um embedding?
Existem várias técnicas para criação de embeddings, algumas das mais conhecidas são:
O Word2Vec é uma técnica amplamente utilizada para representar palavras em vetores. O Word2Vec captura relações semânticas e sintáticas entre palavras com base em seu contexto de ocorrência. O Word2Vec opera em duas arquiteturas principais: Skip-gram e Continuous Bag of Words (CBOW). No Skip-gram, o modelo tenta prever as palavras ao redor de uma palavra de destino, enquanto no CBOW, ele prevê a palavra de destino com base em palavras circunvizinhas. As representações resultantes permitem calcular similaridades entre palavras e são usadas em várias tarefas de processamento de linguagem natural.
O TF-IDF é uma técnica amplamente utilizado para ponderar termos e avaliar a importância de uma palavra dentro de uma coleção de documentos. Quanto maior a frequência do termo (TF) em um documento, maior é sua importância na representação desse documento. Além disso, quanto mais frequentemente um termo é usado em documentos em um corpus, menor é o IDF (Inverso da Frequência nos Documentos) e, consequentemente, a importância desse termo na representação do documento. Ao multiplicar os valores de TF e IDF, o escore TF-IDF é calculado para cada termo.
O SentenceBERT utiliza redes neurais para criar representações de tamanho fixo de frases, utilizando o BERT como base para gerar as codificações. Ele compara as representações do resumo gerado e do resumo de referência por meio da similaridade cosseno e da distância euclidiana. Isso permite avaliar o quão semelhantes são as duas representações, medindo sua proximidade no espaço vetorial.
Por conta do seu desempenho e popularidade em tarefas de busca de código, nesse texto vamos utilizar o SentenceBERT. O site oficial fornece uma excelente documentação inicial. Para criar nosso primeiro embedding, devemos instanciar o objeto SentenceTransformer
, indicando o modelo que queremos utilizar para criar os embeddings. Em seguida, codificar nosso conteúdo utilizando o método encode
, deste mesmo objeto:
!pip install -U sentence-transformers
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('all-MiniLM-L6-v2')
conteudo = "meu primeiro embedding"
embeddings = model.encode(conteudo)
print(embeddings)
E é basicamente isto.
Embeddings de código
Para criar embeddings para código fonte, podemos seguir o mesmo processo1.
Podemos utilizar o SentenceBERT para criar uma representação numérica do trecho de código que queremos buscar:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('all-MiniLM-L6-v2')
codigo_fonte = """
static void addWSNamedAttrs(Operation *op, ArrayRef<mlir::NamedAttribute> attrs) {
for (const NamedAttribute attr : attrs)
if (attr.getName() == "async_agent" || attr.getName() == "agent.mutex_role") {
op->setAttr(attr.getName());
}
}
"""
embeddings = model.encode(codigo_fonte)
print(embeddings)
Com o resultado da codificação (nosso código fonte em formato vetorial), podemos agora fazer uma consulta em uma base de dados, buscando commits que contenham essa mudança.
Para isso, podemos utilizar bibliotecas como o pydriller, que facilitam o processo de mineração de repositórios de código fonte. Com essa biblioteca, podemos:
Baixar todo o histórico de um repositório
Navegar por todos os commits
Extrair somente as informações de mudanças relevantes (por exemplo, excluindo commits de merge), para, por fim
Comparar se o trecho de código que estamos buscando está presente em alguma das mudanças relevantes.
A implementação dos passos 1—4 acima está disponível neste link. Para fazer essa comparação, podemos utilizar a implementação do calculo de similaridade de cosenos.
A similaridade de cosseno é uma métrica que mede a semelhança entre dois vetores em um espaço vetorial, com valores variando de -1 a 1. Quanto mais próximo o valor estiver de 1, maior a semelhança entre os vetores, indicando itens mais parecidos, enquanto valores próximos de -1 denotam oposição completa. Esta métrica é amplamente usada em tarefas de recuperação de informações, classificação de texto e recomendação, auxiliando na tomada de decisões com base na relação entre objetos representados pelos vetores.
Há várias implementações disponíveis. Mas como a biblioteca SentenceBERT também conta com uma implementação, utilizaremos-a. Essa implementação está disponível disponível no pacote util
, através da função util.cos_sim
. Por exemplo:
from sentence_transformers import util
embeddings_original = ...
embeddings_commit_relevante = ...
util.cos_sim(embeddings_original, embeddings_commit_relevante)
Conclusão
Pode-se dizer que o uso de embeddings tornou possível a execução de diversas tarefas que são apoiadas por NLP, sendo busca de código uma delas.
Com embeddings e de suas mais diversas implementações, somos capazes de fazer buscas mais precisas e mais rápidas.
E se você chegou até aqui e gostou desse conteúdo, não deixe de deixar um joinha ou compartilhar esse texto com colegas!
No entanto, é importante destacar que a qualidade do embedding gerado está diretamente relacionado ao modelo utilizado para criar esse embedding. Por exemplo, se o modelo foi treinado em tarefas de tradução Inglês ➜ Francês, talvez não seja realístico imaginar que este modelo tenha um bom desempenho em outros tipos de tarefas para qual este não foi treinado. Há inclusive literatura especializada sobre o uso de embeddings para representação de código fonte. No entanto, por conta da simplicidade do uso do SentenceBERT, neste com esse modelo nesse texto.