RAG向量数据库的内部机制:数据块存储、HNSW、IVF与重排序技术。
RAG向量数据库如何存储数据块,利用HNSW和IVF检索数百万个向量,如何添加混合搜索与重排序功能,以及如何比较相似度度量方法。
来源: Inside Vector Databases for RAG: From Chunk Storage to HNSW & IVF Search
检索增强生成系统必须首先解决一个实际问题:文本片段存储在何处?最常见的答案,也是大多数生产系统采用的方法,是将每个文本片段与其嵌入向量一起存储在向量数据库中。
存储记录的格式
在基于FAISS、Pinecone或Weaviate构建的系统中,每条记录都包含三部分内容:嵌入向量、文本片段以及一些元数据。
{
"embedding": [0.123, 0.456, ...],
"text": "Transformer models are powerful...",
"metadata": {
"doc_id": "doc1",
"page": 5
}
}
将文本块及其向量保持在一起有三个好处。首先,搜索速度更快,因为匹配的文本会在同一响应中返回。其次,架构更为简单,无需额外的存储空间进行查询。最后,延迟较低,这对于需要实时获取结果的大型语言模型而言非常重要。
检索过程共分为五个步骤:用户提出问题,问题被转换为嵌入向量,数据库找出最接近的存储嵌入向量,直接返回匹配的文本块,最后大型语言模型根据该文本生成答案。整个过程中没有额外的查找操作。
搜索为何能保持快速
通常的简短描述是向量数据库通过余弦相似度、点积或欧几里得距离来查找相似的嵌入向量。这虽然正确,但却忽略了其中有趣的工程实现细节。当存在数百万甚至数十亿个向量时,查询为何能在几毫秒内得到结果?
最简单的做法是暴力搜索:将查询与所有存储的向量进行比对。虽然准确率极高,但速度极慢。如果存在一百万个向量,那么每个查询就需要进行一百万次比对,这种方式无法扩展。
实际系统中采用近似最近邻(ANN)搜索算法。其思路是先缩小搜索范围,仅检查少量向量,从而以略低的准确率为代价获得显著的速度提升。ANN通过专门的索引结构实现。例如,FAISS就提供了这类索引,同时HNSW和IVF也在其中可用。
HNSW:分层图结构
HNSW即分层可导航小世界。它基于图结构,Pinecone和Weaviate在其内部都使用了该技术。每个向量对应一个节点,每个节点又会连接到相似的邻居节点。搜索过程就相当于在图中遍历。
该图由多个层次构成。最上层包含的节点极少,只能提供粗略的视图,而最底层则详细展示了所有节点。用高速公路与本地街道作比较能很好地体现这一特点。
构建方法如下:当一个嵌入向量被加入时,它会被随机分配到一个层次,因此大多数节点仅位于最底层,只有极少数能到达最上层。在每个层次中,节点会通过余弦值、点积或欧几里得距离与最近的邻居相连。最终形成的网络中,距离相近的向量之间连接紧密,而距离较远的向量之间则连接松散。
搜索从最顶层开始。查询会在该层找到匹配度最高的节点,然后从该节点进入下一层,重复这种贪婪的搜索方式,一直进行到最底层。在上层和中层,搜索是完全贪婪的:总是选择最佳邻居,一旦没有更好的匹配就停止,然后再向下搜索。最底层则更具探索性:从起始节点开始,使用候选项优先队列,搜索会不断添加新的良好匹配项,并同时检查多个邻居。
实际应用中有两个重要参数:
- M表示每个节点的连接数。较大的M值可以提高召回率,但会增加内存占用和构建时间。
- efSearch表示每次查询时会探索的候选项数量。较大的值能提高召回率,但会导致更高的延迟。
简单来说,M决定了索引的质量,而efSearch则在查询时以速度为代价换取精度。
IVF:先进行聚类
IVF(倒排文件索引)在FAISS中很常见,采用完全不同的方法。它不会构建图结构,而是通过聚类将相似的向量归为一组,仅搜索相关组。用图书馆作类比就很清楚:书籍按类别分类,因此无需逐一检查每个书架。
在插入数据时,诸如k-means这样的聚类算法会将向量分成N个簇。每个簇都有一个质心,每个向量都会被分配到离它最近的质心所在簇中。在查询时,会将嵌入向量与所有质心进行比较,选出距离最近的簇,然后对该簇中的每个向量进行评分,从而返回最佳结果。
这两种方法在内存使用方面存在差异。IVF存储的连接数较少,因此内存占用更低。而HNSW的每个节点都包含大量边,故其内存消耗更大。
纯向量搜索的局限性
嵌入模型能够捕捉语义、上下文以及概念上的相似性,但在处理精确的关键词、数字和标识符时表现较弱。例如搜索“kafka error code 504”时,向量索引可能会返回“kafka网络问题”或“分布式系统中的超时问题”,从而遗漏实际包含504代码的文档。
实际应用中的RAG系统通过混合搜索来解决这一问题,即结合语义检索(嵌入模型加上HNSW或IVF等近似最近邻算法)与基于BM25的精确匹配关键词检索。在典型的架构中,这两种检索在独立的系统中进行,其结果会被合并并重新排序。
更现代的向量数据库能够在内部处理混合搜索,因此无需额外的系统。Weaviate和Pinecone都原生支持这一功能。由于它能够将语义理解与精确意图匹配相结合,很快便成为生产环境中的默认选择。
混合搜索不意味着要重复存储所有数据吗?
在外部架构下确实如此:相同的数据片段通常会同时存在于向量数据库和Elasticsearch这样的关键词引擎中。这是有意为之的。向量存储使用HNSW或IVF索引来实现语义相似度匹配,而关键词引擎则使用倒排索引来实现精确匹配。每种索引都是为特定目的设计的,因此这并非冗余。
团队通常会维护一个单一的数据源,如数据库或数据湖,并通过数据管道将其同步到两个系统中。这样既能保持两者的一致性,又能确保检索的快速与准确。
重新排序顶级结果
许多工程师认为一旦从向量数据库获取到前K个结果,工作就完成了。其实并非如此。这些结果只是大致相关,并非完全有序,而且可能遗漏细微的相关性。
以查询“kafka error 504 fix”为例。前K个结果可能会依次是“Kafka超时问题”、“分布式系统错误”以及“错误代码504网关超时”。实际上第三个结果应该排在最前面,但向量搜索并不总能做到这一点。
重新排序功能会基于对查询的更深层次理解,对已获取的结果进行重新排序。向量数据库通常不会自行执行此操作,因此相关逻辑需要在应用层实现。目前主要有两种实现方法:
- 基于LLM的重新排序。将查询和各个片段发送给LLM,让其根据相关性对它们进行排序。这种方法实现简单,且模型能够很好地理解上下文。但缺点是由于需要额外调用而速度较慢,且在处理大规模数据时成本会上升。
- 交叉编码器。这是高质量重新排序的标准方法。模型将查询与一个片段作为一对输入,输出相关性得分。对所有排名前K的候选项重复此操作,然后按得分排序。该方法具有确定性,且准确度更高。
使用LangChain实现的简洁交叉编码器示例:
"""Super-simple CrossEncoder reranking example.
Steps:
1. Define a query and a few short documents.
2. Build (query, doc) pairs.
3. Use a CrossEncoder to get a relevance score for each pair.
4. Print raw scores, then print documents sorted by score.
"""
from sentence_transformers import CrossEncoder
def main() -> None:
# 1. Create model
model_name = "cross-encoder/ms-marco-MiniLM-L-6-v2"
print(f"Loading CrossEncoder model: {model_name}\n")
model = CrossEncoder(model_name)
# 2. Query and documents
query = "What is a vector database?"
documents = [
"A vector database stores embeddings and allows similarity search.",
"Relational databases store structured data in tables.",
"FAISS is a library for efficient similarity search of vectors.",
"Vector databases are used in AI applications like RAG.",
]
# 3. Build (query, doc) pairs
pairs = [(query, doc) for doc in documents]
# 4. Get scores
scores = model.predict(pairs)
print("Query:\n " + query + "\n")
print("Raw scores (higher = more relevant):")
for doc, score in zip(documents, scores):
print(f" score={score:.4f} | doc={doc}")
# 5. Sort by score (descending)
ranked = sorted(zip(documents, scores), key=lambda x: x[1], reverse=True)
print("\nDocuments sorted by cross-encoder score:\n")
for rank, (doc, score) in enumerate(ranked, start=1):
print(f"Rank {rank}: score={score:.4f}")
print(f" {doc}\n")
if __name__ == "__main__":
main()
Output
****************************************************
Query:
What is a vector database?
Raw scores (higher = more relevant):
score=8.4591 | doc=A vector database stores embeddings and allows similarity search.
score=-7.1590 | doc=Relational databases store structured data in tables.
score=1.0106 | doc=FAISS is a library for efficient similarity search of vectors.
score=7.0736 | doc=Vector databases are used in AI applications like RAG.
Documents sorted by cross-encoder score:
Rank 1: score=8.4591
A vector database stores embeddings and allows similarity search.
Rank 2: score=7.0736
Vector databases are used in AI applications like RAG.
Rank 3: score=1.0106
FAISS is a library for efficient similarity search of vectors.
Rank 4: score=-7.1590
Relational databases store structured data in tables.
相似度度量
检索质量在很大程度上取决于向量相似度的计算方式。三种度量方法几乎可以涵盖所有情况。
余弦相似度关注的是两个向量之间的角度,即方向信息。指向相同方向的两个向量具有很高的相似度,而方向相反的向量则完全不同,长度无关紧要。其计算公式为 cos(θ) = A·B / (‖A‖‖B‖)。对于查询“Kafka错误504”,关于“Kafka错误处理”的文档会获得较高分数(约0.9),而关于“数据库索引”的文档则分数较低(约0.2)。该方法适用于嵌入向量,也是RAG中的常用选择,因为重要的是语义而非数值大小。试试用查询向量[1, 1],文档A为[2, 2],文档B为[1, 0]:文档A的方向与查询向量相同,因此尽管其长度更长,余弦相似度仍为完美匹配。
点积结合了方向与大小,其计算公式为 A·B = |A| × |B| × cos(θ)。向量大小越大则越重要,方向相同的向量则越相似。
欧几里得距离用于衡量两点之间的直线距离,距离越近表示越相似,主要用于聚类问题。
各组件的作用
一个优秀的 RAG 流水线并非简单地将向量数据库连接到大型语言模型上即可。嵌入向量中的方向承载着语义信息,而大小则体现信号的强度。FAISS、Pinecone 和 Weaviate 均采用 HNSW 和 IVF 算法来实现大规模数据检索。之后,质量还取决于其他几项决策:
- 仔细划分文档片段以保留上下文
- 使用混合搜索方式同时覆盖语义匹配和关键词匹配
- 通过交叉编码器或大型语言模型对结果进行重新排序
整个流程包括:数据、分块、嵌入、索引、检索、重排序、提示词设计、大语言模型以及答案生成。