


Fig1.RAG链路图-粗粒度版,有缺失


Fig2.RAG链路图-细粒度版
▐ 文档分块-Chunking
# 设置本地数据保存目录local_data_dir = pathlib.Path("/Users/jiangdanyang/workspaces/python/MarioPython/src/RAG/dataset/ai-arxiv2")# 加载数据集(如果本地存在则从本地加载,否则下载)dataset = load_dataset("jamescalam/ai-arxiv2", split="train", cache_dir=str(local_data_dir))# 初始化编码器encoder = OpenAIEncoder(name="text-embedding-3-small",openai_api_key=os.getenv("AI_API_KEY"),openai_base_url=os.getenv("AI_API_BASE_URL"))chunker = StatisticalChunker(encoder=encoder,min_split_tokens=100,max_split_tokens=500,plot_chunks=True,enable_statistics=True,)chunks_0 = chunker(docs=[dataset["content"][0]], batch_size=500)

Chunking Statistics: - Total Documents: 474 - Total Chunks: 46 - Chunks by Threshold: 41 - Chunks by Max Chunk Size: 4 - Last Chunk: 1 - Minimum Token Size of Chunk: 54 - Maximum Token Size of Chunk: 495 - Similarity Chunk Ratio: 0.89
-
整体Documents(可以简单理解为句子数,本部分设计的document都是该含义):474; -
整体切分的文件块chunk:46个,其中41chunk的切分是基于相似度的阈值(可以理解为是按照语义正常划分出来的),有4个是因为达到了500tokens数量被切分的,还有最后1个chunk是到文章结尾了; -
最大的chunk的token数495个,最小的chunk的token数54个,因为是last chunk,所以会出现小于min_split_tokens的情况; -
最后Similarity Chunk Ratio是统计这次切分的chunk,89%的chunk是按照语义切分出来的(41/46)。
-
Threshold,就是所谓的相似度的下限,上面例子中threshold是0.31,该值越大,chunk内的相关性越好; -
Window Size,是被用于计算的document的数量大小,默认是5,即每次是选择连续的5个document计算相似度,window size设置越大,上下文切分的相关性越好,但是同时chunking过程的计算量和耗时也更高,chunk大小相对要大。
-
语义增强
DOCUMENT_CONTEXT_PROMPT = """<document>{doc_content}</document>"""CHUNK_CONTEXT_PROMPT = """Here is the chunk we want to situate within the whole document<chunk>{chunk_content}</chunk>Please give a short succinct context to situate this chunk within the overall document for the purposes of improving search retrieval of the chunk.Answer only with the succinct context and nothing else."""
-
反向HyDE
Given the following text chunk, generate {n} different questions that this chunk would be a good answer to:Chunk: {chunk}Questions (enumarate the questions with 1. 2., etc.):
first_sentence = "直播分享会AI Living第一场"second_sentence = "直播鲁班小组分享第77期"model = SentenceTransformer("/Users/jiangdanyang/workspaces/python/Model/all-MiniLM-L6-v2")tokenized_first_sentence = model.tokenize([first_sentence])tokenized_second_sentence = model.tokenize([second_sentence])

-
编码模型的语言问题,不同语言会有不同的分词和词汇表,比如例子中使用的这个编码模型all-MiniLM-L6-v2在处理中文的文本时候,就比较差,可以看到返回的id有好些100(不可识别的token),中文的处理可以找相应的中文embedding模型,但是不是所有语言都有对应的编码模型,因为语种太多,同时如果一些语种对应的数据语料太少,不足以训练这样的一个模型。 -
编码模型的词汇表大小,例子中的all-MiniLM-L6-v2的词汇表大小是30522,有些主流模型的词汇表大小基本都在5w以上,有些10w以上,词汇表小会导致一些词无法表示,只能用一个兜底token id来代替,会影响后续处理的效果;词汇表大能精准标识文本的输入,但是间接也会增加文本编码完之后的token大小; -
编码模型的语义空间,不同的编码模型有自己的词汇表,以及自己对应的向量语义空间,向量语义空间的效果决定于该模型训练基于的数据集,目前用于文本编码的模型,基本都是现有世界知识的通用语义空间,偏日常、大众化的关联,如果我们需要在一个特定领域下,有一个特殊的语义空间,可能就需要找一个使用该领域下的数据训练的embedding模型,或者需要自己SFT一个,不然预期想要的效果和实际效果可能会有比较大的gap;(顺便说下图知识的问题,直接拿图片当知识,处理过程可能就是OCR的文本提取,或者是LLM对于图片的理解描述,但是这里的干扰会很大,比如中间过程的文本,是不是你期望的样子和描述的维度,这些都需要把握下,不然后续的检索召回肯定也是一团浆糊)。

Sparse向量主要是通过BM25为代表的算法生成,BM25核心就是TF-IDF算法(词频-反向文档频率),返回是某个query相对每个文档编号的分数值(具体算法如下):

# Load the chunkscorpus_json = json.load(open('/Users/jiangdanyang/workspaces/python/MarioPython/src/RAG/dataset/corpus.json'))corpus_text = [doc["text"] for doc in corpus_json]# optional: create a stemmerenglish_stemmer = snowballstemmer.stemmer("english")# Initialize the Tokenizer with the stemmersparse_tokenizer = Tokenizer(stemmer=english_stemmer,lower=True, # lowercase the tokensstopwords="english",# or pass a list of stopwordssplitter=r"w+",# by default r"(?u)bww+b", can also be a function)# Tokenize the corpuscorpus_sparse_tokens = (sparse_tokenizer.tokenize(corpus_text,update_vocab=True, # update the vocab as we tokenizereturn_as="ids"))# Create the BM25 retriever and attach your corpus_json to itsparse_index = bm25s.BM25(corpus=corpus_json)# Now, index the corpus_tokens (the corpus_json is not used yet)sparse_index.index(corpus_sparse_tokens)# Return 10 the most relevant docs according to the querysparse_results, sparse_scores = sparse_index.retrieve(query_tokens, k=10)

Dense向量主要是通过基于Transformer架构的embedding模型来进行编码生成,同时针对查询query,使用同样的embedding模型进行编码,然后再进行向量的相似度比对,找出最相似的n个结果:
#Dense Index# create the vector database clientqdrant =QdrantClient(path="/Users/jiangdanyang/workspaces/python/MarioPython/src/RAG/dataset/qdrant_data")# Create the embedding encoderdense_encoder = SentenceTransformer('/Users/jiangdanyang/workspaces/python/Model/all-MiniLM-L6-v2')collection_name = "hybrid_search"qdrant.recreate_collection(collection_name=collection_name,vectors_config=models.VectorParams(size=dense_encoder.get_sentence_embedding_dimension(),distance=models.Distance.COSINE))# vectorize!qdrant.upload_points(collection_name=collection_name,points=[models.PointStruct(id=idx,vector=dense_encoder.encode(doc["text"]).tolist(),payload=doc) for idx, doc in enumerate(corpus_json) # data is the variable holding all the enriched texts])query_vector = dense_encoder.encode(query).tolist()dense_results = qdrant.search(collection_name=collection_name,query_vector=query_vector,limit=10)

最后针对上述两种方式找出的chunk做综合筛选,这里可以有多种方式,比如比较常用的就是先分别对Sparse向量和Dense向量计算出来的top n个结果的分值做归一化,然后针对统一个Chunk,按照一定的权重(比如Sparse向量计算结果权重0.2,Dense向量计算结果权重0.8)计算一个最终分值,最后返回top n个chunk列表给到下个节点:
# Normalize the two types of scoresdense_scores = np.array([doc.get("dense_score", 0) for doc in documents_with_scores])sparse_scores = np.array([doc.get("sparse_score", 0) for doc in documents_with_scores])dense_scores_normalized = (dense_scores - np.min(dense_scores)) / (np.max(dense_scores) - np.min(dense_scores))sparse_scores_normalized = (sparse_scores - np.min(sparse_scores)) / (np.max(sparse_scores) - np.min(sparse_scores))alpha = 0.2weighted_scores = (1 - alpha) * dense_scores_normalized + alpha * sparse_scores_normalized

如果当前场景的检索需要兼顾关键词和语义的时候,可以考虑混合搜索(需要结合文档内容、chunking和关键字词构建等环节);相对于关键字词匹配检索,混合搜索可以降低查询编写的规范性(不一定要有特定的关键词出现)以及提升查询的容错性(可能会有拼写错误或者不恰当的描述);相对于语义相似检索,混合搜索可以增加一些领域专有信息的更精准匹配,提升检索结果的准确性。
检索的优点是可以在海量的知识里面快速找到和用户query相关的内容块docs,但是检索所返回出来的docs,实际上可能部分和用户query关联度并不大,这个时候就需要通过re-rank这一步,对于检索返回出来的docs做关联度排序,最终选取最相关的top k个doc,做后续的上下文补充。
在RAG链路中,ReRanking的常用技术是Cross-Encoder(交叉编码器),本质一个Bert模型(Encode-only的transformer架构),计算query和每一个doc相关性,返回0~1之间的结果(1代表最相关),示意图和代码示例如下:

from sentence_transformers import CrossEncodercross_encoder = CrossEncoder("/Users/jiangdanyang/workspaces/python/Model/jina-reranker-v1-tiny-en")hybrid_search_results = {}with open('/Users/jiangdanyang/workspaces/python/MarioPython/src/RAG/dataset/dense_results.json') as f:dense_results = json.load(f)for doc in dense_results:hybrid_search_results[doc['id']] = docwith open('/Users/jiangdanyang/workspaces/python/MarioPython/src/RAG/dataset/sparse_results.json') as f:sparse_results = json.load(f)for doc in sparse_results:hybrid_search_results[doc['id']] = docconsole.print(hybrid_search_results)# This is the query that we used for the retrieval of the above documentsquery = "What is context size of Mixtral?"pairs = [[query, doc['text']] for doc in hybrid_search_results.values()]scores = cross_encoder.predict(pairs)

最后进行排序,选择top k个结果补充到context中,然后调用模型拿最后的结果:
client = OpenAI( api_key=os.getenv("AI_API_KEY"), base_url=os.getenv("AI_API_BASE_URL"))completion = client.chat.completions.create( model="qwen_max", messages=[ {"role": "system", "content": "You are chatbot, an research expert. Your top priority is to help guide users to understand reserach papers."}, {"role": "user", "content": query}, {"role": "assistant", "content": str(search_results)} ])

AI应用的开发实践进行得非常火热,现阶段可能更多的是对已有的一些基建平台、开发编排工具、现成的横向基础产品做整合使用,结合使用场景做链路设计,但是随着时间推移,还是需要慢慢深入到部分细节,往深水区慢慢前行,本文讲述的RAG只是AI架构中的一块,其他相关的技术,在对待方式上也雷同,都需要经历快速使用、技术细节了解、使用产品实现了解、应用中的设计实现迭代、面向效果的循环优化,快速上手有捷径,得益于比较好的基础设施建设,成本比较低,但是深入追寻效果,切实提升效率或幸福感,需要更深入的探寻,希望对读到这里的小伙伴有帮助;

