RAG知识库搭建全流程解析:从数据爬取到向量检索的工程实践
1. 项目概述:为什么RAG知识库搭建是个精细活儿
最近和几个做AI应用的朋友聊天,发现大家一提到RAG(检索增强生成),眼睛都放光,觉得这是让大模型“说真话”、解决幻觉问题的银弹。但真上手去搭一个,从爬数据开始,到最终能稳定、准确地回答用户问题,中间踩的坑能写满一本错题集。这个项目标题“从零搭建 RAG 知识库:爬虫→分词→向量化→检索,一步都不能错”,可以说精准地戳中了所有实践者的痛点。它不是一个简单的流程串联,而是一个环环相扣、每一步都充满技术选型和细节打磨的系统工程。
我自己在搭建企业内部知识库、行业资讯分析系统时,反复验证过这个流程。RAG的核心价值,在于为LLM提供一个精准、可靠的外部知识“记忆体”。但这个记忆体的质量,直接决定了最终回答的靠谱程度。你可以把整个过程想象成给一位博闻强识但记忆模糊的学者(LLM)建造一个私人图书馆。爬虫是采购员,负责从各处搜集书籍(数据);分词和向量化是图书管理员,负责给书籍编目、贴标签、做摘要,把非结构化的文本变成机器能理解的索引卡片;检索则是学者的助手,当学者被问到问题时,助手能快速从海量索引卡片中找到最相关的那几本,递给学者参考。
这个链条里,任何一环的粗糙处理,都会导致“垃圾进,垃圾出”。爬虫抓得不全或格式混乱,后续处理就无从谈起;分词分得不好,语义被割裂,向量化就成了“歪曲事实”;向量模型选得不对,或者索引建得不好,检索召回的就是一堆不相关的文档;最后,即使召回了,如果排序策略不行,最关键的答案也可能被埋没在噪音里。所以,标题强调“一步都不能错”,绝非危言耸听,而是血泪教训的总结。接下来,我就结合实战,把这四步拆开揉碎了讲清楚,尤其是那些容易踩坑、文档里不会写的细节。
2. 第一步:爬虫——数据源的“清洁度”决定天花板
很多人觉得爬虫就是requests加BeautifulSoup,把HTML拖下来就完事了。但在RAG的语境下,爬虫的目标不是“抓到数据”,而是“抓到干净、结构化的高质量文本数据”。你的下游所有工序,都在为这一步的成果买单。
2.1 爬虫策略与工具选型:不只是Python
提到爬虫,第一反应是Python的Scrapy或requests+parsel组合。这没错,但对于RAG项目,我们需要更全面地考虑:
静态页面:对于新闻网站、博客、文档站(如许多开源项目的Docs),
requests/httpx配合BeautifulSoup或parsel(速度更快)是经典选择。重点在于编写健壮的CSS选择器或XPath,并处理好编码问题。import httpx from parsel import Selector async def fetch_page(url): async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: try: resp = await client.get(url, headers={'User-Agent': 'Mozilla/5.0 ...'}) resp.raise_for_status() return resp.text except Exception as e: print(f"Failed to fetch {url}: {e}") return None def parse_content(html): selector = Selector(text=html) # 核心:精准定位正文,剔除导航、广告、评论、页脚 main_content = selector.css('article .post-content ::text').getall() # 示例选择器 # 更通用的策略:通过标签密度、聚类算法识别正文区域 text = ' '.join([txt.strip() for txt in main_content if txt.strip()]) return text注意:不要依赖单一的标签选择器。不同网站结构千差万别。一个实战技巧是使用
readability库或trafilatura这类专门用于正文提取的工具,它们通过算法识别网页核心内容区域,成功率远高于手写规则。动态渲染页面:越来越多的网站(如单页应用SPA)内容由JavaScript动态加载。这时
requests只能拿到空壳。解决方案是:- Selenium/Playwright:模拟浏览器行为,功能强大但重量级,适合复杂交互(如登录、点击)。资源消耗大,不适合大规模爬取。
- Pyppeteer/Puppeteer (Node.js):无头Chrome控制库,比Selenium更轻量高效。
- 逆向工程API:最高效的方法。打开浏览器开发者工具(F12),切换到Network(网络)标签,刷新页面,观察XHR/Fetch请求。直接找到数据接口,用
requests模拟调用。这需要一些耐心,但一旦成功,速度和稳定性极佳。
特定平台爬虫:如微信公众号、小红书、抖音等。这些平台反爬严格,通常需要:
- 模拟登录:获取Cookie或Token。
- 调用官方或非官方API:有些平台有未公开的移动端API,通过抓包分析获得。
- 使用现成的SDK或工具:例如
wechatpy(微信)、inscrapper(Instagram)等,但要注意合规性和稳定性。 - 重要提示:爬取此类数据务必严格遵守
robots.txt协议和平台用户协议,评估法律风险,仅用于个人学习研究,且控制请求频率,避免对目标服务器造成压力。
2.2 数据清洗与标准化:容易被忽视的“脏活”
爬下来的原始文本通常包含大量噪音,直接丢给分词模型会严重影响后续效果。
去除无关元素:
- HTML/JS/CSS标签:用
BeautifulSoup.get_text()或正则表达式彻底清除。 - 广告、推荐阅读、版权声明:通过常见的文本模式(如“猜你喜欢”、“相关阅读”、“Copyright”)进行过滤。
- 导航栏、页眉页脚:在解析阶段就应剔除。
- 特殊字符和乱码:使用
unicodedata.normalize()进行Unicode规范化,并用正则过滤非目标语言的字符集。
- HTML/JS/CSS标签:用
文本规范化:
- 统一编码:确保全部文本为UTF-8。
- 全角转半角:中文环境下,将全角字母、数字、符号转换为半角,保证一致性。
- 日期、数字格式标准化:将“2023年12月1日”、“2023-12-01”、“12/1/2023”统一为一种格式,便于后续可能的信息抽取。
- 去除多余空白:将连续的换行符、空格、制表符压缩为单个空格或标准段落分隔。
质量过滤:
- 长度过滤:剔除过短(如少于20字符)的文本块,这可能是导航碎片或广告。
- 语言检测:如果你的知识库只针对中文,使用
langdetect库过滤掉非中文内容。 - 去重:对高度相似或完全相同的文本进行去重,避免在向量库中引入冗余。可以使用SimHash或MinHash算法进行近似去重。
实操心得:建立一个可配置的清洗管道(Pipeline)至关重要。我为每个数据源定义了一套清洗规则,并保存清洗前的原始文本和清洗后的版本,方便回溯和调试。清洗效果直接影响分词和嵌入的质量,这里多花一天时间,后面能省下一周的调试功夫。
3. 第二步:文本分词与切片——如何让机器“读懂”段落
清洗后的文本是连续的字符串,我们需要将其切割成适合向量化和检索的片段(Chunks)。这一步直接决定了检索的粒度。
3.1 分词(Word Segmentation)与分句
对于中文RAG,分词是基础。但这里的“分词”更多指的是为后续的语义切片做准备,而不是简单的词语切分。
工具选择:
- Jieba:最常用的中文分词库,速度快,自定义词典功能强大。适合作为基础分词器。
- HanLP:功能更全面,除了基础分词,还提供词性标注、命名实体识别(NER)、依存句法分析等。在Spring Boot等Java生态中集成良好(如
hanlp-spring-boot-starter)。如果你的知识库涉及大量实体(人名、地名、机构名、专业术语),HanLP的NER能帮你更好地识别和保留关键信息。 - PKUSeg、LTP:北大和哈工大的工具,在学术文本上分词准确率较高。
- 大模型分词:使用ChatGLM、Qwen等模型的tokenizer进行分词,可以保证与后续使用的LLM在词汇表上对齐,但速度较慢,适合对齐要求高的场景。
# 使用Jieba进行基础分词和关键词提取 import jieba import jieba.analyse text = "这是一段关于人工智能技术的示例文本。" # 精确模式分词 words = jieba.lcut(text, cut_all=False) # 提取TF-IDF关键词 keywords = jieba.analyse.extract_tags(text, topK=5, withWeight=False) # 使用TextRank算法提取关键词 keywords_tr = jieba.analyse.textrank(text, topK=5, withWeight=False)分句(Sentence Splitting):将文本按句号、问号、感叹号等分割成独立的句子。这是更细粒度的切片基础。可以使用简单的正则,也可以使用更智能的库如
pysbd(适用于多种语言)。
3.2 知识切片(Chunking)策略:核心中的核心
这是RAG项目成败的关键技术点之一。切片太大,会引入无关噪声,降低检索精度;切片太小,会丢失上下文,导致语义不完整。
固定长度重叠切片:最常用、最稳定的方法。使用字符数或token数作为窗口大小,并设置一个重叠区域(overlap),以保持上下文连贯。
from langchain.text_splitter import RecursiveCharacterTextSplitter # LangChain提供的递归字符文本分割器,效果很好 text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, # 目标块大小(字符数) chunk_overlap=50, # 块之间的重叠字符数 length_function=len, separators=["\n\n", "\n", "。", ";", ",", " ", ""] # 按此优先级尝试分割 ) chunks = text_splitter.split_text(long_text)- chunk_size选择:一般建议在256-1024个字符(或tokens)之间。需要权衡:较小的size(如256)检索更精准,但可能信息不全;较大的size(如1024)信息完整,但可能包含无关内容。一个经验是,让你的块大小大致等于你期望LLM在生成答案时参考的上下文长度。
- chunk_overlap选择:通常为chunk_size的10%-20%。重叠是为了防止一个完整的句子或概念被生硬地切到两个块中,导致检索时只召回一半。
基于语义的切片:更高级的方法,试图在语义边界(如段落、章节)处进行切割。可以使用:
- 自然断点:优先在
\n\n(空行)、标题标记(#、##)、<p>标签等处切割。 - 语义分割模型:使用预训练模型判断哪里是自然的语义边界。这更智能,但计算成本高,且模型本身可能引入误差。
- 自然断点:优先在
混合切片(Hybrid Chunking):对于结构清晰的文档(如PDF、Markdown),采用分层切片。先按章节/标题切大块,再在大块内按固定长度切小块。为每个块保留其层级信息(如
{“chapter”: “3”, “section”: “3.1”, “content”: “...”}),检索时可以利用层级进行过滤或加权。
注意事项:
- 不要破坏表格和代码:对于技术文档,表格和代码块应被视为一个整体,不应被切开。需要在分割前进行预处理,将这些部分标记并保护起来。
- 保留元数据:为每个切片(chunk)附加来源信息,如URL、文件名、标题、时间戳、作者等。这些元数据在后续检索和结果展示中非常有用。
- 测试你的切片:随机抽样一些切片,人工阅读,检查其语义是否完整,重叠是否合理。这是调试切片策略最直接有效的方法。
4. 第三步:向量化(嵌入)——将文本映射到语义空间
向量化,也叫嵌入(Embedding),是把文本切片转换成高维空间中的向量(一组数字)。语义相似的文本,其向量在空间中的距离(如余弦相似度)也相近。这是实现语义检索的数学基础。
4.1 嵌入模型选型:开源与闭源的权衡
开源模型(本地部署):
- BGE(BAAI General Embedding)系列:智源研究院出品,如
BGE-large-zh、BGE-small-zh,在中文语义相似度任务上表现SOTA(最先进),且针对检索任务进行了优化。这是目前中文RAG项目的首选。 - Sentence-BERT(SBERT):有中文变体
paraphrase-multilingual-MiniLM-L12-v2,在多语言场景下表现稳健。 - M3E系列:专门为中文优化的嵌入模型,在部分中文评测集上表现不错。
- 开源模型优势:数据隐私安全、可离线运行、定制化微调、无调用费用。
- 劣势:需要本地GPU或CPU资源,推理速度取决于硬件;模型管理需要自行负责。
- BGE(BAAI General Embedding)系列:智源研究院出品,如
闭源API(在线服务):
- OpenAI
text-embedding-3系列:性能强大且稳定,使用简单。 - 百度文心、阿里通义、智谱ChatGLM等国内厂商的嵌入API:符合数据合规要求。
- API优势:免运维,性能稳定,随模型升级自动更新。
- 劣势:有调用成本和数据出境风险(对于国外API),存在网络延迟和速率限制。
- OpenAI
选型建议:对于企业内部或对数据安全要求高的项目,优先选择BGE这类优秀的开源模型在本地部署。对于快速原型验证或中小规模应用,可以考虑国内大厂的嵌入API。一个折中方案是:用开源模型处理核心敏感数据,用API处理非敏感或公开数据。
4.2 嵌入实践与优化
批量处理与性能:调用嵌入模型是耗时的。务必使用批量推理(batch inference)来提升效率。
from sentence_transformers import SentenceTransformer import numpy as np model = SentenceTransformer('BAAI/bge-large-zh-v1.5') # 准备文本列表 texts = ["段落1", "段落2", ...] # 批量编码,指定batch_size embeddings = model.encode(texts, batch_size=32, normalize_embeddings=True, show_progress_bar=True) # embeddings 是一个 numpy 数组, shape 为 (文本数, 向量维度)normalize_embeddings=True:将向量归一化为单位向量。强烈建议开启,这样后续计算余弦相似度就简化为点积(np.dot(a,b)),计算更快,且余弦相似度范围在[-1,1]或[0,1](归一化后)。
向量维度:不同模型的输出维度不同(如BGE-large是1024维,OpenAI text-embedding-3-small是1536维)。这会影响向量数据库的存储和检索效率,但通常更高维度的模型表征能力更强。选择时需权衡效果和成本。
指令微调模型的使用:像
BGE这类为检索优化的模型,通常经过了指令微调。这意味着在编码查询(query)时,需要添加指令前缀,而在编码被检索的文档(passage)时则不需要或使用不同的前缀,以达到最佳效果。# 对于BGE模型 query = "什么是机器学习?" passages = ["机器学习是人工智能的一个分支...", "深度学习是机器学习的一种..."] # 编码查询时添加指令 query_embedding = model.encode([f"为这个句子生成表示以用于检索相关文章:{query}"], normalize_embeddings=True) # 编码文档时不需要(或使用其他指令,具体看模型说明) passage_embeddings = model.encode(passages, normalize_embeddings=True)务必查阅所用模型的官方文档或Hugging Face页面,了解其推荐的编码方式,这是发挥模型性能的关键。
5. 第四步:检索与排序——从海量向量中精准定位
有了高质量的向量,下一步就是建立索引,并实现快速准确的检索。检索系统通常分为“召回”(Recall)和“排序”(Rerank)两步。
5.1 向量数据库选型与索引构建
向量数据库负责高效存储向量,并提供近似最近邻(ANN)搜索。选型考虑因素:性能、易用性、社区生态、云服务支持。
| 数据库 | 特点 | 适用场景 |
|---|---|---|
| Milvus | 功能全面,性能强劲,生态丰富,支持标量过滤、动态Schema。 | 大规模、高并发生产环境,需要复杂过滤和混合搜索。 |
| Chroma | 轻量级,API简单,与LangChain集成极佳,入门快。 | 原型开发、小规模项目、快速验证想法。 |
| Qdrant | Rust编写,性能好,支持丰富的数据类型和过滤条件,有云服务。 | 对性能和过滤有较高要求的生产环境。 |
| PGVector | PostgreSQL的扩展,利用现有PG生态,支持ACID。 | 已在使用PostgreSQL,希望简化技术栈。 |
| Weaviate | 集成了向量、图、关键词搜索,自带模块化设计。 | 需要结合多种搜索方式或图关系的复杂应用。 |
以Milvus为例的索引构建流程:
- 连接与集合(Collection)创建:定义集合的Schema,包括向量字段和元数据字段(如id、text、source等)。
- 选择索引类型:常用的有
IVF_FLAT(平衡精度与速度)、HNSW(高召回率高速度,内存占用大)、SCANN(磁盘索引,内存占用小)。对于中等规模数据(百万级),IVF_FLAT是不错的选择。 - 插入数据:将文本切片、其对应的向量和元数据,批量插入集合。
- 加载集合:在搜索前,需要将集合加载到内存。
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType # 1. 连接 connections.connect(host='localhost', port='19530') # 2. 定义Schema fields = [ FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True), FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535), FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1024), # 维度与模型匹配 FieldSchema(name="source", dtype=DataType.VARCHAR, max_length=255), ] schema = CollectionSchema(fields, description="知识库文档集合") # 3. 创建集合 collection_name = "rag_knowledge_base" collection = Collection(name=collection_name, schema=schema) # 4. 创建索引 index_params = { "index_type": "IVF_FLAT", "metric_type": "IP", # 内积,因为我们的向量是归一化的,内积=余弦相似度 "params": {"nlist": 1024} # 聚类中心数,值越大搜索越准越慢,通常取 sqrt(数据量) } collection.create_index(field_name="embedding", index_params=index_params) # 5. 插入数据(假设`chunks`是文本列表,`embeddings`是向量列表,`metas`是元数据列表) data = [ chunks, # 文本 embeddings.tolist(), # 向量,转为list [meta['source'] for meta in metas] # 元数据,例如来源 ] collection.insert(data) collection.load() # 加载到内存5.2 多路召回与混合检索
单一的向量检索(语义检索)有时会漏掉一些关键词匹配但语义稍远的文档。因此,工业级RAG系统常采用“多路召回”策略。
向量检索(语义召回):如上所述,利用余弦相似度查找语义最接近的片段。
关键词检索(稀疏召回):使用BM25、TF-IDF等算法。它更注重关键词的精确匹配,能有效召回那些包含查询关键词但语义嵌入可能不近的文档(例如,包含特定产品型号、错误代码、人名)。
- BM25:是TF-IDF的改进版,考虑了文档长度归一化,效果更好。可以使用
rank_bm25库实现。
from rank_bm25 import BM25Okapi import jieba # 准备语料库(分好词的文档列表) tokenized_corpus = [list(jieba.cut(doc)) for doc in chunks] bm25 = BM25Okapi(tokenized_corpus) # 对查询进行分词和检索 query = "如何解决网络连接超时?" tokenized_query = list(jieba.cut(query)) doc_scores = bm25.get_scores(tokenized_query) top_keyword_indices = np.argsort(doc_scores)[::-1][:10] # 取BM25分数最高的10个- BM25:是TF-IDF的改进版,考虑了文档长度归一化,效果更好。可以使用
混合检索(Hybrid Search):将向量检索和关键词检索的结果融合。最简单的方法是加权求和(Reciprocal Rank Fusion, RRF 是更鲁棒的方法)。
def hybrid_search(query, vector_collection, bm25_index, tokenized_corpus, top_k=10, alpha=0.5): # 1. 向量检索 query_embedding = model.encode([f"为这个句子生成表示以用于检索相关文章:{query}"]) vector_results = vector_collection.search(query_embedding, anns_field="embedding", param={"nprobe": 10}, limit=top_k) vector_ids = [hit.id for hit in vector_results[0]] vector_scores = [hit.score for hit in vector_results[0]] # 2. 关键词检索 tokenized_query = list(jieba.cut(query)) bm25_scores = bm25_index.get_scores(tokenized_query) top_bm25_indices = np.argsort(bm25_scores)[::-1][:top_k] # 3. 融合(简单加权,实际可用RRF) fused_scores = {} # 为向量检索结果赋分 for vid, vscore in zip(vector_ids, vector_scores): fused_scores[vid] = alpha * vscore # 为BM25检索结果赋分(需归一化到与向量分数相近的范围) max_bm25 = max(bm25_scores) if max(bm25_scores) > 0 else 1 for idx in top_bm25_indices: normalized_score = bm25_scores[idx] / max_bm25 fused_scores[idx] = fused_scores.get(idx, 0) + (1 - alpha) * normalized_score # 4. 按融合分数排序 sorted_items = sorted(fused_scores.items(), key=lambda x: x[1], reverse=True) final_ids = [item[0] for item in sorted_items[:top_k]] return final_idsalpha参数控制语义检索和关键词检索的权重,通常需要在一个验证集上调试。
5.3 重排序(Reranking):提升精度的最后一道关卡
召回(Recall)阶段我们可能找回了100个相关文档,但其中真正有用的可能只有前5个。重排序的目标是利用一个更精细的模型(通常是交叉编码器,Cross-Encoder),对召回的Top N个结果进行两两比较或与查询进行精细匹配,重新打分排序,将最相关的结果推到最前面。
为什么需要重排序?:用于召回的向量模型(双编码器,Bi-Encoder)为了效率,将查询和文档独立编码,损失了一些交互信息。而交叉编码器将查询和文档同时输入模型,进行深度的注意力交互,判断相关性更准确,但速度慢,只适合对少量候选进行精排。
重排序模型:
- BGE-Reranker:智源推出的中文重排序模型,与BGE嵌入模型是绝配。
- Sentence-BERT Cross-Encoder:也有多语言版本。
from FlagEmbedding import FlagReranker reranker = FlagReranker('BAAI/bge-reranker-large', use_fp16=True) # 使用FP16加速 query = "如何配置Python虚拟环境?" retrieved_docs = ["文档A的文本...", "文档B的文本...", ...] # 从召回阶段获得 # 准备 (query, document) 对 pairs = [(query, doc) for doc in retrieved_docs] # 计算相关性分数 scores = reranker.compute_score(pairs) # 返回一个分数列表 # 根据分数对retrieved_docs重新排序 reranked_indices = np.argsort(scores)[::-1] final_docs = [retrieved_docs[i] for i in reranked_indices]重排序的位置:在混合召回得到Top K(例如K=50)个候选文档后,使用重排序模型对这50个文档进行精排,选出最终的Top M(例如M=5)个文档,送入LLM生成答案。
6. 全链路集成与效果调优
将以上四个模块串联起来,就形成了一个完整的RAG流水线。但搭建完成只是开始,持续的调优才是让系统变得好用的关键。
6.1 流水线搭建与框架选择
你可以完全从零手写这个流水线,但对于快速开发和维护,使用框架是更明智的选择。
LangChain/LlamaIndex:这两个是当前最流行的RAG应用框架。
- LangChain:更像一个“胶水”框架,提供了丰富的组件(Document Loaders, Text Splitters, Vector Stores, Retrievers, Chains),灵活性极高,你可以自由组合。学习曲线稍陡,但能深度定制。
- LlamaIndex:更专注于RAG和数据索引,提供了更高级的检索抽象(如索引、查询引擎),开箱即用的体验更好,对复杂查询(带过滤、汇总)支持更友好。
- 选择建议:如果你需要高度定制化,或者你的应用逻辑非常复杂,LangChain可能更适合。如果你希望快速构建一个标准且高效的RAG系统,LlamaIndex可能更省心。
核心流程代码示意(以LangChain为例):
from langchain_community.document_loaders import WebBaseLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_huggingface import HuggingFaceEmbeddings from langchain_community.vectorstores import Milvus from langchain.chains import RetrievalQA from langchain_community.llms import ChatGLM # 示例,可用其他LLM # 1. 加载文档 loader = WebBaseLoader("https://example.com/doc") documents = loader.load() # 2. 分割文本 text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50) docs = text_splitter.split_documents(documents) # 3. 嵌入并存入向量库 embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-large-zh-v1.5") vector_store = Milvus.from_documents( docs, embeddings, connection_args={"host": "localhost", "port": "19530"}, collection_name="my_rag_collection" ) retriever = vector_store.as_retriever(search_kwargs={"k": 5}) # 创建检索器 # 4. 构建QA链 llm = ChatGLM(endpoint_url="http://localhost:8000", max_tokens=2048) qa_chain = RetrievalQA.from_chain_type( llm=llm, chain_type="stuff", # 还有其他如map_reduce, refine等 retriever=retriever, return_source_documents=True ) # 5. 提问 result = qa_chain.invoke({"query": "什么是RAG?"}) print(result["result"]) print("来源:", [doc.metadata.get("source") for doc in result["source_documents"]])
6.2 效果评估与迭代调优
一个RAG系统的好坏,不能凭感觉,需要量化评估。
评估指标:
- 检索阶段:
- 命中率(Hit Rate):在Top K个检索结果中,至少包含一个正确答案文档的比例。
- 平均倒数排名(MRR):正确答案在检索结果中排名的倒数的平均值。衡量系统把正确答案排在前面的能力。
- 生成阶段:
- 忠实度(Faithfulness):生成的答案是否严格基于检索到的文档,有没有“胡编乱造”(幻觉)。可以用LLM本身或专门模型来评判。
- 答案相关性(Answer Relevance):生成的答案是否直接回答了问题。
- 人工评估:最终的金标准。设计一批测试问题,让人来评判答案的准确性、有用性和流畅性。
- 检索阶段:
迭代调优的闭环:
- 收集bad cases:记录系统回答错误或不好的问题。
- 归因分析:是检索没找到相关文档?还是文档找到了但排序不对?还是LLM在生成时误解了文档?
- 针对性优化:
- 检索问题:调整切片策略(chunk_size/overlap)、尝试混合检索、调整重排序模型、优化查询(对用户原始查询进行改写或扩展,即Query Rewriting/Expansion)。
- 生成问题:优化提示词(Prompt),明确要求LLM“严格根据给定上下文回答”,并设计更好的上下文组织方式(如将最相关的文档放在最前面)。
一个关键的提示词技巧:在给LLM的Prompt中,明确指令和上下文格式至关重要。
你是一个专业的助手,请严格根据以下提供的上下文信息来回答问题。如果上下文中的信息不足以回答问题,请直接说“根据已知信息无法回答该问题”,不要编造信息。 上下文: {context} 问题:{question} 请根据上下文回答:同时,可以在{context}中,按照相关性从高到低排列文档,并在每个文档前加上[文档1]、[文档2]的标识,有助于LLM更好地利用信息。
7. 常见问题与避坑指南
在实际搭建过程中,你会遇到各种各样的问题。这里记录一些高频问题和解决思路。
| 问题现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
| 检索结果完全不相关 | 1. 嵌入模型不适合领域。 2. 文本切片不合理,破坏了语义。 3. 查询未用指令格式化(针对BGE等模型)。 4. 向量索引类型或参数设置不当。 | 1. 用领域内句子对测试嵌入模型相似度。 2. 人工检查切片,调整 chunk_size和overlap。3. 确认查询编码格式符合模型要求。 4. 检查索引的 metric_type(应为IP或L2),调整nprobe等搜索参数。 |
| LLM回答出现幻觉,不依据上下文 | 1. 检索到的上下文本身不相关或质量差。 2. Prompt指令不够强硬,LLM自由发挥。 3. 上下文太长或格式混乱,LLM无法有效处理。 | 1. 先检查检索阶段返回的文档是否真的相关。 2. 强化Prompt,使用“严格根据”、“必须引用”等措辞,并让LLM指出引用来源。 3. 尝试 map_reduce或refine等链式方式处理长上下文,或对检索到的文档进行摘要后再输入。 |
| 系统响应速度慢 | 1. 嵌入模型推理慢(未用GPU或批处理)。 2. 向量索引未加载到内存或ANN参数 nprobe太大。3. LLM生成速度慢。 4. 网络延迟(使用远程API时)。 | 1. 使用GPU推理,并增大encode的batch_size。2. 确认集合已 load(),适当调小nprobe以平衡速度与精度。3. 考虑使用更小的LLM,或启用流式输出改善体验。 4. 考虑缓存频繁查询的嵌入或结果。 |
| 对于简单事实性问题效果好,复杂推理问题差 | 1. 检索粒度太细,单个切片无法提供完整推理链条。 2. 未实现多跳检索(Multi-hop Retrieval)。 | 1. 尝试增大chunk_size,或采用基于章节的切片策略。2. 实现迭代检索:用LLM从第一轮答案中提炼出新的查询,进行第二轮检索,如此循环。 |
| 如何处理文档更新? | 全量重建向量库成本高。 | 1.增量更新:为新文档生成向量并插入,为已删除文档标记为无效(软删除)。定期(如每周)合并增量,重建索引以优化性能。 2. 使用支持动态更新的向量数据库(如Milvus、Qdrant)。 |
最后一点个人体会:RAG系统是一个典型的“木桶效应”工程,最终效果取决于最短的那块板。不要只盯着最炫的LLM或最复杂的检索算法,往往花时间把最基础的数据清洗和文本切片做好,整个系统的效果就能提升一个档次。从简单的流程开始,构建一个可工作的最小版本(MVP),然后基于真实的用户问题和bad cases,有针对性地、一步一步地去迭代优化每一个环节,这才是搭建一个健壮RAG知识库的正道。