Il vector store in breve

Il vector store in breve

Condividi con i tuoi amici...

Negli ultimi anni i modelli linguistici hanno dimostrato di saper comprendere e generare testo in modo sorprendentemente naturale. Tuttavia, da soli, questi modelli non “conoscono” i documenti di un’azienda, i manuali interni o le basi di dati proprietarie. Qui entra in gioco il vector store.

Un vector store è un sistema che permette di trasformare testi in rappresentazioni numeriche (embedding) e di conservarle in modo da poterle confrontare per somiglianza semantica. In pratica, invece di cercare una parola esatta all’interno di un testo, si cerca un significato simile. Due frasi che parlano dello stesso concetto, anche con parole diverse, finiscono vicine nello spazio matematico degli embedding.

Questo meccanismo è alla base della ricerca semantica e, soprattutto, dei sistemi di Retrieval Augmented Generation (RAG): l’utente fa una domanda, il sistema recupera i pezzi di conoscenza più rilevanti da un vector store e li fornisce al modello OpenAI come contesto per generare una risposta fondata su dati reali.


Perché è necessario dividere i testi in chunk

Un errore comune è pensare di poter vettorializzare un intero documento in un solo colpo. In realtà, un embedding rappresenta una sorta di “media semantica” del testo: se il testo è troppo lungo o contiene argomenti diversi, questa media diventa imprecisa. Il modello non riesce più a distinguere bene i concetti.

Per capire in cosa consiste la vettorializzazione leggi

Per questo motivo il testo di conoscenza viene suddiviso in chunk, ovvero porzioni coerenti e autosufficienti. Ogni chunk dovrebbe parlare di un solo argomento o di una singola unità informativa: una procedura, una descrizione, una sezione di manuale. In questo modo ogni embedding risulta più “nitido” e la ricerca per similarità diventa molto più precisa.

Spesso si introduce anche un overlap, una piccola sovrapposizione tra chunk consecutivi, per evitare di perdere informazioni che si trovano proprio al confine tra due sezioni. L’overlap non serve a correggere un cattivo chunking, ma solo a preservare continuità concettuale.


Chunking automatico o manuale?

Esistono strumenti che suddividono automaticamente un testo in blocchi di lunghezza fissa, ad esempio ogni 500 token. Questo approccio è rapido e può andare bene per prototipi o testi narrativi semplici. Tuttavia, quando il contenuto è strutturato – manuali, procedure, documenti legali – il chunking automatico rischia di spezzare concetti importanti a metà.

In questi casi è preferibile un chunking guidato dalla struttura semantica del testo. Un metodo molto efficace consiste nell’inserire nel testo dei tag convenzionali, come [CHUNK], [SEZIONE], [PROCEDURA], che indicano chiaramente dove inizia e finisce un’unità di conoscenza. Il codice può poi usare questi marcatori per suddividere il testo in modo controllato e coerente.

Immaginiamo di avere un testo di conoscenza strutturato così:

[CHUNK]
La firma remota consente di firmare documenti digitali senza l’uso di una smart card.
È particolarmente utile per chi lavora da remoto.

[CHUNK]
Procedura di firma remota:
1. Accedere al portale
2. Caricare il documento
3. Confermare con OTP

[CHUNK]
In caso di problemi con l’OTP, verificare la connessione o contattare l’assistenza.

Ogni blocco contrassegnato da [CHUNK] rappresenta un’unità semantica autonoma.

Suddivisione in chunk con python

def split_by_tag(text, tag="[CHUNK]"):
    parts = text.split(tag)
    return [p.strip() for p in parts if p.strip()]

Vettorializzazione dei chunk con OpenAI

from openai import OpenAI
import numpy as np

client = OpenAI()

def embed_chunks(chunks):
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=chunks
    )
    vectors = [d.embedding for d in response.data]
    return np.array(vectors, dtype="float32")

Creazione del vector store (FAISS)

import faiss

def create_vector_store(vectors):
    faiss.normalize_L2(vectors)
    dim = vectors.shape[1]
    index = faiss.IndexFlatIP(dim)
    index.add(vectors)
    return index

Ricerca semantica

def search(query, index, chunks, k=2):
    q_vec = embed_chunks([query])
    faiss.normalize_L2(q_vec)
    scores, ids = index.search(q_vec, k)

    results = []
    for score, idx in zip(scores[0], ids[0]):
        results.append({
            "score": float(score),
            "chunk": chunks[idx]
        })
    return results

Codice completo

text = """
[CHUNK]
La firma remota consente di firmare documenti digitali senza l’uso di una smart card.

[CHUNK]
Procedura di firma remota:
1. Accedere al portale
2. Caricare il documento
3. Confermare con OTP

[CHUNK]
In caso di problemi con l’OTP, contattare l’assistenza.
"""

chunks = split_by_tag(text)
vectors = embed_chunks(chunks)
index = create_vector_store(vectors)

results = search("Come funziona la firma senza smart card?", index, chunks)

for r in results:
    print(r["score"])
    print(r["chunk"])

Il vector store non è semplicemente un “database di testi”, ma una memoria semantica che consente ai modelli OpenAI di lavorare su conoscenza esterna in modo preciso e controllabile. La qualità delle risposte non dipende tanto dal modello quanto da come la conoscenza viene preparata: la suddivisione in chunk coerenti, l’uso consapevole dell’overlap e una strategia di chunking adeguata al tipo di contenuto sono gli elementi che fanno davvero la differenza.

In altre parole, progettare bene i chunk significa progettare bene il modo in cui l’intelligenza artificiale potrà “ragionare” sui tuoi dati.

Stesso esempio con aggiunta di metadata

Immaginiamo questo testo (unico file / stringa):

[CHUNK id=1 type=overview section="Introduzione"]
La firma remota consente di firmare documenti digitali senza l’uso di una smart card.
È particolarmente utile per chi lavora da remoto.

[CHUNK id=2 type=procedura section="Firma remota"]
Procedura di firma remota:
1. Accedere al portale
2. Caricare il documento
3. Confermare con OTP

[CHUNK id=3 type=troubleshooting section="Problemi comuni"]
In caso di problemi con l’OTP, verificare la connessione
o contattare l’assistenza.

Qui i metadata sono dichiarati nel testo (pattern molto usato):

  • id
  • type
  • section

Parsing del testo + costruzione chunk object

import re
from typing import List, Dict

def parse_chunks(text: str) -> List[Dict]:
    pattern = re.compile(
        r"\[CHUNK\s+(.*?)\]\n(.*?)(?=\n\[CHUNK|\Z)",
        re.DOTALL
    )

    chunks = []

    for match in pattern.finditer(text):
        meta_raw = match.group(1)
        content = match.group(2).strip()

        # parse metadata key=value
        metadata = dict(re.findall(r'(\w+)=(?:"([^"]+)"|(\S+))', meta_raw))
        # fix tuple values
        metadata = {k: v1 or v2 for k, v1, v2 in metadata.items()}

        chunks.append({
            "text": content,
            "metadata": metadata
        })

    return chunks

L’output è fatto più o meno così:

{
“text”: “Procedura di firma remota…”,
“metadata”: {
“id”: “2”,
“type”: “procedura”,
“section”: “Firma remota”
}
}

Vettorializzazione con OpenAI

from openai import OpenAI
import numpy as np

client = OpenAI()

def embed_chunks(chunks):
    texts = [ch["text"] for ch in chunks]

    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=texts
    )

    vectors = np.array(
        [d.embedding for d in response.data],
        dtype="float32"
    )

    return vectors

Creazione del Vector Store (FAISS + cosine)

import faiss

def build_vector_store(vectors):
    faiss.normalize_L2(vectors)
    dim = vectors.shape[1]
    index = faiss.IndexFlatIP(dim)
    index.add(vectors)
    return index

Ricerca semantica con restituzione metadata

def semantic_search(query, index, chunks, top_k=3):
    q_emb = client.embeddings.create(
        model="text-embedding-3-small",
        input=query
    ).data[0].embedding

    q_vec = np.array([q_emb], dtype="float32")
    faiss.normalize_L2(q_vec)

    scores, ids = index.search(q_vec, top_k)

    results = []
    for score, idx in zip(scores[0], ids[0]):
        results.append({
            "score": float(score),
            "text": chunks[idx]["text"],
            "metadata": chunks[idx]["metadata"]
        })

    return results

Codice completo

raw_text = """[CHUNK id=1 type=overview section="Introduzione"]
La firma remota consente di firmare documenti digitali senza l’uso di una smart card.

[CHUNK id=2 type=procedura section="Firma remota"]
Procedura di firma remota:
1. Accedere al portale
2. Caricare il documento
3. Confermare con OTP

[CHUNK id=3 type=troubleshooting section="Problemi comuni"]
In caso di problemi con l’OTP, contattare l’assistenza.
"""

chunks = parse_chunks(raw_text)
chunks = enrich_metadata(
    chunks,
    source="manuale_firma_remota.txt",
    version="2024-03"
)

vectors = embed_chunks(chunks)
index = build_vector_store(vectors)

results = semantic_search(
    "Come si firma senza smart card?",
    index,
    chunks
)

for r in results:
    print("\nSCORE:", r["score"])
    print("SEZIONE:", r["metadata"]["section"])
    print("TIPO:", r["metadata"]["type"])
    print("TESTO:", r["text"])