Build a secure RAG with knowledge base encryption

更新时间:
复制 MD 格式

To enhance the data security of your retrieval-augmented generation (RAG) knowledge base, you can encrypt text chunks and vectors. Because vectors can also leak original semantics, encrypting them lets you store and transmit data as ciphertext. Vector encryption requires a special algorithm that supports similarity searches on encrypted vectors. This topic uses the LangChain framework in a Data Science Workshop (DSW) development environment as an example to demonstrate how to build a RAG application that supports knowledge base encryption.

Solution overview

The key process is as follows:

  1. Encrypted storage: Parse, chunk, and vectorize the document. Then, encrypt the resulting text chunks and embeddings and store the ciphertext in a vector database.

    This topic uses the Python rai_sam library for encryption and decryption. The encryption methods are as follows:

    • Text chunk encryption: Uses the industry-standard Advanced Encryption Standard (AES)-CTR-256 encryption algorithm.

    • Vector encryption: Uses the DCPE encryption algorithm. This algorithm supports similarity calculations and sorting for encrypted vectors and provides higher security than order-preserving encryption. For more information about its security, see the DCPE paper.

  2. Decryption and inference: Vectorize and encrypt the user's query, and then search the vector database. The inference service decrypts the search results (ciphertext) and inputs the decrypted results and the original query into a large language model (LLM) to generate a response.

image

1. Prepare the development environment

  1. Open the DSW development environment. You can also use a local development environment or another environment of your choice.

    1. Create a DSW instance. This topic uses a DSW instance with the following configurations:

      • Alibaba Cloud Image: modelscope:1.26.0-pytorch2.3.1tensorflow2.16.1-gpu-py311-cu121-ubuntu22.04.

      • Resource Specification: ecs.gn7i-c8g1.2xlarge.

    2. On the Data Science Workshop (DSW) page, click Open in the Actions column for the target instance.

    3. On the Notebook tab, click Create Notebook.image

  2. Install the dependencies required to run the code.

    !pip install -U langchain langchain-community langchain_huggingface
    !pip install -U pypdf
    !pip install -U modelscope
    !pip install -U alibabacloud-ha3engine-vector
    !pip install -U pymilvus
    !pip install -U rai_sam
    !pip install -U flask
  3. Download the embedding model to convert text chunks into vectors. This topic uses bge-large-zh-v1.5. You can choose another embedding model based on your data type, language, or specific domain, such as the legal field.

    from modelscope import snapshot_download
    model_dir = snapshot_download('BAAI/bge-large-zh-v1.5', cache_dir='.')
  4. Prepare the encryption key. To encrypt data using the `rai_sam` module, you need a custom encryption key (4 to 48 bytes) and a key ID (4 to 128 bytes). This topic uses `LD_Secret_0123456789` as the key and `LD_ID_123456` as the key ID. You can configure them as environment variables to be retrieved later. For production applications, you must manage your keys more securely, such as using Key Management Service (KMS).

    import os
    
    # Custom key ID (4 to 48 bytes)
    os.environ["SAM_KEY_ID"] = "LD_ID_123456"
    # Custom key (4 to 128 bytes)
    os.environ["SAM_KEY_SECRET"] = "LD_Secret_0123456789"

2. Process the document and store it with encryption

This section describes how to encrypt a file and store it in a vector database.

2.1 Chunk and vectorize the file

  1. Click the icon shown in the following figure to upload the LLM_Security_Practice_White_Paper_2024.pdf file to the current working directory of your DSW development environment.

  2. Run the following code to chunk and vectorize the file.

    from langchain_community.document_loaders import PyPDFLoader
    from langchain.text_splitter import RecursiveCharacterTextSplitter
    from langchain_huggingface import HuggingFaceEmbeddings
    
    # 1. Load the knowledge base file.
    
    # Path to the file.
    doc_name = "./LLM_Security_Practice_White_Paper_2024.pdf"
    
    file_path = (
        doc_name
    )
    
    loader = PyPDFLoader(file_path)
    docs = loader.load()
    
    print(len(docs))
    
    # 2. Parse and chunk the text. Split the loaded document object into smaller text chunks.
    text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
    split_docs = text_splitter.split_documents(docs)
    
    print(len(split_docs))
    
    # 3. Vectorize the text.
    # Load the embedding model.
    embeddings_model = HuggingFaceEmbeddings(model_name="./BAAI/bge-large-zh-v1.5")
    # Convert text to vectors.
    documents = []
    for i in range(len(split_docs)):
        documents.append(split_docs[i].page_content)
    embeddings = embeddings_model.embed_documents(documents)
    
    print(len(embeddings), len(embeddings[0]))
    
    # Vector dimensions.
    embedding_dimension = len(embeddings[0])

2.2 Encrypt text chunks and vectors

Encrypt the embeddings and the original text content.

import os
from rai_sam.engine.packager.content import SamContentPackager
from rai_sam.engine.packager.vector import SamVectorPackager

# Encrypt the vectors.
vector_packager = SamVectorPackager()
enc_embeddings = vector_packager.SamPkgEncryptVectors(os.getenv("SAM_KEY_ID"), os.getenv("SAM_KEY_SECRET"), embeddings)

# Encrypt the text chunks.
content_packager = SamContentPackager()
contents = []
for i in range(len(split_docs)):
    contents.append(split_docs[i].page_content)
enc_contents = content_packager.SamPkgEncryptContents(os.getenv("SAM_KEY_ID"), os.getenv("SAM_KEY_SECRET"), contents)

# Data to be stored in the vector database.
data = [ {"id": i, 
          "vector": enc_embeddings[i],
          "content": enc_contents[i],
          "metadata": split_docs[i].metadata["source"],
          "key_id": os.getenv("SAM_KEY_ID")
         } for i in range(len(embeddings))
       ]
print(len(data))

2.3 Store the ciphertext in the vector database

This topic uses the Milvus Lite vector database for demonstration purposes. For production applications, you should select a mature, cloud-hosted vector database service. For more information, see Use an Alibaba Cloud vector database.

from pymilvus import MilvusClient

demo_collection_name = "milvus_demo_collection"

# Connect to the vector database. If this is the first time, a database file named milvus_demo.db is created in the current folder.
client = MilvusClient("./milvus_demo.db")

# Create a collection.
if client.has_collection(demo_collection_name):
    client.drop_collection(demo_collection_name)

client.create_collection(
    collection_name=demo_collection_name,
    dimension=embedding_dimension
)

# Insert data into the vector database.
res = client.insert(
    collection_name=demo_collection_name,
    data=data
)
print(res)

3. Deploy the model service

For data security, the vector search results retrieved from the model service are ciphertext. This data must be decrypted before it is sent to the LLM for inference. You can refer to the sample `app.py` file to modify your online prediction code to work with the encrypted knowledge base.

Click to view the complete app.py code

# -*- coding: utf-8 -*-
# Copyright (c) Alibaba, Inc. and its affiliates.
import os
import re
import json
import torch
import logging
from flask import Flask, request
from modelscope import AutoModelForCausalLM, AutoTokenizer
from rai_sam.engine.client.content import SamContentClient

logging.basicConfig(level=logging.DEBUG)
log: logging.Logger = logging.getLogger(__name__)

app = Flask(__name__)

# Used to encrypt and decrypt the knowledge base. Must be consistent with the key used for knowledge base file encryption. Retrieved from environment variables here.
# SAM_KEY_ID: Knowledge base key ID (4 to 48 bytes), SAM_KEY_SECRET: Key (4 to 128 bytes).
sam_key_sets = {
    os.getenv("SAM_KEY_ID"): os.getenv("SAM_KEY_SECRET"),
}

# Configure the absolute path to the large language model file.
model_name = "/mnt/workspace/Qwen/Qwen2___5-3B-Instruct"

# Pre-defined context start and end identifier.
rai_context_start = "<|rai_sam_encrypted_context_start|>"
rai_context_end = "<|rai_sam_encrypted_context_end|>"

device = 'cuda' if torch.cuda.is_available() else 'cpu'
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype="auto",
    device_map=device
)
tokenizer = AutoTokenizer.from_pretrained(model_name)

rai_sam_client = SamContentClient()

def rai_sam_decrypt_content(key_ids: list[str], contents: list[str]) -> list[str]:
    client = SamContentClient()

    if len(set(key_ids)) == 1:
       combine = True
    else:
       combine = False

    log.info("combine: %d", combine)

    if combine == True:
       key_id = key_ids[0]
       key_secret = sam_key_sets.get(key_id)
       if key_secret == None:
           raise RuntimeError("No sam key secret found")

       dec_contents = client.SamClientDecryptContents(key_id, key_secret, contents)
       if dec_contents == None:
           log.error("Failed to decrypt contents")
           return None
    else:
       dec_contents = []
       for i in range(len(key_ids)):
           key_id = key_ids[i]
           key_secret = sam_key_sets.get(key_id)
           if key_secret == None:
               raise RuntimeError("No sam key secret found")

           content = contents[i]
           dec_content = client.SamClientDecryptContents(key_id, key_secret, [content])
           if dec_content == None:
               log.error("Failed to decrypt content")
               return None

           dec_contents.append(dec_content[0])

    return dec_contents

def generate_prompt_plaintext(in_prompt: str) -> str:
    start_pos = in_prompt.find(rai_context_start)
    if start_pos == -1:
        log.info("The input prompt is plaintext")
        return in_prompt

    log.debug("rai_context_start pos: %d", start_pos)

    end_pos = in_prompt.rfind(rai_context_end)
    if end_pos == -1:
        log.error("Not find context end tag: %s", rai_context_end)
        return None

    log.debug("rai_context_end pos: %d", end_pos)

    # Get context content in the in_prompt.
    context = in_prompt[start_pos + len(rai_context_start):end_pos]
    context_json = json.loads(context)
    log.debug("context_json: %s", context_json)

    contents = context_json["contents"]
    log.debug("contents: %s", contents)

    key_ids = context_json["key_ids"]
    log.debug("key_ids: %s", key_ids)

    if len(contents) != len(key_ids):
        raise RuntimeError("the length of contents and key_ids is not euqal")

    dec_contents = rai_sam_decrypt_content(key_ids, contents)
    log.debug("dec_contents: %s", dec_contents)
 
    context = "\n\n".join(
        [content for content in dec_contents]
    )

    out_prompt = in_prompt[:start_pos] + context + in_prompt[end_pos + len(rai_context_end):]

    return out_prompt

def generate_model_response(
        prompt: str,
        max_new_tokens: int = 512,
        temperature: float = 1.0,
        top_k: int = 50,
        top_p: float = 1.0) -> str:

    model_inputs = tokenizer(prompt, return_tensors="pt").to(device)

    generated_ids = model.generate(
        **model_inputs,
        max_new_tokens=max_new_tokens,
        temperature=temperature,
        top_k=top_k,
        top_p=top_p
    )

    generated_ids = [
        output_ids[len(input_ids):] for input_ids, output_ids in zip(model_inputs.input_ids, generated_ids)
    ]

    response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]
    log.debug("response: %s", response)

    tag = "Assistant:"
    pos = response.find(tag)
    if pos != -1:
        print("pos: %d", pos)
        response = response[pos + len(tag):]

    return response.strip()

@app.route('/model', methods=['POST'])
def process_model_generate():
    log.debug('process model generate start.')

    body = request.json
    log.debug("request body: %s", body)

    if "prompt" in body:
        in_prompt = body['prompt']
    else:
        raise RuntimeError("No prompt found")

    if "max_new_tokens" in body:
        max_new_tokens = body['max_new_tokens']
    else:
        max_new_tokens = 512

    if "temperature" in body:
        temperature = body['temperature']
    else:
        temperature = 0.95

    if "top_k" in body:
        top_k = body['top_k']
    else:
        top_k = 50

    if "top_p" in body:
        top_p = body['top_p']
    else:
        top_p = 1.0

    log.debug("prompt: %s", in_prompt)
    log.debug("max_new_tokens: %d", max_new_tokens)
    log.debug("temperature: %f", temperature)
    log.debug("top_k: %d", top_k)
    log.debug("top_p: %f", top_p)

    prompt = generate_prompt_plaintext(in_prompt)
    if prompt == None:
        log.error("Failed to generate prompt plaitext")
        raise RuntimeError("generate prompt fail")

    log.debug("generated prompt: %s", prompt)

    model_response = generate_model_response(prompt,
                         max_new_tokens, temperature, top_k, top_p)
    if model_response == None:
        log.error("Failed to generate model response")
        raise RuntimeError("generate model response fail")

    response = {
        "response": model_response
    }

    return response

@app.route('/v1/chat/completions', methods=['POST'])
def process_model_chat_completions():
    log.debug('process model chat completions start.')

    body = request.json
    log.debug("request json: %s", body)
 
    if "messages" in body:
        messages = body['messages']
    else:
        raise RuntimeError("No messages found")

    if "temperature" in body:
        temperature = body['temperature']
    else:
        temperature = 0.95

    log.debug("temperature: %s", temperature)

    in_prompt = str(messages[0])
    prompt = generate_prompt_plaintext(in_prompt)
    if prompt == None:
        raise RuntimeError("generate prompt fail")

    log.debug("generated prompt: %s", prompt)

    model_response = generate_model_response(prompt, temperature=temperature)
    if model_response == None:
        raise RuntimeError("generate model response fail")

    message = {
        'role': 'assistant',
        'content': model_response
    }

    content = {
        'message': message
    }

    response = {
        'choices': [content]
    }

    return response

if __name__ == "__main__":
    app.run(host = '0.0.0.0', port = '8000', debug=True)

For testing purposes, this section uses the preceding app.py file to start an inference service in the DSW development environment.

  1. Download the model code.

    # Download the model.
    from modelscope import snapshot_download
    model_dir = snapshot_download('Qwen/Qwen2.5-3B-Instruct', cache_dir='/mnt/workspace/')
  2. Start the inference service. Open a terminal and run the following command in the directory where `app.py` is located:

    # Set temporary environment variables.
    export SAM_KEY_ID=LD_ID_123456
    export SAM_KEY_SECRET=LD_Secret_0123456789
    # Run the Python code file.
    python app.py

    The following output indicates that the service started successfully.

4. Vector search and inference

4.1 Vectorize and encrypt user input

Run the following code to convert the user query into a vector and encrypt it.

from langchain_huggingface import HuggingFaceEmbeddings
from rai_sam.engine.packager.vector import SamVectorPackager

query = "What is the guiding principle for building large model security?"

# Load the embedding model and vectorize the query.
embeddings_model = HuggingFaceEmbeddings(model_name="./BAAI/bge-large-zh-v1.5")
query_embedding = embeddings_model.embed_query(query)

# Encrypt the query vector.
vector_packager = SamVectorPackager()
enc_query_embedding = vector_packager.SamPkgEncryptVectors(os.getenv("SAM_KEY_ID"), os.getenv("SAM_KEY_SECRET"), [query_embedding])[0]

print(len(enc_query_embedding))

4.2 Retrieve relevant knowledge segments

from pymilvus import MilvusClient

client = MilvusClient("./milvus_demo.db")

demo_collection_name = "milvus_demo_collection"

# Use the encrypted query vector to perform a search.
search_res = client.search(
    collection_name=demo_collection_name,
    data=[enc_query_embedding],
    limit=3,
    output_fields=["content", "key_id"],
)

# Print the search results.
for res in search_res[0]:
    print("Index:", res["id"])
    print("Distance:", res["distance"])
    print("Content:", res["entity"]["content"])
    print("KeyID:", res["entity"]["key_id"])
    print("\n")

retrieved_contents = [
    (res["entity"]["content"]) for res in search_res[0]
]
key_ids = [
    (res["entity"]["key_id"]) for res in search_res[0]
]

4.3 Decryption and inference

Decrypt the retrieved content and send it with the query to the LLM to generate an inference result.

import os
import json
from langchain import hub
from langchain_community.llms.pai_eas_endpoint import PaiEasEndpoint
from langchain_core.output_parsers import StrOutputParser
from langchain_community.llms import chatglm

rai_context_start = "<|rai_sam_encrypted_context_start|>"
rai_context_end = "<|rai_sam_encrypted_context_end|>"

# Local model service.
llm = chatglm.ChatGLM(endpoint_url="http://127.0.0.1:8000/model")

# EAS model service.
# llm = PaiEasEndpoint(
#    eas_service_url="<service_url>/model",
#    eas_service_token="<service_token>",
#)

prompt = hub.pull("rlm/rag-prompt")

chain = prompt | llm | StrOutputParser()

contents = {
    "contents": retrieved_contents,
    "key_ids": key_ids
}

content_str = json.dumps(contents)
context = rai_context_start + content_str + rai_context_end

print("context: ", context)
print("\n")

response = chain.invoke({"context": context, "question": query})
print(response)

The execution result is as follows:

context:  <|rai_sam_encrypted_context_start|>{"contents": [******], "key_ids": ["LD_ID_****", "LD_ID_****", "LD_ID_****"]}<|rai_sam_encrypted_context_end|>
The core guiding principle for building large model security is to be people-centric, ensuring that technological development aligns with ethics and positively impacts human society. This means that throughout the technology and application process of large models, the interests, needs, and safety of people are always prioritized. The people-centric philosophy requires all participants, including designers, developers, and users, to maintain this mindset and effectively protect the security and interests of users and society. Deviating from this core principle may lead to security risks and challenges, causing unforeseen issues such as privacy violations, social inequality, and ethical conflicts.

Production environment applications

To use this solution in a production environment, you can use an Alibaba Cloud vector database and deploy a securely encrypted inference service in PAI-EAS. This practice enhances your application's security.

Use an Alibaba Cloud vector database

The following code shows how to use Alibaba Cloud vector databases, such as Milvus, OpenSearch, and Elasticsearch, to store and retrieve encrypted data.

Alibaba Cloud Milvus

Data storage

from pymilvus import MilvusClient

demo_collection_name = "milvus_demo_collection"

client = MilvusClient(
    uri="http://c-xxxx-internal.milvus.aliyuncs.com:19530",
    token="User:Password",
    db_name="default"
)

if client.has_collection(demo_collection_name):
    client.drop_collection(demo_collection_name)

client.create_collection(
    collection_name=demo_collection_name,
    dimension=embedding_dimension
)

res = client.insert(
    collection_name=demo_collection_name,
    data=data
)
print(res)

Data retrieval

from pymilvus import MilvusClient

demo_collection_name = "milvus_demo_collection"

client = MilvusClient(
    uri="http://c-xxx-internal.milvus.aliyuncs.com:19530",
    token="User:YourPassword",
    db_name="default"
)

# Use the encrypted query vector to perform a search.
search_res = client.search(
    collection_name=demo_collection_name,
    data=[enc_query_embedding],
    limit=3,
    output_fields=["content", "key_id"],
)

for res in search_res[0]:
    print("Index:", res["id"])
    print("Distance:", res["distance"])
    print("Content:", res["entity"]["content"])
    print("KeyID:", res["entity"]["key_id"])
    print("\n")

retrieved_contents = [
    (res["entity"]["content"]) for res in search_res[0]
]
key_ids = [
    (res["entity"]["key_id"]) for res in search_res[0]
]

The following describes the key configuration parameters:

  • demo_collection_name: The name of the collection in the Milvus instance, such as `milvus_demo_collection`.

  • uri: The endpoint of the Milvus instance. Both private network and public network access are supported. The format is http://<endpoint>:<port>.

  • token: The authentication token. The format is username:password, which corresponds to Milvus instance username:Milvus instance password.

  • db_name: The name of an existing database, such as `default`.

Alibaba Cloud OpenSearch

Data storage

from alibabacloud_ha3engine_vector import models, client
from Tea.exceptions import TeaException, RetryError

# Instance ID
instance_id = "ha-cn-xxx"

# Table name
table_name = "OPS_demo"

# The domain name, username, and password of the instance.
Config = models.Config(
    endpoint=instance_id + ".public.ha.aliyuncs.com",
    instance_id=instance_id,
    protocol="http",
    access_user_name="root",
    access_pass_word="YourPassword"
)

ha3EngineClient = client.Client(Config)

try:
    documentArrayList = []
    for i in range(len(data)):
        add2Document = {
            "fields": data[i],
            "cmd": "add"
        }
        documentArrayList.append(add2Document)

    print(len(documentArrayList))

    optionsHeaders = {}
    pushDocumentsRequest = models.PushDocumentsRequest(optionsHeaders, documentArrayList)

    pkField = "id"
    response = ha3EngineClient.push_documents(instance_id + "_" + table_name, pkField, pushDocumentsRequest)

    print(response.body)
except TeaException as e:
    print(f"send request with TeaException : {e}")
except RetryError as e:
    print(f"send request with Connection Exception  : {e}")

Data retrieval

import json
from alibabacloud_ha3engine_vector import models, client
from alibabacloud_ha3engine_vector.client import Client
from alibabacloud_ha3engine_vector.models import Config
from alibabacloud_ha3engine_vector.models import FetchRequest, QueryRequest

# Instance ID
instance_id = "ha-cn-xxx"

# Table name
table_name = "OPS_demo"

# The domain name, username, and password of the instance.
Config = models.Config(
    endpoint=instance_id + ".public.ha.aliyuncs.com",
    instance_id=instance_id,
    protocol="http",
    access_user_name="root",
    access_pass_word="YourPassword"
)

ha3EngineClient = client.Client(Config)

# Use the encrypted query vector to perform a search.
request = QueryRequest(
    table_name=table_name,
    vector=enc_query_embedding,
    search_params="{\\\"qc.searcher.scan_ratio\\\":0.01}",
    top_k=3,
    output_fields=["content", "key_id"],
    sort = "__vs_vector_score__")

response = ha3EngineClient.query(request)
search_res = json.loads(response.body)

for res in search_res['result']:
    print("Index:", res["id"])
    print("Distance:", res["score"])
    print("Content:", res["fields"]["content"])
    print("KeyId:", res["fields"]["key_id"])
    print("\n")

retrieved_contents = [
    (res["fields"]["content"]) for res in search_res['result']
]
key_ids = [
    (res["fields"]["key_id"]) for res in search_res['result']
]

Where:

  • instance_id: The ID of the OpenSearch instance.

  • table_name: The name of the OpenSearch index table.

  • access_user_name: The username for the OpenSearch instance.

  • access_pass_word: The password for the OpenSearch instance.

Alibaba Cloud Elasticsearch

Data storage

from elasticsearch import Elasticsearch

index_name = "elasticsearch_demo"

index_config = {
    "mappings": {
        "properties": {
            "vector": {
                "type": "dense_vector",
                "dims": embedding_dimension,
                "similarity": "cosine"
            },
            "content": {
                "type": "text"
            },
           "metadata": {
                "type": "text"
            },
            "key_id": {
                "type": "text"
            }
        }
    }
}

client = Elasticsearch(
        '<Elasticsearch_URL>',
        basic_auth=('elastic', '<YourPassword>')
)

exists = client.indices.exists(index=index_name)
if exists == False:
    result = client.indices.create(index=index_name, body=index_config)
    print(result)
else:
    print(f"{index_name} already exists")


for i in range(len(data)):
    document = data[i]
    client.index(
        index=index_name,
        id = document['id'],
        document=document
)
print("Documents indexed successfully")

Data retrieval

from elasticsearch import Elasticsearch

index_name = "elasticsearch_demo"

client = Elasticsearch(
        '<Elasticsearch_URL>',
        basic_auth=('elastic', '<YourPassword>')
)

# Use the encrypted query vector to perform a search.
response = client.search(
    index = index_name,
    query = {
        "knn": {
            "field": "vector",
            "query_vector": enc_query_embedding,
            "k": 3
        }
    },
    fields=["content", "key_id"]
)
search_res = response["hits"]

# Print the search results.
for res in search_res["hits"]:
    print("Index:", res["_id"])
    print("Score:", res["_score"])
    print("Content:", res["_source"]["content"])
    print("KeyId:", res["_source"]["key_id"])
    print("\n")

retrieved_contents = [
    (res["_source"]["content"]) for res in search_res["hits"]
]
key_ids = [
    (res["_source"]["key_id"]) for res in search_res["hits"]
]

The parameters are as follows:

  • index_name: The name of the index. To customize the index name, you must update the YML file on the Elasticsearch instance page to allow automatic index creation.

  • <Elasticsearch_URL>: The endpoint of the Elasticsearch instance. This endpoint supports both private network and public network access. The format is http://<address>:<port>.

  • <YourPassword>: The logon password for the Elasticsearch instance.

Deploy a PAI-EAS model service

PAI-EAS lets you configure a secure and encrypted environment. Using the system trust management service, you can ensure that information such as data, models, and code is securely encrypted during service deployment and invocation. This enables a secure and verifiable inference service. For more information, see Secure and encrypted inference service.