ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

RAG实战:LangChain+FAISS本地部署与性能调优指南

2026/10/2 5:51:21 拓冰建站 浏览量
RAG实战:LangChain+FAISS本地部署与性能调优指南 1. 这不是又一个RAG概念课——它是一份能让你在面试桌上把茶杯放下、直视面试官眼睛说清楚的实操笔记“RAG是什么”“RAG和微调有什么区别”“你项目里用的检索模块召回率怎么算的FAISS索引类型选的是IVF还是HNSW为什么”“LangChain的Retriever和Chain怎么配合中间加个reranker会不会拖慢响应加在哪一层”这些问题我带过27个实习生、参与过41场技术终面、自己也被问过19次——每次看到候选人张嘴说“RAG就是把文档喂给大模型”我就知道他没跑通哪怕一个最小闭环。不是不会背定义是根本没亲手拆过那根“检索-增强-生成”的链条。今天这篇不讲PPT式定义不列三段式优势总结不堆砌“提升幻觉抑制”“增强事实一致性”这种正确但空洞的套话。我们从一个真实面试场景切入你刚被问到“请现场画出你做的RAG系统数据流并说明每个环节的耗时瓶颈”而你手边只有一台装了Python的笔记本。接下来15分钟你要用真实可运行的代码可复现的本地数据可测量的指标把整个链路跑通、测准、讲透。这就是本文全部内容——它是一份带体温的工程笔记不是教科书也不是教程合集。核心关键词全部落在实操层RAG不是缩写是动词指“执行一次检索增强生成动作”、检索增强生成强调“增强”是动态注入不是静态拼接、代码必须可复制粘贴即跑无隐藏依赖、LangChain版本锁定在0.1.16避坑v0.2的breaking change、FAISS不是“用了FAISS”而是明确告诉你IVF_PQ索引在10万chunk下的内存占用比Flat索引低63%且QPS高2.8倍。全文所有结论都来自我在MacBook M2 Pro16GB RAM上实测的37次基准测试、12个不同PDF解析方案对比、以及对LangChain源码中BaseRetriever.get_relevant_documents()方法的逐行调试。适合谁读正在准备AI方向面试的应届生或转行者你能直接抄走代码在面试前夜搭起一个能演示的本地RAG demo已上线RAG但总被业务方质疑“为啥搜不到我刚上传的合同条款”的工程师你会看到FAISS索引重建的触发时机、chunk重叠率对长尾查询的影响、以及为什么“语义相似度0.7”这个阈值在法律文本里根本不可靠想用RAG落地但卡在“文档切分就错”的产品经理我会用一份真实的《劳动合同法》PDF展示从PDF解析→表格识别→标题层级保留→代码块隔离→中文标点归一化的完整预处理流水线连OCR失败时的fallback策略都写进代码注释。现在关掉浏览器里那些“RAG十大误区”的公众号文章。打开你的终端我们从第一行pip install langchain0.1.16 faiss-cpu1.8.0 python-docx PyPDF2开始——这不是学习是开工。2. 为什么非得用LangChainFAISS组合拆解RAG链路上的四个真实断点RAG不是“把文档扔进向量库再问问题”这么简单。它是一条精密装配线任何环节松动都会导致最终输出崩坏。我见过太多人栽在看似最基础的环节文档解析失真、向量化丢失语义、检索结果错位、LLM提示词吞掉关键上下文。下面这四个断点是我在37个真实RAG项目里反复验证过的“死亡陷阱”而LangChainFAISS组合正是为精准卡住这些断点设计的。2.1 断点一PDF解析器把表格变成乱码却还声称“结构化提取成功”这是RAG项目夭折的第一大原因。92%的业务文档含表格合同条款、财务报表、技术参数表而默认PDF解析器PyPDF2、pdfplumber在处理合并单元格、跨页表格、嵌入图片时会把“甲方”和“乙方”强行拉成同一行导致向量编码时语义完全错乱。我实测过11种PDF解析方案最终选择unstructured库的partition_pdf函数但它必须配两个关键参数from unstructured.partition.pdf import partition_pdf # 必须开启这两个参数否则表格解析形同虚设 elements partition_pdf( filenamecontract.pdf, strategyhi_res, # 强制OCR避免纯文本解析失真 infer_table_structureTrue, # 启用表格结构识别 chunking_strategyby_title, # 按标题切分保留语义边界 )提示strategyhi_res会调用LayoutParser检测页面元素耗时增加40%但表格准确率从51%提升至96%。别省这点时间——你花3分钟等解析总比花3小时调prompt修正幻觉强。2.2 断点二向量化时中文词粒度丢失“人工智能”被切成“人工”“智能”语义向量偏离37度OpenAI的text-embedding-ada-002对中文支持极差而国内常用模型bge-m3、m3e虽支持中文但默认配置下会把复合词强行切开。比如“深度学习框架”bge-m3默认tokenizer会拆成[深度, 学习, 框架]而实际语义锚点是“深度学习”这个整体概念。解决方案是自定义分词后向量化from transformers import AutoTokenizer, AutoModel import torch tokenizer AutoTokenizer.from_pretrained(BAAI/bge-m3) model AutoModel.from_pretrained(BAAI/bge-m3) def embed_text(text: str) - np.ndarray: # 关键用jieba强制保留复合词 import jieba jieba.add_word(深度学习) # 手动注入领域词 jieba.add_word(RAG系统) words jieba.lcut(text) inputs tokenizer(words, return_tensorspt, paddingTrue, truncationTrue) with torch.no_grad(): outputs model(**inputs) # 取[CLS]向量非mean pooling——实测对长文本更鲁棒 return outputs.last_hidden_state[:, 0, :].numpy().flatten()注意不要用model.encode()快捷接口它默认mean pooling会抹平关键词权重。实测在法律条款检索中[CLS]向量的top3召回准确率比mean pooling高22个百分点。2.3 断点三FAISS索引类型选错10万文档下QPS从120暴跌到7FAISS不是“装上就能用”。IVFInverted File和HNSWHierarchical Navigable Small World是两种根本不同的索引范式IVF适合批量插入高频查询场景它把向量空间划分为聚类中心centroids查询时先定位最近的几个聚类再在子集中搜索。内存占用低但需要预估聚类数kHNSW适合实时插入低频查询场景它构建多层图结构插入快但内存占用高且不支持增量更新。我们的真实业务是每天凌晨批量导入500份新合同约8万chunks白天承受200并发查询。实测数据如下M2 Pro 16GB索引类型内存占用建索引时间QPS10并发top1准确率Flat2.1GB42s12089.2%IVF1000.8GB31s18791.5%HNSW323.4GB68s8990.1%结论清晰选IVF100100个聚类中心并手动指定nprobe10查询时搜索10个最近聚类。代码实现import faiss import numpy as np # 假设embeddings是(80000, 1024)的numpy数组 index faiss.IndexIVFFlat(faiss.IndexFlatL2(1024), 1024, 100) index.train(embeddings) # 必须先train否则add报错 index.add(embeddings) index.nprobe 10 # 关键参数控制精度/速度平衡实操心得nprobe不是越大越好。实测nprobe20时QPS跌到142但top1准确率只提升0.3%。我们取nprobe10在QPS和准确率间取得最佳拐点。2.4 断点四LangChain Retriever返回的context长度超限LLM直接截断关键条款这是最隐蔽的坑。LangChain的VectorStoreRetriever默认返回4个document每个document的page_content可能长达2000字。当把这些拼成prompt喂给LLM时总token数轻松突破4096限制导致后半段条款被截断。解决方案是两级截断Retriever层截断用SearchKwargs限制单个document长度Chain层截断用ContextualCompressionRetriever动态压缩无关句。from langchain.retrievers import ContextualCompressionRetriever from langchain.retrievers.document_compressors import LLMChainExtractor # 第一步Retriever只取每个doc的前512字符 retriever vectorstore.as_retriever( search_kwargs{k: 4, filter: {source: contract}} ) # 第二步用LLM压缩器剔除冗余句如“根据本合同第X条”这类引用句 compressor LLMChainExtractor.from_llm( llmChatOpenAI(model_namegpt-3.5-turbo, temperature0) ) compression_retriever ContextualCompressionRetriever( base_compressorcompressor, base_retrieverretriever )踩坑记录曾有个项目用max_tokens_limit2048全局控制结果发现LLM在生成时把“甲方义务”部分全删了只留“乙方义务”。后来发现是压缩器把“甲方”误判为冗余主语。最终改用规则压缩器DocumentCompressorPipeline 自定义正则过滤专删“详见附件X”“参见第Y条”这类指向性短语保留所有主谓宾完整句。3. 从零搭建可演示的RAG系统代码逐行解析与性能实测现在我们把前面所有断点解决方案组装成一个能在面试现场10分钟内跑通的最小可行系统。目标输入问题“员工离职需提前几天通知公司”系统返回《劳动合同法》第37条原文及上下文解释。全程使用本地资源无需API Key所有依赖版本锁定。3.1 环境准备与依赖安装精确到小版本号# 创建干净虚拟环境 python -m venv rag_env source rag_env/bin/activate # macOS/Linux # rag_env\Scripts\activate # Windows # 安装精确版本避坑LangChain v0.2重构了Retriever接口 pip install langchain0.1.16 \ faiss-cpu1.8.0 \ unstructured0.10.22 \ PyPDF23.0.1 \ python-docx0.8.11 \ jieba0.42.1 \ transformers4.38.2 \ torch2.2.0 \ sentence-transformers2.3.1注意unstructured必须0.10.20否则infer_table_structureTrue参数不存在faiss-cpu1.8.0是最后一个支持M1/M2芯片的稳定版1.9.0需编译安装。3.2 文档预处理让PDF开口说话我们用真实的《中华人民共和国劳动合同法》PDF官网下载版共12页。重点解决三个问题表格识别、标题层级保留、法律条款编号提取。from unstructured.partition.pdf import partition_pdf from unstructured.chunking.title import chunk_by_title import re def preprocess_labor_law(): # 1. 高精度解析耗时但必要 elements partition_pdf( filenamelabor_law.pdf, strategyhi_res, infer_table_structureTrue, languages[chi], ) # 2. 过滤无意义元素页眉页脚、空白行 clean_elements [ el for el in elements if el.category not in [PageBreak, Table, Image] and len(el.text.strip()) 5 ] # 3. 按标题切分保留法律条款编号如“第三十七条” chunks chunk_by_title( elementsclean_elements, multipage_sectionsTrue, combine_text_under_n_chars500, new_after_n_chars1500, ) # 4. 提取条款编号并标准化统一为“第X条”格式 processed_chunks [] for chunk in chunks: # 匹配“第三十七条”“第三十七条”“第三十七条 ”等多种格式 law_num_match re.search(r第[零一二三四五六七八九十百千\d]条[\s ]*, chunk.text[:50]) if law_num_match: law_num law_num_match.group().strip( ) chunk.metadata[law_section] law_num processed_chunks.append(chunk) return processed_chunks # 运行预处理 chunks preprocess_labor_law() print(f共提取{len(chunks)}个语义块最大长度{max(len(c.text) for c in chunks)}字符) # 输出共提取42个语义块最大长度1842字符实操心得chunk_by_title比RecursiveCharacterTextSplitter靠谱10倍。后者按固定长度切分会把“第三十七条 劳动者提前三十日以书面形式通知用人单位可以解除劳动合同。”硬生生切成两半导致向量编码丢失主谓关系。而标题切分确保每块以“第X条”开头语义完整。3.3 向量化与FAISS索引构建让文字变成可计算的坐标我们选用BAAI/bge-m3模型它支持中英混合、多粒度检索dense/sparse/hybrid且对法律文本优化过。from sentence_transformers import SentenceTransformer import numpy as np import faiss # 加载模型首次运行会下载约2GB model SentenceTransformer(BAAI/bge-m3) # 构建向量注意bge-m3返回dict取dense向量 embeddings [] for chunk in chunks: # bge-m3支持batch但chunk长度差异大单条处理更稳 result model.encode([chunk.text], return_denseTrue, return_sparseFalse) embeddings.append(result[dense_vecs][0]) embeddings np.array(embeddings) print(f向量维度{embeddings.shape[1]}总向量数{embeddings.shape[0]}) # 输出向量维度1024总向量数42 # 构建IVF索引 dimension 1024 index faiss.IndexIVFFlat(faiss.IndexFlatL2(dimension), dimension, 10) index.train(embeddings) index.add(embeddings) index.nprobe 3 # 小型数据集nprobe3足够 # 封装为LangChain VectorStore from langchain.vectorstores import FAISS from langchain.embeddings import FakeEmbeddings # 注意LangChain FAISS要求Embeddings对象我们伪造一个 class BGEEmbeddings: def __init__(self, model): self.model model def embed_documents(self, texts): results self.model.encode(texts, return_denseTrue, return_sparseFalse) return results[dense_vecs].tolist() def embed_query(self, text): result self.model.encode([text], return_denseTrue, return_sparseFalse) return result[dense_vecs][0].tolist() faiss_store FAISS( embedding_functionBGEEmbeddings(model), indexindex, docstoreNone, # 我们手动管理docs index_to_docstore_id{}, ) # 手动注入documents关键否则retriever找不到源文本 faiss_store.add_documents(chunks)关键细节FAISS.from_documents()会重建索引破坏我们精心调优的IVF参数。所以必须用add_documents()注入已有索引。index_to_docstore_id为空字典因为add_documents会自动填充。3.4 检索增强生成链让LLM真正“看见”条款原文现在构建核心Chain。重点解决两个问题1如何把检索结果精准注入prompt2如何让LLM严格引用原文不自由发挥。from langchain.prompts import ChatPromptTemplate from langchain.chat_models import ChatOpenAI from langchain.schema.runnable import RunnablePassthrough from langchain.schema.output_parser import StrOutputParser # 定义prompt模板强制LLM引用原文 template 你是一名劳动法律师严格依据《中华人民共和国劳动合同法》回答问题。 请直接引用法条原文不要解释、不要补充、不要推测。 context {context} /context 问题{question} 请严格按此格式回答 【法条原文】 此处粘贴检索到的法条原文 【条款编号】 此处填写条款编号如“第三十七条” prompt ChatPromptTemplate.from_template(template) # 构建Chain注意retriever必须是可调用对象 retriever faiss_store.as_retriever(search_kwargs{k: 1}) # 只取最相关1条 chain ( {context: retriever, question: RunnablePassthrough()} | prompt | ChatOpenAI(model_namegpt-3.5-turbo, temperature0) | StrOutputParser() ) # 测试查询 result chain.invoke(员工离职需提前几天通知公司) print(result)预期输出【法条原文】 第三十七条 劳动者提前三十日以书面形式通知用人单位可以解除劳动合同。 【条款编号】 第三十七条实测耗时M2 Pro上平均响应时间842ms含检索LLM调用。其中FAISS检索耗时12msLLM生成耗时830ms。若换成本地LLM如Phi-3总耗时可压至320ms以内。3.5 性能压测与瓶颈定位用数据说话最后我们用真实压力测试验证系统稳定性。模拟20个并发用户每个用户随机提问10个问题从预设的50个劳动法问题库中抽取。import asyncio import time from concurrent.futures import ThreadPoolExecutor async def query_worker(question: str): start time.time() try: result chain.invoke(question) latency time.time() - start return {question: question, result: result[:100], latency: latency, success: True} except Exception as e: return {question: question, error: str(e), latency: time.time() - start, success: False} async def run_load_test(): questions [ 试用期最长多久, 加班费怎么算, 公司不交社保怎么办, # ... 共50个问题 ] tasks [query_worker(q) for q in questions * 4] # 200次请求 results await asyncio.gather(*tasks) success_rate sum(1 for r in results if r[success]) / len(results) avg_latency sum(r[latency] for r in results if r[success]) / sum(1 for r in results if r[success]) print(f成功率{success_rate:.1%} | 平均延迟{avg_latency*1000:.0f}ms | P95延迟{np.percentile([r[latency] for r in results if r[success]], 95)*1000:.0f}ms) # 运行压测 asyncio.run(run_load_test())实测结果20并发成功率100%平均延迟867msP95延迟1120ms内存占用峰值1.2GBFAISS索引模型权重关键发现当并发从20升到50时P95延迟跳升至2300ms瓶颈在LLM API调用。解决方案不是升级FAISS而是加一层Redis缓存——把“问题→法条编号”映射缓存起来命中率可达68%劳动法问题高度重复。代码只需加3行import redis cache redis.Redis() cache_key flabor_qa:{hash(question)} if cache.exists(cache_key): return cache.get(cache_key).decode() # ... 执行chain.invoke ... cache.setex(cache_key, 3600, result) # 缓存1小时4. 面试高频问题实战拆解用代码回答而非背诵定义现在你已拥有一个可运行的RAG系统。但面试官要的不是demo而是你对原理的穿透力。下面用真实代码片段回应6个最高频问题。每个回答都附带可验证的代码行和实测数据。4.1 “RAG和微调的区别什么时候该用哪个”错误回答“RAG适合知识更新快微调适合领域适配深。”正确回答带代码证据RAG和微调解决的是不同维度的问题。RAG解决知识新鲜度问题微调解决任务指令遵循问题。看这个实验# 场景回答“2024年北京最低工资标准” # 方案ARAG用2024年政策PDF retriever faiss_store_2024.as_retriever() result_rag chain.invoke(北京2024年最低工资多少) # 输出北京市2024年最低工资标准为每月2420元 # 方案B微调模型用2023年数据训练 llm_finetuned load_finetuned_model(qwen-7b-lora-2023) result_ft llm_finetuned.invoke(北京2024年最低工资多少) # 输出北京市2023年最低工资标准为每月2320元错误模型不知道2024年数据 # 方案CRAG微调最优解 # 微调模型专注理解“最低工资”这个概念RAG提供最新数值 chain_finetuned chain.with_config({llm: llm_finetuned}) result_hybrid chain_finetuned.invoke(北京2024年最低工资多少) # 输出北京市2024年最低工资标准为每月2420元结论RAG管“是什么”微调管“怎么答”。新政策发布后RAG只需替换PDF微调模型不用动但若业务要求“用表格形式输出工资对比”微调模型能学会RAG做不到。4.2 “FAISS索引重建会影响线上服务吗”错误回答“会所以要双写索引。”正确回答带代码证据FAISS索引重建本身不阻塞查询但index.add()是线程安全的而index.train()不是。正确做法是离线重建原子切换import os import shutil def rebuild_index_offline(): # 1. 在临时目录构建新索引 temp_index_path /tmp/faiss_new.index new_index build_faiss_index(new_documents) # 你的构建逻辑 faiss.write_index(new_index, temp_index_path) # 2. 原子切换Linux/macOS final_index_path faiss_prod.index # 先备份旧索引 shutil.copy2(final_index_path, f{final_index_path}.backup) # 用rename实现原子切换毫秒级 os.replace(temp_index_path, final_index_path) # 3. 重启服务时加载新索引或热重载 faiss_store.load_index(final_index_path) # 假设你封装了load方法 # 实测切换耗时0.003秒期间QPS无抖动注意os.replace()在POSIX系统上是原子操作Windows需用MoveFileEx。绝不能用shutil.move()它会先删除再复制造成服务中断。4.3 “LangChain的Retriever和Chain怎么协同工作”错误回答“Retriever找文档Chain整合答案。”正确回答带代码证据Retriever和Chain通过Runnable协议协同核心是RunnablePassthrough。看这段调试代码from langchain.schema.runnable import RunnableLambda # 在Chain中插入调试节点 debug_chain ( {context: retriever, question: RunnablePassthrough()} | RunnableLambda(lambda x: print(f检索到{len(x[context])}个文档) or x) # 调试输出 | prompt | ChatOpenAI(...) | StrOutputParser() ) # 运行时输出 # 检索到1个文档 # 检索到1个文档 # ...原理RunnablePassthrough不改变输入只执行副作用如打印。这证明Retriever在Chain执行时才被调用且每次查询独立触发。不是“预加载所有文档”而是“按需检索”。4.4 “RAG的召回率怎么测Hit Rate和MRR有什么区别”错误回答“Hit Rate是top-k是否包含答案MRR是平均倒数排名。”正确回答带代码证据必须用真实标注数据集。我们用《劳动合同法》50个问题人工标注每个问题的唯一正确法条编号如Q1→“第三十七条”。def calculate_metrics(questions, answers, ground_truth): hits 0 mrr_sum 0 for i, (q, a, gt) in enumerate(zip(questions, answers, ground_truth)): # 模拟retriever返回top3法条编号 retrieved [第三十六条, 第三十七条, 第三十八条] # 实际从retriever获取 # Hit Rate1第一个是否正确 if retrieved[0] gt: hits 1 # MRR第一个正确结果的倒数排名 for rank, r in enumerate(retrieved, 1): if r gt: mrr_sum 1 / rank break hit_rate hits / len(questions) mrr mrr_sum / len(questions) return hit_rate, mrr # 实测结果 # Hit Rate1: 82% | MRR: 0.87 # 说明82%的问题最相关法条排第一剩余18%中平均在第1.15名找到正确答案。关键不要用“是否包含关键词”判断必须用法条编号匹配。因为“通知”这个词在多条中出现但只有第三十七条规定“三十日”。4.5 “RAG知识库能存储图片吗”错误回答“不能RAG只处理文本。”正确回答带代码证据能但需多模态嵌入。用clip-ViT-B-32模型把图片和文本映射到同一向量空间from sentence_transformers import SentenceTransformer import cv2 # 加载多模态模型 multimodal_model SentenceTransformer(clip-ViT-B-32) # 处理图片 img cv2.imread(salary_slip.jpg) img_embedding multimodal_model.encode([img]) # 直接传入numpy array # 处理对应文本描述 text 员工张三2024年3月工资条实发工资8500元 text_embedding multimodal_model.encode([text]) # 计算相似度 similarity np.dot(img_embedding, text_embedding.T)[0][0] print(f图片与文本相似度{similarity:.3f}) # 输出0.721 # 存入FAISS与文本向量同维度 faiss_store.add_images([img_embedding]) # 假设扩展了add_images方法注意clip-ViT-B-32输出512维向量需确保FAISS索引维度匹配。图片检索准确率取决于文本描述质量——“工资条”比“一张纸”好10倍。4.6 “你们的RAG系统有啥瓶颈怎么优化”错误回答“主要是LLM延迟我们换更快的模型。”正确回答带代码证据我们做了全链路耗时分析单位ms环节耗时优化方案效果PDF解析2100改用unstructuredhi_res↓38%向量化850batch size32 GPU加速↓62%FAISS检索12IVF索引 nprobe3——已最优Prompt组装8预编译Jinja模板↓40%LLM生成830Redis缓存高频问答↓68%命中时最终瓶颈是LLM生成但优化思路不是换模型而是降低LLM负担用LLMChainExtractor压缩context使输入token减少42%用SelfQueryRetriever让LLM自己生成检索query避免人工写prompt召回率15%对法律条款类问答用规则引擎兜底如“第X条”直接匹配响应时间50ms。# 规则引擎兜底针对条款编号查询 def rule_based_retrieve(query: str): # 匹配“第X条”“第X款”等格式 match re.search(r第([零一二三四五六七八九十百千\d])[条|款], query) if match: num chinese_to_arabic(match.group(1)) # 中文数字转阿拉伯数字 # 直接从chunks中查找law_section字段 for chunk in chunks: if chunk.metadata.get(law_section) f第{num}条: return [chunk] return None # Chain中优先调用规则引擎 def hybrid_retrieve(query: str): result rule_based_retrieve(query) if result: return result else: return retriever.get_relevant_documents(query)实测23%的查询命中规则引擎平均响应时间从867ms降至42ms且100%准确。5. 那些没人告诉你的RAG实战陷阱来自37个项目的血泪笔记最后分享5个在文档里找不到、但会让你在真实项目中连续加班三天的陷阱。每个都附带一行救命代码。5.1 陷阱一PDF里的“空格”其实是全角空格向量化时被当作文本噪声中文PDF常混用全角空格\u3000和半角空格\x20。bge-m3模型把全角空格当有效字符导致“甲方 张三”和“甲方 张三”向量距离达0.42余弦相似度仅0.58。解决方案预处理时统一归一化。def normalize_spaces(text: str) - str: # 将全角空格、不间断空格、制表符全转为半角空格 text text.replace(\u3000, ).replace(\xa0, ).replace(\t, ) # 合并连续空格 text re.sub(r , , text) return text.strip() # 在chunk.text上应用 for chunk in chunks: chunk.text normalize_spaces(chunk.text)实测归一化后相同语义文本的向量距离从0.42降至0.03top1召回率提升19个百分点。5.2 陷阱二FAISS的search()返回ID是int64LangChain的docstore用str做key导致查不到文档这是LangChain 0.1.x的著名bug。FAISS返回的Iindices是np.int64而FAISS.docstore的search()方法用str(i)当key但str(np.int64(123))和123在某些Python版本下hash不同导致docstore.search()返回None。# 修复代码在FAISS类中重写_get_docs