私有知识问答系统实战:LangChain+Weaviate+Streamlit落地指南 1. 项目概述这不是一个“调用API”的玩具而是一套可落地的私有知识问答系统我去年帮一家做工业设备维保的客户搭过三套类似的系统其中一套现在每天要处理近两千条来自一线工程师的现场问题查询——比如“型号X2000的液压泵在-15℃环境下的启动电流异常升高可能原因有哪些”系统能直接从他们积压了八年的维修手册PDF、故障案例Excel、技术通告Word中精准定位答案并生成带引用来源的回复。这背后没有魔法就是LangChain Weaviate Streamlit 这个组合拳打出来的实绩。它解决的核心问题非常具体如何让大模型不凭空编造而是严格基于你自己的文档说人话。关键词里的“AI”在这里不是泛指而是特指“生成式AI在私有知识场景下的可控应用”。它适合三类人技术团队想快速验证知识库方案的产品经理、需要把内部文档变成智能助手的中小型企业IT负责人以及正在准备AI工程化面试的开发者——因为这套流程覆盖了Embedding构建、向量检索、Prompt编排、UI交互四个关键链路全是硬核考点。我不会讲“LangChain是什么”而是直接告诉你为什么Weaviate比FAISS更适合生产环境为什么必须用Document Loader而不是手动切文本为什么Streamlit里一个st.session_state的误用会让整个对话历史错乱这些细节才是决定项目成败的分水岭。2. 整体架构设计与技术选型逻辑拆解2.1 为什么是LangChain、Weaviate、Streamlit这个铁三角很多人看到标题第一反应是“又一个LangChain教程”但实际落地时LangChain绝不是可选项而是必选项。它的核心价值在于抽象掉LLM调用的底层差异。我们试过直接用OpenAI SDK写结果发现当客户突然要求把GPT-4换成本地部署的Qwen-7B时光是调整temperature、max_tokens、stop参数就改了两天更别说system prompt的格式兼容性问题。LangChain的LLM Wrapper层把这一切标准化了——换模型只需改一行代码llm ChatOpenAI(model_namegpt-4)→llm ChatOllama(modelqwen:7b)。这才是工程化的起点。Weaviate的选择则源于一次血泪教训。最早我们用FAISS测试数据量5000份PDF时响应速度还行但当客户把十年的设备图纸单张图纸平均8MB共12万页导入后FAISS的内存占用直接飙到48GB重启一次服务要等7分钟。Weaviate的分布式架构和自动分片机制解决了这个问题。更重要的是它的语义过滤能力——比如用户问“X2000泵的密封圈更换步骤”FAISS只能返回相似度最高的几个chunk而Weaviate可以加条件where_filter {path: [metadata, doc_type], operator: Equal, valueString: maintenance_manual}强制只从维修手册里找答案彻底规避了从采购合同里“幻觉”出操作步骤的风险。Streamlit被选中纯粹因为它的开发效率碾压其他方案。我们对比过Gradio和FastAPIReactGradio的UI定制太弱客户要求把“引用来源”做成可点击的PDF高亮链接Gradio做不到FastAPIReact开发周期预估3周而Streamlit用st.expander和st.markdown两小时就搞定。最关键的是它的st.session_state机制——这是实现多轮对话状态管理的唯一可靠方式。我见过太多人用全局变量或st.cache搞对话历史结果在并发请求下数据全乱套。Streamlit的state是按会话隔离的天然支持多用户。提示不要被“向量数据库”这个词吓住。Weaviate本质是个带语义搜索功能的JSON数据库。你存进去的是{text: 密封圈需涂抹锂基脂, metadata: {doc_id: MM-2023-001, page: 42}}查的时候不是搜关键词而是搜“怎么润滑密封圈”它会自动匹配语义相近的存储内容。2.2 架构图背后的取舍为什么放弃RAG标准范式标准RAGRetrieval-Augmented Generation流程是用户提问→向量检索→拼接Top-K chunk→喂给LLM→生成答案。但我们在线上环境砍掉了“拼接Top-K”这一步改成了动态上下文窗口裁剪。原因很现实GPT-4 Turbo的上下文是128K但客户文档里一份《X系列设备全生命周期维护白皮书》就有92K tokens。如果按标准流程把top-3 chunk全塞进去很可能挤占LLM思考空间导致它忽略关键约束条件。我们的做法是先用Weaviate检索出10个候选chunk再用一个轻量级分类器就用sentence-transformers的all-MiniLM-L6-v2对每个chunk打分计算它和用户问题的语义相关性、以及chunk内是否包含动词如“更换”“校准”“禁用”最后只选得分最高且含动作词的1-2个chunk送入LLM。实测下来答案准确率从73%提升到89%响应时间反而下降18%——因为LLM处理的token数少了。这个设计暴露了一个行业真相RAG不是银弹而是需要根据业务场景反复调优的管道。我们甚至为不同文档类型配置了不同策略技术手册走“高相关性动作词”策略故障案例库走“时间最近相似度阈值”策略优先返回半年内的同类故障处理记录合同条款库则强制启用where_filter限定“legal_clause”字段。这些细节才是让AI真正可用的关键。2.3 安全边界设计如何防止LLM泄露你的核心知识所有客户最担心的问题其实是“我的设备图纸、维修报价单会不会被模型记住然后泄露出去”这里必须划清红线Weaviate存储的是向量不是原始文本LLM只看到我们允许它看到的chunk片段。但光这样不够我们加了三层防护第一层是文档预处理脱敏。用正则表达式扫描所有PDF文本自动替换[A-Z]{2}\d{6}格式的设备序列号为[SERIAL_MASKED]把¥\d\.?\d*的价格数字替换为[PRICE_MASKED]。这步在Document Loader阶段完成确保原始敏感信息根本不会进入向量库。第二层是检索结果过滤。Weaviate的near_text查询默认返回所有匹配项但我们加了limit5和certainty0.72这个阈值是通过测试200个真实问题确定的低于0.72时错误答案率陡增。更重要的是with_additional[id, certainty]拿到结果后立刻检查certainty值低于阈值的chunk直接丢弃绝不喂给LLM。第三层是LLM输出审查。在LangChain的LLMChain之后插入一个OutputParser用规则引擎扫描生成文本如果出现“根据XX合同第X条”但原文档里没提具体条款号或者出现“建议联系销售总监XXX”就触发raise ValueError(Output contains hallucinated contact info)前端显示“未找到相关信息”。注意别信“LLM不会记事”的说法。我们做过压力测试把同一份设备图纸连续上传3次然后问“图纸里提到的备用件编号是多少”LLM有12%概率复述出之前见过的编号。所以预处理脱敏是刚需不是锦上添花。3. 核心模块实现与关键细节解析3.1 文档加载与向量化为什么不能直接用UnstructuredLoaderLangChain官方推荐的UnstructuredPDFLoader看似省事但它有个致命缺陷对扫描版PDF完全失效。客户提供的2015年老版维修手册全是扫描件UnstructuredPDFLoader跑出来全是乱码。我们切换到了PyMuPDFLoader即fitz它能直接提取PDF的原始文本流配合OCR引擎Tesseract准确率提升到94%。但OCR有代价处理100页PDF要23秒。于是我们做了个折中方案——先用fitz检测PDF是否含文本层doc[0].get_text() ! 如果是文本型PDF直接提取否则才调用Tesseract且限制只OCR含图表的页面通过page.get_image_info()判断。向量化环节的坑更多。初版我们用OpenAIEmbeddings结果发现客户内网无法访问OpenAI API。换成HuggingFaceEmbeddings后又遇到维度不匹配Weaviate默认向量维度是768但all-MiniLM-L6-v2输出384维。解决方案是修改Weaviate schema在创建class时显式指定vectorIndexConfig: { distance: cosine, vectorDimensions: 384 }。更隐蔽的坑是中文分词all-MiniLM-L6-v2对中文支持一般我们最终换成了bge-small-zh-v1.5它专为中文优化相似度计算更准。验证方法很简单用Weaviate的explore功能输入“液压泵漏油”看返回的chunk是否真和漏油相关而不是“液压系统原理”。# 关键代码带OCR容错的文档加载器 from langchain.document_loaders import PyMuPDFLoader import fitz import pytesseract from PIL import Image def smart_pdf_loader(file_path): doc fitz.open(file_path) text_chunks [] for page_num in range(len(doc)): page doc[page_num] # 检测是否有文本层 text page.get_text() if text.strip(): # 直接提取文本 text_chunks.append(text) else: # OCR处理 pix page.get_pixmap(dpi300) img Image.frombytes(RGB, [pix.width, pix.height], pix.samples) ocr_text pytesseract.image_to_string(img, langchi_sim) text_chunks.append(ocr_text) return \n\n.join(text_chunks) # 向量化时指定正确维度 from langchain.embeddings import HuggingFaceEmbeddings embeddings HuggingFaceEmbeddings( model_nameBAAI/bge-small-zh-v1.5, model_kwargs{device: cpu} )3.2 Weaviate Schema设计字段命名不是小事Weaviate的schema设计直接影响检索质量。我们最初按直觉建了DocChunkclass字段设为content文本、source文件名、page页码。结果上线后发现两个问题一是用户常问“X2000泵的故障代码F07怎么处理”但content字段里只有“F07电机过载”没提“X2000”二是维修手册和故障案例混在一起LLM容易混淆操作步骤和现象描述。解决方案是重构schema增加业务语义字段字段名类型说明示例texttext分块后的纯文本“F07错误检查电机绕组绝缘电阻”doc_typetext文档类型用于where_filter“maintenance_manual”, “fault_case”equipment_modeltext[]支持多型号用数组避免漏检[X2000, X2000-PRO]action_verbtext[]动作动词加速LLM理解[检查, 更换, 校准]certainty_thresholdnumber该chunk的置信度基准0.75关键点在于equipment_model用text数组类型。Weaviate的数组字段支持ContainsAny操作符查询时可写{path: [equipment_model], operator: ContainsAny, valueTextArray: [X2000]}。这比用text字段全文搜索快10倍且零误报。action_verb字段则是在文档加载时用jieba分词自定义动词词典提取的比如“请更换密封圈”会提取出“更换”。实操心得Weaviate的text字段默认开启全文搜索但语义搜索必须用near_text。很多新手以为建好schema就能搜结果发现where_filter查不到东西——因为where_filter只作用于结构化字段near_text才作用于向量。两者要配合使用。3.3 Prompt链编排为什么不用LangChain内置的StuffDocumentsChainLangChain的StuffDocumentsChain会把所有检索到的chunk硬塞进prompt但我们的场景需要分层提示。比如用户问“X2000泵启动异常”系统要先判断是电气问题还是机械问题再针对性检索。我们拆成了三级Prompt第一层路由Prompt输入用户问题输出分类标签你是一个工业设备专家请将以下问题归类到[电气故障, 机械故障, 软件故障, 操作失误]。只输出一个标签不要解释。问题{question}第二层检索Prompt根据标签构造Weaviate查询条件生成Weaviate where_filter JSON要求1. doc_type必须是maintenance_manual 2. equipment_model必须包含用户提到的型号 3. action_verb必须包含检查或测量。问题{question}第三层生成Prompt用检索结果生成答案你是一名资深维修工程师根据以下维修手册内容用中文回答用户问题。要求1. 答案必须严格基于提供的内容 2. 如果内容中没有明确答案回答未找到相关信息 3. 在答案末尾标注引用来源格式为[来源{source}, 第{page}页]。问题{question} 内容{context}这种设计让系统具备了“思考”能力。测试时用户问“X2000泵启动时有异响”第一层判为“机械故障”第二层就自动过滤掉电气测试步骤第三层只检索“轴承检查”“联轴器校准”相关内容避免了GPT-4从电气手册里“推理”出机械解决方案的荒诞场景。4. Streamlit UI实现与状态管理深度解析4.1 对话状态管理st.session_state的正确打开方式Streamlit的st.session_state是多轮对话的生命线但90%的教程都用错了。常见错误是把整个对话历史存在st.session_state.messages里然后每次st.chat_message循环渲染。这会导致两个问题一是新消息追加时UI闪动二是当用户点击“清除对话”按钮st.session_state.messages清空了但st.chat_input的value没同步清空下次输入会叠加。我们的解法是分离状态与UI# 初始化状态只在首次运行时执行 if messages not in st.session_state: st.session_state.messages [] st.session_state.chat_history [] # 专门存LLM的完整输入输出 # 渲染历史消息用st.chat_message不依赖st.session_state.messages实时更新 for message in st.session_state.messages: with st.chat_message(message[role]): st.markdown(message[content]) # 处理新输入 if prompt : st.chat_input(请输入您的问题...): # 1. 添加用户消息到UI状态 st.session_state.messages.append({role: user, content: prompt}) # 2. 调用RAG链获取答案 response rag_chain.invoke({ question: prompt, chat_history: st.session_state.chat_history }) # 3. 添加AI消息到UI状态 st.session_state.messages.append({role: assistant, content: response[answer]}) # 4. 更新聊天历史供下一轮参考 st.session_state.chat_history.extend([ (human, prompt), (ai, response[answer]) ])关键点在于st.session_state.chat_history独立于UI渲染状态。它只存[(human, q), (ai, a)]元组不参与st.chat_message渲染避免了状态污染。st.session_state.messages则纯粹是UI层的数据每次st.chat_input触发后才追加保证了渲染顺序绝对正确。4.2 引用来源可视化让答案可信可追溯客户最看重的不是答案多漂亮而是“这个结论从哪来的”。我们把引用来源做成了可交互的PDF高亮。核心思路是Weaviate返回的chunk带page和source字段用PyMuPDF重新打开PDF定位到对应页面用page.add_highlight_annot()画高亮框再用page.get_pixmap()转成图片最后用st.image显示。但直接渲染PDF图片会卡顿。优化方案是懒加载缓存import streamlit as st from langchain.vectorstores import Weaviate import weaviate # Weaviate客户端单例模式 st.cache_resource def get_weaviate_client(): return weaviate.Client(http://localhost:8080) # PDF高亮图片缓存按sourcepage哈希 st.cache_data(ttl3600) # 缓存1小时 def render_pdf_highlight(source: str, page_num: int, highlight_text: str): doc fitz.open(f./docs/{source}) page doc[page_num] # 查找文本位置并高亮 text_instances page.search_for(highlight_text) for inst in text_instances: page.add_highlight_annot(inst) # 渲染为图片 pix page.get_pixmap(dpi150) img Image.frombytes(RGB, [pix.width, pix.height], pix.samples) return img # 在回答后显示引用 if source in response and page in response: with st.expander(f 查看来源{response[source]} 第{response[page]}页): img render_pdf_highlight(response[source], response[page], response[highlight_text]) st.image(img, use_column_widthTrue)这个设计让用户能一键验证答案真实性。测试时工程师看到“F07错误处理步骤”后点开expander直接看到PDF原图高亮信任度瞬间拉满。4.3 错误处理与用户体验当RAG失败时怎么办RAG不是永远成功。我们统计过线上数据约11%的查询因各种原因失败检索无结果、LLM超时、网络抖动。如果只显示“抱歉我没听懂”用户会立刻放弃。我们的应对策略是降级三板斧检索失败降级Weaviate返回空结果时不直接报错而是用关键词搜索兜底。比如用户问“X2000泵的密封圈型号”Weaviate没匹配到就用source字段的全文搜索找含“X2000”和“密封圈”的文档哪怕相似度低也返回。LLM超时降级设置timeout30超时后立即返回缓存的相似问题答案。我们建了个小表存了高频问题TOP50的答案用Levenshtein距离算相似度相似度0.85就直接返回缓存答案。最终兜底所有降级都失败时显示结构化引导“未找到相关信息。您可以① 检查设备型号是否输入正确如X2000而非X200② 尝试更具体的描述如‘启动时异响’而非‘有问题’③ 联系技术支持400-XXX-XXXX”。注意Streamlit的st.rerun()在错误处理中慎用。我们曾用它重试失败请求结果导致无限循环。正确做法是用try/except捕获异常然后用st.warning()显示友好提示让用户手动重试。5. 常见问题排查与生产环境避坑指南5.1 向量化性能瓶颈为什么处理1000份PDF要3小时初版我们用单线程loader.load_and_split()处理PDF1000份文档耗时3小时。优化后压缩到18分钟关键在三点批量分块不用CharacterTextSplitter逐个切改用RecursiveCharacterTextSplitter的chunk_size500, chunk_overlap50并设置separators[\n\n, \n, 。, ]优先按段落切避免把“步骤1”和“步骤2”切到不同chunk。并行加载用concurrent.futures.ThreadPoolExecutor线程数设为CPU核心数-1留1个给OS。注意Weaviate客户端不是线程安全的要为每个线程创建独立client实例。向量缓存HuggingFaceEmbeddings的embed_documents()方法很慢我们加了lru_cache(maxsize1000)装饰器对重复文本如每份文档都有的版权声明直接返回缓存向量。from concurrent.futures import ThreadPoolExecutor, as_completed from langchain.text_splitter import RecursiveCharacterTextSplitter def process_single_pdf(file_path): loader PyMuPDFLoader(file_path) docs loader.load() text_splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, ] ) chunks text_splitter.split_documents(docs) # 为每个chunk添加metadata for chunk in chunks: chunk.metadata.update({ source: os.path.basename(file_path), file_path: file_path }) return chunks # 并行处理 with ThreadPoolExecutor(max_workersos.cpu_count()-1) as executor: future_to_file { executor.submit(process_single_pdf, f): f for f in pdf_files } all_chunks [] for future in as_completed(future_to_file): chunks future.result() all_chunks.extend(chunks)5.2 Weaviate内存暴涨如何避免OOM Killer杀死进程Weaviate默认配置在大数据量下极易OOM。我们线上服务器32GB内存导入5万份文档后Weaviate进程被OOM Killer干掉3次。根治方案是修改docker-compose.ymlweaviate: image: semitechnologies/weaviate:1.22.4 environment: QUERY_DEFAULTS_LIMIT: 25 # 降低默认limit AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: false PERSISTENCE_DATA_PATH: /var/lib/weaviate DEFAULT_VECTORIZER_MODULE: none # 关闭自动向量化 CLUSTER_HOSTNAME: node1 volumes: - ./weaviate-data:/var/lib/weaviate # 关键限制内存 mem_limit: 16g mem_reservation: 8g # 关键调整JVM参数 command: weaviate --host 0.0.0.0 --port 8080 --scheme http -Dweaviate.jvm.options-Xms4g -Xmx8g重点是-Xms4g -Xmx8g把JVM堆内存锁死在4-8G避免动态扩容吃光系统内存。同时mem_limit: 16g给Docker容器设硬上限。实测后内存稳定在12GB再没被杀过。5.3 Streamlit热重载失效为什么改了代码UI不更新Streamlit的st.experimental_rerun()在某些场景下不生效尤其是涉及st.session_state变更时。根本原因是Streamlit的脚本执行模型每次用户交互整个脚本从头执行一遍st.session_state是跨执行周期保持的。但如果在st.button回调里修改了st.session_state又没触发UI组件重绘就会看起来“没更新”。解决方案是强制重绘# 错误写法只改state不触发重绘 if st.button(清除对话): st.session_state.messages [] st.session_state.chat_history [] # 正确写法用st.rerun()强制重绘整个脚本 if st.button(清除对话): st.session_state.messages [] st.session_state.chat_history [] st.rerun() # 关键但st.rerun()有副作用它会重置st.chat_input的value。所以更优雅的方案是用st.form包裹输入框提交时用form_submit_button这样st.rerun()只重绘form区域。5.4 生产环境监控如何知道RAG系统是否健康没有监控的AI系统等于裸奔。我们在Weaviate和Streamlit之间加了一层健康检查中间件Weaviate健康端点curl http://localhost:8080/v1/meta返回{version:1.22.4,status:HEALTHY}向量库完整性检查定时执行client.query.aggregate(DocChunk).with_meta().do()检查objectCount是否与文档总数一致RAG延迟监控在Streamlit里埋点记录rag_chain.invoke()的耗时用st.metric显示P95延迟。当延迟5s时自动发企业微信告警答案质量抽检每天凌晨用100个预设问题自动测试计算答案准确率。低于95%时触发告警人工介入分析是检索问题还是LLM问题。这套监控让我们在客户投诉前就发现问题。上周就靠它提前发现Weaviate的certainty阈值被误调为0.9导致检索召回率暴跌及时回滚配置。6. 部署与运维实战从本地开发到K8s集群6.1 Docker Compose一键部署为什么不用K8s对中小客户K8s是杀鸡用牛刀。我们用Docker Compose实现了三节点高可用# docker-compose.prod.yml version: 3.8 services: weaviate: image: semitechnologies/weaviate:1.22.4 # ...前面的内存配置 depends_on: - weaviate-migrate weaviate-migrate: image: python:3.11-slim volumes: - ./migrations:/app/migrations command: python /app/migrations/init_schema.py depends_on: - weaviate streamlit: build: . ports: - 8501:8501 environment: - WEAVIATE_URLhttp://weaviate:8080 - OPENAI_API_KEY${OPENAI_API_KEY} depends_on: - weaviate关键创新是weaviate-migrate服务它在Weaviate启动后自动执行init_schema.py创建class和配置向量索引。这样部署时不用人工连Weaviate CLI真正做到“docker-compose up -d后开箱即用”。6.2 模型切换策略如何平滑从GPT-4迁移到本地模型客户总有一天会要求“不用国外API”。我们的迁移路径是第一阶段API代理用llama-cpp-python封装Qwen-7B对外提供OpenAI兼容API/v1/chat/completionsStreamlit里只改OPENAI_API_BASE环境变量第二阶段混合推理用vLLM部署Qwen-7B支持PagedAttention吞吐量提升3倍。此时LangChain的ChatOpenAI换成ChatVLLM第三阶段模型蒸馏用Qwen-7B蒸馏出3B小模型部署在客户边缘服务器4核8G用llama.cpp量化到GGUF格式内存占用2GB每一步都保持API接口不变客户前端代码零修改。我们甚至写了自动切换脚本当OpenAI API超时率5%自动切到本地vLLM恢复后10分钟再切回来。6.3 成本控制实录如何把月成本从$2000压到$200GPT-4 Turbo的API调用费是主要成本。我们通过三个手段压降Prompt压缩用llama.cpp的llama_tokenizer预处理把用户问题中的停用词“请问”“麻烦”“谢谢”自动过滤token数减少22%缓存策略用Redis缓存question → answer映射TTL设为7天。对“X2000泵启动电流标准值”这类高频问题缓存命中率83%分级响应简单问题含“是多少”“多少”“标准”字眼直接用SQL查Weaviate的aggregate接口绕过LLM复杂问题才走RAG链最终效果月API调用量从120万次降到18万次成本从$2000降至$200而用户满意度反而上升——因为缓存响应快了5倍。最后分享个小技巧Weaviate的near_text查询支持moveTo和moveAwayFrom参数。比如用户问“X2000泵的密封圈更换步骤”我们可以加moveTo{concepts: [更换, 安装]}moveAwayFrom{concepts: [采购, 报价]}让结果更聚焦。这个功能文档里藏得很深但实测提升相关性17%。