LLM avec Ollama et recherche de similarité avec Qdrant, base de données vectorielle

Base de données vectorielle

Je me suis intéressé aux bases de données vectorielles. Contrairement à une base de données relationnelle, où les données sont organisées en tables avec des lignes et des colonnes, dans une base de données vectorielle, les données sont représentées sous forme de vecteurs dans un espace à dimensions élevées.

Cette approche est particulièrement adaptée aux algorithmes d'apprentissage automatique qui fonctionnent sur des vecteurs, tels que la recherche de similarité ou l'apprentissage profond.

Pour illustrer cela, considérons un exemple simple : une base de données vectorielle de documents textuels.

Chaque document est représenté par un vecteur dans un espace à dimensions élevées, où chaque dimension correspond à une caractéristique du document (par exemple, la fréquence d'un mot spécifique). Grâce à cette représentation vectorielle, il est possible de rechercher des documents similaires en utilisant des algorithmes de recherche de similarité vectorielle, tels que la distance cosinus.

Les bases de données vectorielles

Pour implémenter une base de données vectorielle, on peut utiliser un certain nombre de technologies différentes. Par exemple, on peut utiliser une base de données spécialisée dans le stockage et la recherche de vecteurs, telle que Faiss, Milvus, Qdrant ou encore Pinecone en mode SaaS.

Alternativement, on peut utiliser une base de données NoSQL telle que MongoDB ou même PostgreSQL via pg-vector, qui offrent des fonctionnalités pour le stockage et la récupération efficaces de vecteurs.

Note: pg-vector est également disponible par défaut sur les bases de données AWS RDS

Dans cet article, je vais utiliser Qdrant, base de données open-source écrite en Rust.

Nous verrons comment représenter des données sous forme de vecteurs, comment construire une base de données vectorielle et comment l'utiliser pour rechercher des données similaires.

Utiliser un LLM pour générer les vecteurs

Base de données vectorielle

Vous pouvez générer vos propres vecteurs ou utiliser un Large Language Model (LLM) afin de bénéficier de leur entraînement et de leur capacité à générer de nombreux vecteurs (4096 dans notre exemple) plus fiables par rapport au contexte donné.

Étant utilisateur de Go, j'ai naturellement choisi d'utiliser ici Ollama.ai qui fourni un package client pour interragir avec l'API.

Il s'agit d'un outil, basé sur le célèbre llama.cpp qui permet de faire tourner un LLM en local sur vos machines. Disponible sous Linux, MacOS et Windows.

Ainsi, nous pouvons l'installer sur notre machine et récupérer un modèle :

$ ollama pull mixtral:8x7b

Je récupère ici le modèle Mixtral 8x7b qui est un modèle de type Mix of Experts (MoE).

Cela signifie qu'à chaque couche, pour chaque token, un réseau de routeurs choisit deux de ces groupes (les "experts") pour traiter le token et combiner leurs résultats de manière additive.

Nous lançons également en parallèle notre base de données vectorielle à l'aide d'un conteneur :

$ docker run --rm -p 6333:6333 -p 6334:6334 \
    -v $(pwd)/qdrant_storage:/qdrant/storage:z \
    qdrant/qdrant

Instancions maintenant un client Ollama en Go ainsi qu'un client Qdrant :

package main

import (
	"log"

	"github.com/jmorganca/ollama/api"
	pb "github.com/qdrant/go-client/qdrant"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
)

func main() {
  // Ollama client
	ollamaClient, err := api.ClientFromEnvironment()
	if err != nil {
		log.Fatalf("unable to create ollama client: %v\n", err)
	}

  // Qdrant client
	conn, err := grpc.Dial("localhost:6334", grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatalf("did not connect: %v", err)
	}
	defer conn.Close()

	pointsClient := pb.NewPointsClient(conn)
	collectionsClient := pb.NewCollectionsClient(conn)

  // To be continued...
}

Une fois les clients initialisés, vous pouvez initialiser une nouvelle collection dans la base de données Qdrant :

	_, err = collectionsClient.Create(ctx, &pb.CreateCollection{
		CollectionName: "test",
		VectorsConfig: &pb.VectorsConfig{
			Config: &pb.VectorsConfig_ParamsMap{
				ParamsMap: &pb.VectorParamsMap{
					Map: map[string]*pb.VectorParams{
						"": {
							Size:     4096,
							Distance: pb.Distance_Cosine,
							OnDisk:   &trueValue,
						},
					},
				},
			},
		},
	})
	if err != nil {
		log.Printf("unable to create collection: %v\n", err)
	}

Nous spécifions ici un vecteur unique (vide : "") dans cette collection mais il est également possible d'avoir plusieurs vecteurs dans une même collection.

La taille de vecteurs de 4096 est définie car c'est le nombre de vecteurs que va nous renvoyer le modèle que nous allons utiliser.

Petite particularité ici : nous spécifions un algorithme Cosine sur lequel nous reviendrons plus tard dans cet article.

En effet, prennons par exemple les phrases suivantes :

	documents := []string{
		"Thomas adore manger des pommes sous l'ombre d'un arbre.",
		"Thomas a une soeur, elle s'appelle Julie et a 30 ans. Elle n'aime pas les pommes mais préfère les fraises.",
	}

Pour pouvoir discuter avec un LLM et effectuer une recherche à partir de nos données, le principe est le suivant :

LLM et base de données vectorielle

  • 1 - Nous allons générer 4096 vecteurs pour chacune de ces phrases (que nous appelerons document),
  • 2 - Nous stockons ces vecteurs dans notre base de données
  • 3 - Lorsqu'un prompt est saisi, nous récupérons à nouveau 4096 vecteurs générés par ce prompt
  • 4 - Nous effectuons ensuite une recherche de similarité dans notre base de données vectorielle
  • 5 - Nous récupérons les informations de contexte les plus fiables
  • 6 - Nous donnons ces informations de contexte à notre LLM afin qu'il ait du context personnalisé

Ces étapes peuvent paraitrent coûteuses mais les documents peuvent être indexés au fil de l'eau.

Ainsi, uniquement l'étape de génération des vecteurs du prompt et la recherche de similarité sont à effectuer dans le cas d'une requête utilisateur.

Indexation des documents

Côté code, cela donne :

	for i, document := range documents {
		fmt.Printf("Indexing: %q...\n", document)

		// Generate vectors
		response, err := ollamaClient.Embeddings(
			ctx,
			&api.EmbeddingRequest{
				Model:  "mixtral:8x7b",
				Prompt: document,
			},
		)
		if err != nil {
			log.Printf("unable to embed document %q: %v\n", document, err)
		}

		// Insert vectors
		_, err = pointsClient.Upsert(
			ctx,
			&pb.UpsertPoints{
				CollectionName: "test",
				Points: []*pb.PointStruct{
					{
						Id: &pb.PointId{
							PointIdOptions: &pb.PointId_Num{Num: uint64(i)},
						},
						Payload: map[string]*pb.Value{
							"": {
								Kind: &pb.Value_StringValue{
									StringValue: document,
								},
							},
						},
						Vectors: &pb.Vectors{
							VectorsOptions: &pb.Vectors_Vector{
								Vector: &pb.Vector{
									Data: convertFloat64ToFloat32(response.Embedding),
								},
							},
						},
					},
				},
				Wait: &trueValue,
			},
		)
		if err != nil {
			log.Printf("unable to upsert points vectors for document %q: %v\n", document, err)
		}
	}

La génération des vecteurs s'effectue très simplement en utilisantl l'API Embeddings() et en spécifiant le modèle que l'on souhaite utiliser.

Côté insertion des vecteurs, nous spécifions la collection dans laquelle nous souhaitons stocker nos vecteurs, le/les vecteur(s) à utiliser.

Lors de l'indexation, nous spécifions également sous Payload le contenu du document, ce qui nous sera renvoyé lors de la recherche de similarité.

Intérrogation du LLM

Afin d'effectuer la recherche de similarité, nous pouvons écrire :

  userInput := bufio.NewScanner(os.Stdin)
  userInput.Scan()

  prompt := userInput.Text()

  // Generate vectors
  response, err := ollamaClient.Embeddings(
    ctx,
    &api.EmbeddingRequest{
      Model:  "mixtral:8x7b",
      Prompt: prompt,
    },
  )
  if err != nil {
    panic(err)
  }

  // Similarity search
  searchResult, err := pointsClient.Search(
    ctx,
    &pb.SearchPoints{
      CollectionName: "test",
      Vector:         convertFloat64ToFloat32(response.Embedding),
      Limit:          5,
      WithPayload: &pb.WithPayloadSelector{
        SelectorOptions: &pb.WithPayloadSelector_Include{
          Include: &pb.PayloadIncludeSelector{
            Fields: []string{""},
          },
        },
      },
    },
  )
  if err != nil {
    panic(err)
  }

Comme pour l'indexation précédemment, nous générons donc nos 4096 vecteurs pour ce prompt donné.

Nous effectuons ensuite une recherche de similarité sur notre vecteur unique de notre collection de test.

Nous récupérons alors les documents les plus similaires, ordonnés par score de similarité et il suffit alors de les donner à notre LLM comme contexte :

  messages := []api.Message{}

  for _, item := range searchResult.Result {
    messages = append(messages, api.Message{
      Role:    "assistant",
      Content: item.Payload[""].GetStringValue(),
    })
  }

  messages = append(
    messages, api.Message{
      Role:    "system",
      Content: "Tu es un assistant technique permettant de répondre à des questions à partir des informations fournies. Réponds à la question demandée par l'utilisateur. N'ajoute pas de note supplémentaire. Réponds simplement à la question demandée. Réponds uniquement à partir des informations fournies. Réponds en français.",
    },
  )

  var fullResponse = ""

  // LLM - Chat request
  if err := ollamaClient.Chat(
    ctx,
    &api.ChatRequest{
      Model:  "mixtral:8x7b",
      Stream: &trueValue,
      Messages: append(messages, api.Message{
          Role:    "user",
          Content: prompt,
        },
      ),
    },
    func(chatResponse api.ChatResponse) error {
      fmt.Print(chatResponse.Message.Content)
      fullResponse += chatResponse.Message.Content

      if chatResponse.Done {
        fmt.Printf("\n")

        history = append(history, api.Message{
            Role:    "user",
            Content: prompt,
          },
          api.Message{
            Role:    "assistant",
            Content: fullResponse,
          },
        )
      }
      return nil
    },
  ); err != nil {
    panic(err)
  }

Tout simplement, ceci permet donc de donner le contexte à notre requête de chat avec le LLM ainsi que d'afficher sur la sortie standard sa réponse.

Algorithmes de recherche de similarité

Algorithmes de recherche de similarité

Qdrant permet actuellement d'utiliser les algorithmes suivants :

Nous avons ici utilisés un algorithme Cosine.

Voici des cas d'utilisation pour chacun d'entre eux :

  • Cosine: Recommandation de contenu basée sur la similarité entre les profils utilisateur et les éléments du contenu.
  • Euclid: Détection d'anomalies dans les données géospatiales en calculant la distance entre les points de données et leur centre de gravité.
  • Dot: Filtrage collaboratif dans les systèmes de recommandation en mesurant la similarité entre les préférences des utilisateurs et les éléments du contenu.
  • Manhattan: Itinéraires de navigation dans les villes en calculant la distance entre les points de départ et d'arrivée en utilisant uniquement des directions horizontales et verticales.

L'algorithme Cosine est particulièrement adapté pour mesurer la similarité entre les vecteurs de représentation de texte, ce qui est essentiel dans les LLM.

Ces modèles représentent le sens des mots et des phrases sous forme de vecteurs dans un espace multidimensionnel. En utilisant l'angle entre ces vecteurs plutôt que leur distance euclidienne, l'algorithme Cosine capture mieux la similitude sémantique entre les expressions.

Conclusion

Dans cet article, nous avons exploré les bases de données vectorielles et leur pertinence dans le contexte des Large Language Models (LLM).

En combinant les capacités des LLM pour générer des vecteurs de texte riches en informations avec les fonctionnalités de recherche de similarité de Qdrant, les développeurs peuvent créer des applications intelligentes et contextuelles qui exploitent efficacement les informations sémantiques des données textuelles.

Cette approche ouvre la voie à de nombreuses applications potentielles dans des domaines tels que la recommandation de contenu personnalisé, l'assistance virtuelle avancée et bien plus encore.

Si ce contenu vous a intéressé, vous avez désormais un bout de code Go pour pouvoir commencer à jouer avec !

Crédits

Photo par Joshua Sortino sur Unsplash