
1. 项目概述为什么我们需要“听懂弦外之音”的搜索最近在折腾几个AI应用项目时我反复被一个核心问题卡住用户输入的问题和大模型内部的知识经常对不上“频道”。比如用户问“公司去年那个绿色环保的项目进展怎么样了”而你的知识库里只有一份名为《2023年度可持续发展报告》的PDF。传统的关键词匹配基本歇菜因为字面上一个都对不上。这就是“语义鸿沟”——用户用自然语言表达意图而机器只认识冷冰冰的关键词。RAG检索增强生成技术尤其是其中的语义搜索就是为了填平这道鸿沟而生的。它能让AI系统真正理解你问题的“弦外之音”从海量文档中精准找到相关片段再交给大模型生成准确回答。这个项目就是带你从零开始亲手搭建一个具备语义搜索能力的RAG系统。我们不依赖任何现成的、封装好的SaaS服务或高度集成的框架而是从最基础的组件开始组装。你会清晰地看到文本如何变成向量向量如何被存储和比对以及整个检索流程是如何串联起来的。通过这个过程你不仅能获得一个可运行的原型更能透彻理解RAG语义搜索的每一处关节未来无论面对何种定制化需求都能心中有数手中有术。2. 核心架构与组件选型解析一个典型的RAG语义搜索系统可以拆解为几个核心环节文档加载与处理、文本向量化Embedding、向量存储与检索、以及最终的答案生成。我们的目标是用最小化的可行组件搭建一个逻辑清晰、便于理解和扩展的管道。2.1 文档处理流水线设计文档处理是第一步也是最容易埋坑的地方。核心任务是将各种格式的原始文档PDF、Word、TXT、网页等转化为一段段纯净的、适合做向量化的文本。这里的关键在于“分块”Chunking。分块不是简单粗暴地按字数切割它需要平衡上下文完整性和检索精度。我常用的策略是采用“递归式分块”。首先我会尝试按文档的自然结构进行分割比如Markdown的标题、LaTeX的章节、或者PDF的段落。如果找不到明确分隔符再回退到按固定字符数例如1000个字符进行重叠式分割前后块重叠一部分比如200字符这样可以有效防止一个完整的句子或概念被生生切断。对于中文尤其要注意按句号、问号等标点进行分割避免在词组中间断开。注意分块大小没有黄金标准。小块如256字符检索精度高但可能丢失上下文大块如1024字符信息完整但可能引入噪声。需要根据你的文档类型和问题特点进行实验。我的经验是对于事实性问答小块效果更好对于需要概括、分析的问题大块更有优势。2.2 Embedding模型的选择与考量文本向量化是整个系统的“灵魂”。Embedding模型负责将一段文本映射为一个高维空间中的向量一组数字语义相似的文本其向量在空间中的距离如余弦相似度也更近。选型直接决定了搜索质量。目前开源社区有很多优秀的Embedding模型。对于中文场景我强烈推荐BAAI/bge-large-zh-v1.5或BAAI/bge-reranker-v2-m3。前者是通用的文本向量化模型在中文语义相似度任务上表现非常稳健后者是重排序模型可以在初步检索后对结果进行精排显著提升Top1结果的准确率。如果你的资源有限BAAI/bge-small-zh-v1.5是一个不错的轻量级选择。选择时你需要权衡“效果”、“速度”和“资源消耗”。大型模型如bge-large生成的向量维度高通常1024维表征能力强但计算慢、存储开销大。小型模型速度快、省资源但在处理复杂语义或专业术语时可能力有不逮。对于从零开始的实验我建议先用bge-small-zh跑通流程再根据效果升级到更大模型。2.3 向量数据库的轻量化实践向量数据库负责存储上一步生成的向量并提供高效的相似性搜索功能。对于个人项目或中小规模应用上马Milvus、Weaviate这类专业向量数据库可能有些“杀鸡用牛刀”。我们的原则是简单够用易于部署。这里我推荐两个方案ChromaDB一个轻量级、嵌入式的向量数据库API简单无需单独服务非常适合原型开发和中小规模数据万级文档以内。FAISSFacebook AI Similarity Search这是一个专注于高效相似性搜索和稠密向量聚类的库可以作为一个本地索引使用。它性能极高但需要自己管理元数据即文本块和对应向量的关联。在本项目中为了极致简化我们将采用FAISS 本地索引 SQLite/JSON 存储元数据的方案。FAISS负责海量向量下的快速近邻搜索而文本块、来源等元数据则用简单的结构化文件存储。这样整个系统没有任何外部依赖一个Python环境就能跑起来。3. 从零开始的逐步实现下面我们进入具体的实现环节。请确保你的Python环境在3.8以上并安装必要的库pip install sentence-transformers faiss-cpu pypdf langchain。langchain在这里我们只极简地使用其文档加载器其他环节手动实现以加深理解。3.1 第一步文档加载与文本分块假设我们有一个名为docs的文件夹里面存放着若干PDF文件。我们首先需要读取并分割它们。from langchain.document_loaders import DirectoryLoader, PyPDFLoader from langchain.text_splitter import RecursiveCharacterTextSplitter # 1. 加载文档 loader DirectoryLoader(./docs, glob**/*.pdf, loader_clsPyPDFLoader) documents loader.load() print(f共加载了 {len(documents)} 个文档) # 2. 初始化文本分割器 # 这里设置块大小为500重叠为50对于中文separators优先尝试按段落和句子分割 text_splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , , , 、, , ] ) # 3. 执行分割 all_splits text_splitter.split_documents(documents) print(f分割后得到 {len(all_splits)} 个文本块)这段代码运行后all_splits就是一个包含了所有文本块对象的列表每个对象都有page_content文本内容和metadata来源、页码等属性。实操心得chunk_size按字符数计算。一个中文字符算一个。实际分割后块的大小可能会略小于设定值因为分割器会在最后一个分隔符处截断以保证句子完整。重叠overlap非常必要它能防止关键信息恰好在边界丢失。3.2 第二步生成文本向量Embedding接下来我们使用选定的Embedding模型将每一个文本块转化为向量。from sentence_transformers import SentenceTransformer import numpy as np # 1. 加载Embedding模型 # 首次运行会下载模型请保持网络通畅 model SentenceTransformer(BAAI/bge-small-zh-v1.5) # 2. 准备文本列表 texts [split.page_content for split in all_splits] # 3. 批量生成向量 # 模型会自动处理批处理对于大量数据这是最高效的方式 print(正在生成文本向量...) embeddings model.encode(texts, normalize_embeddingsTrue) # normalize_embeddingsTrue 将向量归一化方便后续使用余弦相似度 print(f向量生成完成。形状{embeddings.shape}) # 应为 (文本块数量, 向量维度) # 4. 保存文本块和对应的向量 # 为了简单我们用两个文件分别存储 import pickle # 保存文本块和元数据 with open(text_chunks.pkl, wb) as f: # 存储一个列表每个元素是 (文本内容, 元数据) 的元组 data_to_save [(split.page_content, split.metadata) for split in all_splits] pickle.dump(data_to_save, f) # 保存向量为numpy格式供FAISS使用 np.save(embeddings.npy, embeddings)这里有几个关键点第一normalize_embeddingsTrue会将向量归一化为单位长度此时向量点积就等于余弦相似度这是最常用的相似度度量方式。第二我们将文本和向量分开存储因为FAISS索引只处理向量文本和元数据需要我们自己关联。3.3 第三步构建FAISS向量索引并实现检索现在我们有了向量和文本可以构建搜索索引了。import faiss import numpy as np # 1. 加载之前保存的向量 embeddings np.load(embeddings.npy) dimension embeddings.shape[1] # 向量维度 # 2. 创建FAISS索引 # 这里使用最基础的IndexFlatIP内积索引因为我们的向量是归一化的内积余弦相似度 index faiss.IndexFlatIP(dimension) # 将向量添加到索引中 index.add(embeddings.astype(float32)) # FAISS要求float32类型 print(f索引构建完成总计 {index.ntotal} 个向量。) # 3. 实现检索函数 def semantic_search(query, model, index, text_data, k5): 语义搜索函数 Args: query: 用户查询字符串 model: Embedding模型 index: FAISS索引 text_data: 文本数据列表每个元素是 (文本内容, 元数据) k: 返回最相似的k个结果 Returns: 包含相似文本块和得分的列表 # 将查询文本向量化 query_vector model.encode([query], normalize_embeddingsTrue).astype(float32) # 在索引中搜索 distances, indices index.search(query_vector, k) # 组织结果 results [] for i, (dist, idx) in enumerate(zip(distances[0], indices[0])): if idx ! -1: # 有效索引 text_content, metadata text_data[idx] results.append({ content: text_content, metadata: metadata, score: dist # 这里是余弦相似度越接近1越相似 }) return results # 4. 加载文本数据用于结果展示 with open(text_chunks.pkl, rb) as f: loaded_text_data pickle.load(f) # 5. 进行搜索测试 test_query 公司去年在环保方面做了什么 search_results semantic_search(test_query, model, index, loaded_text_data, k3) print(f\n查询{test_query}) for i, res in enumerate(search_results): print(f\n--- 结果 {i1} (相似度: {res[score]:.4f}) ---) print(f内容片段{res[content][:200]}...) # 打印前200字符 print(f来源{res[metadata]})至此一个最核心的语义检索系统已经完成了。你可以输入任何自然语言问题它都能返回语义上最相关的文档片段。IndexFlatIP是一种暴力搜索索引它计算查询向量与索引中所有向量的内积精度最高但数据量巨大时比如超过百万会变慢。对于更大规模的数据可以考虑使用IndexIVFFlat等量化索引来加速。3.4 第四步集成大模型生成最终答案简易版检索到相关片段后最后一步是将这些片段作为上下文连同用户问题提交给大语言模型LLM生成最终答案。这里我们以调用OpenAI API为例你也可以替换为本地部署的Qwen、ChatGLM等。# 假设已安装openai库 pip install openai from openai import OpenAI import os # 设置你的API Key (请替换为你的真实Key或从环境变量读取) os.environ[OPENAI_API_KEY] your-api-key-here client OpenAI() def generate_answer_with_context(query, search_results, modelgpt-3.5-turbo): 利用检索到的上下文生成答案 # 1. 组装上下文 context \n\n.join([f[片段{i1}]: {res[content]} for i, res in enumerate(search_results)]) # 2. 构造Prompt prompt f请基于以下提供的上下文信息回答用户的问题。如果上下文信息不足以回答问题请直接说“根据提供的信息无法回答该问题”。 上下文信息 {context} 用户问题{query} 请给出准确、简洁的答案 # 3. 调用LLM response client.chat.completions.create( modelmodel, messages[ {role: system, content: 你是一个专业的助手严格根据提供的上下文回答问题。}, {role: user, content: prompt} ], temperature0.1, # 低温度保证答案更确定更基于上下文 max_tokens500 ) return response.choices[0].message.content # 使用之前的搜索结果生成答案 if search_results: answer generate_answer_with_context(test_query, search_results) print(f\n 生成的最终答案 \n{answer}) else: print(未检索到相关上下文无法生成答案。)这个简易的生成环节核心是Prompt工程。我们明确指示模型“基于上下文回答”并设置了拒绝回答的兜底策略这能有效减少模型胡编乱造幻觉的情况。temperature参数调低是为了让输出更稳定、更依赖于上下文。4. 效果优化与高级技巧基础流程跑通后你会发现一些痛点为什么有时候搜不准答案为什么还是有点“飘”下面分享几个立竿见影的优化技巧。4.1 检索质量提升重排序Reranking第一步检索我们上面做的叫做“召回”它力求不遗漏任何相关文档因此可能会返回一些相关性稍差的结果。重排序就像一位严格的裁判对召回的结果进行精细打分和重新排序把最相关的那一两个提到最前面。这对于最终生成答案的质量至关重要因为LLM的上下文窗口有限我们通常只把Top1或Top2的结果喂给它。我们可以使用专门的交叉编码器Cross-Encoder模型来做重排序它在判断“查询-文档对”的相关性上比我们之前用的双编码器Bi-Encoder模型更精准但计算代价也更高。from sentence_transformers import CrossEncoder # 加载一个重排序模型 reranker CrossEncoder(BAAI/bge-reranker-v2-m3, max_length512) def rerank_search_results(query, initial_results, reranker, top_k3): 对初步检索结果进行重排序 if not initial_results: return [] # 准备模型输入[(query, passage1), (query, passage2), ...] model_inputs [(query, res[content]) for res in initial_results] # 获取相关性分数 scores reranker.predict(model_inputs) # 将分数与原始结果绑定并排序 for i, res in enumerate(initial_results): res[rerank_score] scores[i] # 按重排序分数降序排列 reranked_results sorted(initial_results, keylambda x: x[rerank_score], reverseTrue) return reranked_results[:top_k] # 使用示例先进行语义搜索再重排序 initial_hits semantic_search(test_query, model, index, loaded_text_data, k10) # 召回多一些 final_hits rerank_search_results(test_query, initial_hits, reranker, top_k3) print(重排序后的Top3结果) for res in final_hits: print(f分数{res[rerank_score]:.4f}, 内容{res[content][:100]}...)重排序模型输出的分数没有固定范围数值越大代表越相关。经过这一步喂给LLM的上下文质量会显著提升。4.2 上下文管理让LLM更聚焦即使我们提供了最相关的片段LLM有时还是会“走神”或者被片段中的无关细节带偏。这里有两个小技巧元数据过滤在检索时除了语义相似度还可以加入过滤器。例如如果知道用户问题只关心“2023年”的“财务报告”那么在检索时可以优先筛选metadata里包含year:2023和doc_type:finance的块。这需要在分块时就把这些信息存入元数据。Prompt优化在给LLM的指令中更加强调“严格依据”。可以尝试这样的Prompt模板“你是一个严谨的文档分析专家。请严格依据以下提供的上下文片段来回答问题。上下文中的每一段信息都标明了来源。你的答案必须完全基于这些上下文不得引入外部知识或进行推测。如果上下文中没有明确信息可以回答问题请直接回答‘根据所提供的上下文无法回答此问题’。请先判断问题是否可答再生成答案。”4.3 系统扩展与性能考量当你的文档库从几百个增长到几十万个文本块时基础的IndexFlatIP就会遇到性能瓶颈。此时需要考虑索引升级FAISS提供了IndexIVFFlat或IndexIVFPQ等索引类型。它们的工作原理是先对向量空间进行聚类聚类中心称为voronoi cells搜索时只查询查询向量所在的那个或那几个聚类里的向量大大减少了计算量。构建这类索引需要额外的训练步骤。nlist 100 # 聚类中心数量 quantizer faiss.IndexFlatIP(dimension) index_ivf faiss.IndexIVFFlat(quantizer, dimension, nlist, faiss.METRIC_INNER_PRODUCT) index_ivf.train(embeddings.astype(float32)) # 训练索引 index_ivf.add(embeddings.astype(float32)) # 添加向量 index_ivf.nprobe 10 # 搜索时探查的聚类数平衡速度和精度元数据管理当文本块数量巨大时用pickle或JSON文件管理元数据会变得笨重。可以考虑迁移到轻量级数据库如SQLite为每个文本块建立ID并与向量索引的ID对应。异步处理文档加载、分块、向量化都是耗时操作。在生产环境中这些应该设计成异步任务队列例如使用Celery避免阻塞主服务。5. 常见问题与排查实录在实际搭建过程中你肯定会遇到各种奇怪的问题。这里记录了几个我踩过的坑和解决方案。5.1 检索结果完全不相关症状无论问什么返回的文本块都风马牛不相及。排查思路检查Embedding模型确认你使用的模型是否支持中文如果文档是中文并且是否适合你的任务类型检索 vs 聚类 vs 句子相似度。用model.encode([苹果, 水果])和model.encode([苹果, iPhone])测试一下看前者的相似度是否显著高于后者。检查向量归一化确保索引构建index faiss.IndexFlatIP(dimension)和搜索时向量都经过了归一化normalize_embeddingsTrue。如果索引用的是内积IP度量但向量没归一化结果会完全错误。检查文本预处理查看你的文本块内容是否干净。是否包含了大量无意义的页眉、页脚、页码或乱码这些噪声会严重影响向量表征。增加一个文本清洗的步骤比如移除过多的换行符、特殊字符等。5.2 生成答案胡编乱造幻觉症状检索的片段是对的但LLM生成的答案却凭空捏造事实。排查思路强化Prompt约束这是最常见的原因。在你的系统指令system message和用户Prompt中反复、明确地强调“严格依据上下文”。使用“必须”、“不得”、“只能”等强约束性词语。并设置好“无法回答”的兜底输出。减少上下文长度给LLM的上下文是不是太长了或者包含了多个可能含有矛盾信息的片段尝试只提供相似度最高的那一个片段top_k1看看幻觉是否减少。LLM在处理过长或复杂上下文时容易“迷失”。检查上下文相关性你以为检索到的片段是相关的但可能只是语义上“沾边”并没有包含问题答案的直接事实。此时LLM只能基于“沾边”的信息进行推测从而产生幻觉。可以尝试使用更强大的重排序模型或者调整分块策略让每个文本块的信息更集中、完整。5.3 处理速度太慢症状从提问到出答案需要等待十几秒甚至更久。排查思路定位瓶颈使用简单的计时分别记录encode向量化、index.search检索、LLM调用三个阶段的时间。瓶颈通常出现在其中一个。向量化慢考虑使用更小的Embedding模型如bge-small或者对查询进行缓存。对于相对静态的文档库可以预先计算所有文档向量避免实时计算。检索慢文档库大了之后必须将IndexFlatIP升级为IndexIVFFlat等索引。nprobe参数控制精度和速度的平衡增大它提高精度但降低速度。LLM调用慢这通常是主要延迟来源。可以考虑使用更快的模型如gpt-3.5-turbovsgpt-4设置合理的超时和重试或者对于简单、重复性问题构建一个答案缓存。5.4 如何评估系统效果搭建好了怎么知道它好不好不能只靠感觉。可以构建一个简单的评估集构造测试集从你的文档中人工提炼出20-50个“问题-答案”对。确保答案确实存在于文档中。定义评估指标检索命中率对于每个问题系统检索到的Top3片段中是否包含了正确答案所在的片段答案准确率系统生成的最终答案与标准答案在语义上是否一致这需要人工或更复杂的NLP模型判断迭代优化根据评估结果有针对性地调整分块大小、重叠长度、Embedding模型、重排序模型、Prompt模板等观察指标变化。从零实现一个RAG语义搜索系统就像搭乐高。我们一步步地组装了文档加载器、文本分割器、Embedding模型、向量索引和LLM接口。这个过程最重要的不是得到一个能跑的工具而是在每一步中你都能清晰地知道数据是如何流动的每个组件为何如此选择以及当效果不如预期时应该从哪个环节入手去调试和优化。我个人的体会是RAG的“调优”是一个系统工程往往牵一发而动全身。改一个分块策略可能就需要重新生成所有向量换一个Embedding模型整个索引都要重建。所以在初期就建立一个可复现、可评估的流水线至关重要。先追求流程跑通再针对最痛的痛点进行优化用小的测试集快速验证想法避免在错误的方向上投入过多时间。这个自己亲手搭建的系统或许界面简陋但它的每一行代码你都了如指掌这将是你在AI应用开发路上最扎实的底气。