可视化FAISS矢量空间并调整RAG参数提高结果精度
开源大语言模型的进步有目共睹,写代码、做推荐、文本摘要、问答,样样都越来越拿手。但问题在于,一旦碰到模型训练数据里没见过的内部文档——比如公司内部的合规文件、商业秘密或者隐私信息——LLM就容易“出戏”,产生幻觉,给出不相关、甚至完全捏造的回答。
应对这个挑战,检索增强生成(RAG)是个很实用的方案。它的思路很直接:在生成回答之前,先从外部知识库里检索相关的文档片段,然后把它们当上下文给LLM。这样一来,语料库的质量、以及在向量空间里的表示方式(也就是嵌入),就直接决定了RAG的精度。
这篇文章,我们会用可视化库renumics-spotlight,把FAISS向量空间里高维的嵌入映射到2维平面上,然后通过调整几个关键的矢量化参数,看看能不能提升RAG的回答精度。LLM方面,我们选的是TinyLlama 1.1B Chat——这个模型和Llama 2同架构,但体量小、反赌,准确度损失也没那么大,做快速实验再合适不过。
系统设计
整个QA系统分成两个模块,如图所示。
LoadFVectorize模块
第二个模块
代码实现
1. 安装必要的库
renumics-spotlight这个库用了类似UMAP的降维方法,能把高维嵌入压缩成好理解的2D可视化,同时保留关键特征。之前我们也聊过UMAP,但这次是把它整合到完整的系统里。先装依赖:
pip install langchain faiss-cpu sentence-transformers flask-sqlalchemy psutil unstructured pdf2image unstructured_inference pillow_heif opencv-python pikepdf pypdf pip install renumics-spotlight CMAKE_ARGS="-DLLAMA_METAL=on" FORCE_CMAKE=1 pip install --upgrade --force-reinstall llama-cpp-python --no-cache-dir
最后一行是安装带Metal支持的llama-cpp-python,这样在M1处理器上加载TinyLlama时可以硬件加速。
2. LoadFVectorize模块
这个模块包含三个函数:
- :加载在线PDF,每个块512字符,重叠100字符,返回文档列表。
load_doc
- :调用load_doc获取文档块,创建嵌入并保存到本地目录
vectorize
opdf_index,同时返回FAISS实例。 - :检查磁盘上是否有FAISS库文件,有则加载,没有则调用vectorize创建。
load_db
完整代码如下:
# LoadFVectorize.py
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.document_loaders import OnlinePDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import FAISS
def load_doc() -> 'List[Document]':
loader = OnlinePDFLoader("https://support.riverbed.com/bin/support/download?did=7q6behe7hotvnpqd9a03h1dji&version=9.15.0")
documents = loader.load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=100)
docs = text_splitter.split_documents(documents)
return docs
def vectorize(embeddings_model) -> 'FAISS':
docs = load_doc()
db = FAISS.from_documents(docs, embeddings_model)
db.sa ve_local("./opdf_index")
return db
def load_db() -> 'FAISS':
embeddings_model = HuggingFaceEmbeddings()
try:
db = FAISS.load_local("./opdf_index", embeddings_model)
except Exception as e:
print(f'Exception: {e}\nNo index on disk, creating new...')
db = vectorize(embeddings_model)
return db
3. 主模块
主模块先定义TinyLlama的提示模板,格式如下:
<|system|>{context}
<|user|>{question}
<|assistant|>
我们选用了TheBloke的量化版本TinyLlama,以GGUF格式加载,内存占用极小。
然后利用LoadFVectorize返回的FAISS对象创建检索器,实例化RetrievalQA,最后进行查询。代码:
# main.py
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
from langchain_community.llms import LlamaCpp
from langchain_community.embeddings import HuggingFaceEmbeddings
import LoadFVectorize
from renumics import spotlight
import pandas as pd
import numpy as np
qa_template = """<|system|>
You are a friendly chatbot who always responds in a precise manner. If answer is
unknown to you, you will politely say so.
Use the following context to answer the question below:
{context}
<|user|>
{question}
<|assistant|>
"""
QA_PROMPT = PromptTemplate.from_template(qa_template)
llm = LlamaCpp(
model_path="./models/tinyllama_gguf/tinyllama-1.1b-chat-v1.0.Q5_K_M.gguf",
temperature=0.01,
max_tokens=2000,
top_p=1,
verbose=False,
n_ctx=2048
)
db = LoadFVectorize.load_db()
faiss_retriever = db.as_retriever(search_type="mmr", search_kwargs={'fetch_k': 3}, max_tokens_limit=1000)
qa_chain = RetrievalQA.from_chain_type(
llm,
retriever=faiss_retriever,
chain_type_kwargs={"prompt": QA_PROMPT}
)
query = 'What versions of TLS supported by Client Accelerator 6.3.0?'
result = qa_chain({"query": query})
print(f'--------------\nQ: {query}\nA: {result["result"]}')
visualize_distance(db, query, result["result"])
最后一行visualize_distance负责向量空间可视化。这个函数通过FAISS对象的内部属性获取文档ID和嵌入,然后重建整个向量空间:
vs = db.__dict__.get("docstore")
index_list = db.__dict__.get("index_to_docstore_id").values()
doc_cnt = db.index.ntotal
embeddings_vec = db.index.reconstruct_n()
接着构建DataFrame,并加入问题和答案的嵌入,同时计算每个文档块与问题嵌入的距离:
doc_list = []
for i, doc_id in enumerate(index_list):
a_doc = vs.search(doc_id)
doc_list.append([doc_id, a_doc.metadata.get("source"), a_doc.page_content, embeddings_vec[i]])
df = pd.DataFrame(doc_list, columns=['id','metadata','document','embedding'])
embeddings_model = HuggingFaceEmbeddings()
question_embedding = embeddings_model.embed_query(question)
question_df = pd.DataFrame({"id":"question", "question":question, "embedding":[question_embedding]})
answer_df = pd.DataFrame({"id":"answer", "answer":answer, "embedding":[embeddings_model.embed_query(answer)]})
df = pd.concat([question_df, answer_df, df])
df["dist"] = df.apply(lambda row: np.linalg.norm(np.array(row["embedding"]) - question_embedding), axis=1)
spotlight.show(df)
这一步会在浏览器中启动Spotlight交互界面。
运行测试
1. 基本测试
我们用一个样本问题来测试:
What versions of TLS supported by Client Accelerator 6.3.0?
正确答案是:
Client Accelerator 6.3.0 supports TLS 1.1 or 1.2.
附加信息里还提到具体的CLI命令细节。来看看TinyLlama的回答:
Client Accelerator 6.3.0 supports TLS 1.1 or 1.2 as the default supported TLS versions ... (后略)
表面看和正确答案很像,但仔细看它说了“default”——这是一个不准确的细节。那么,模型是从哪些文档片段里找到答案的呢?
在Spotlight中,用“visible”按钮控制显示的列,按“dist”排序后,问题、答案和最相关的文档片段会出现在顶部。观察整个嵌入空间,几乎所有的文档块都聚成了一个簇——这很合理,因为原始PDF是针对特定产品的部署指南,内容高度同质。
在Similarity Map选项卡里点击过滤器图标,选中的文档列表会高亮显示,其他呈灰色,如图所示。
2. 测试块大小和重叠参数
检索器是影响RAG性能的关键因素。我们调整了TextSplitter的块大小(1000、2000)和重叠(100、200)参数,观察对嵌入空间的影响。
所有组合的输出看起来差别不大,但仔细对比正确答案和每个回答,发现参数为(1000, 200)时答案最准确,其他组合的回答里出现了不正确的细节(已用红色标出)。为什么?让我们从可视化嵌入来理解。
从左到右观察,随着块大小增加,向量空间变得稀疏,块更小;从下到上,重叠增加,但向量空间特征没有明显变化。所有映射仍然大致呈现为一个簇,只有少量离群点。这也解释了为什么回答非常相似——因为整个语料库本身就高度同质。
如果查询恰好位于簇中心附近,那么最近邻可能随参数变化而改变,回答就可能出现明显差异。当RAG应用对某些问题的回答不理想时,生成类似的可视化图表并结合问题分析,往往能找到优化语料库划分的方向。
为了进一步说明,我们加入了两个不相关领域的维基百科文档(格莱美奖和詹姆斯·韦布空间望远镜)。只修改了load_doc函数中的URL,其余代码不变。运行后得到下图:
可以看到两个清晰的、互不重叠的簇。如果提问的内容落在任何一个簇之外,那么检索到的上下文对LLM不仅无益,反而很可能有害。我们拿同样的问题去问,看看LLM会怎么“幻觉”:
Client Accelerator 6.3.0 supports the following versions of Transport Layer Security (TLS): 1.22. TLS 1.33. TLS 1.2 with EV certificates...(明显错误)
这里用的是FAISS做向量存储。如果你用的是ChromaDB,renumics-spotlight同样支持可视化。
总结
检索增强生成让我们能借助LLM的能力,即使模型没见过内部文档,也能得到靠谱的回答。其核心是从向量库中检索相关文档块,再交给LLM当作上下文。因此,嵌入的质量直接决定了RAG的效果。
在本文中,我们演示并可视化了几个关键矢量化参数对LLM性能的影响。借助renumics-spotlight,可以直观地看到整个FAISS向量空间,并基于问题探索空间分布。通过调整块大小、重叠等参数,我们能够影响生成行为,从而提升精度。