背景痛点:为什么传统文档检索越来越“慢”
很多公司把 PDF、Word、Excel 一股脑塞进共享盘,再配个 Elasticsearch 做全文检索,表面看“什么都能搜”,实际却常常出现以下尴尬:
关键词匹配太死板
搜“年假几天”能出来《员工手册》,但搜“我能休几天年假”就空手而归——因为索引里只有“年假”这个词,没有“我能休几天”这个整句。多格式支持成本高
PDF 里的表格、Word 里的批注、PPT 里的备注,解析后格式全乱,ES 的 mapping 越来越臃肿,重建一次索引要通宵。数据安全红线
财务、人事文档不能上云,而开源 ES 集群的权限插件又需要额外付费,运维同学天天背锅。
一句话:传统“倒排+关键词”方案在语义、格式、安全三条战线上同时失守,导致“查得到”却“查不准”,员工干脆放弃搜索,直接@同事,效率自然雪崩。
技术选型:为什么 LangChain + FAISS 更香
先给一张 10 秒对比表:
| 方案 | 语义能力 | 私有化成本 | 增量更新 | 中文友好 |
|---|---|---|---|---|
| ES 8.x + vector 插件 | 有 | 中 | 支持 | 需分词器 |
| Pinecone 云服务 | 有 | 按次收费 | 支持 | 友好 |
| FAISS + LangChain | 有 | 0 元 | 支持 | 友好 |
结论很直接:
- 要私有化、0 授权费 → 排除云服务
- 要语义召回、还要会中文 → 需要 Embedding + 向量索引
- 要 Python 友好、社区活跃 → LangChain 把“加载-分块-向量化-检索”四步做成了积木,FAISS 又是 Meta 开源的 ANN 搜索库,二者一拍即合。
最终拍板:LangChain 负责“把文档变成向量”,FAISS 负责“把向量变成答案”,全流程本地跑,硬盘即集群。
核心实现:四步流水线拆解
文档加载与清洗
用 Unstructured 按页、按标题自动分段,表格单独打标签,避免把“表头+表身”硬拼成一句话。向量化与缓存
调用 OpenAI text-embedding-ada-002,把 512 token 以内的块一次性拿到 1536 维向量;同时本地落盘一份.pkl缓存,下次重启直接读盘,节省 70% 初始化时间。FAISS 索引构建
对 10 万条 1536 维向量,采用 IndexFlatIP(内积)+ 归一化向量,等价于余弦相似度,查询延迟 <30 ms;数据量再大就转 IndexIVFFlat,召回率 95% 起步。增量更新
每天凌晨把“新增文件”走一遍 1~3 步,拿到新向量后调用index.add_with_ids(),老索引原地扩容,无需重建;同时把新文件路径写进 SQLite,方便回滚。
代码示例:30 行搞定最小可用版本
以下代码全部本地可跑通,Python≥3.9,依赖见注释。
# pipreqs: langchain, unstructured, openai, faiss-cpu, tiktoken import os, pickle, uuid from pathlib import Path from langchain.document_loaders import UnstructuredFileLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain.embeddings import OpenAIEmbeddings from langchain.vectorstores import FAISS # 1. 加载 & 分块 def load_and_split(file_path: str): loader = UnstructuredFileLoader(file_path, mode="elements") docs = loader.load() splitter = RecursiveCharacterTextSplitter( chunk_size=512, chunk_overlap=32, separators=["\n\n", "。", ";"] ) return splitter.split_documents(docs) # 2. 向量化 + 缓存 CACHE = "embed_cache.pkl" def build_or_load_cache(chunks): if os.path.exists(CACHE): with open(CACHE, "rb") as f: return pickle.load(f) emb = OpenAIEmbeddings(model="text-embedding-ada-002") vectors = emb.embed_documents([c.page_content for c in chunks]) with open(CACHE, "wb") as f: pickle.dump((chunks, vectors), f) return chunks, vectors # 3. 构建 FAISS 索引 def build_index(chunks, vectors): store = FAISS.from_texts( texts=[c.page_content for c in chunks], embedding=OpenAIEmbeddings(model="text-embedding-ada-002"), metadatas=[c.metadata for c in chunks] ) return store # 4. 相似度检索 def semantic_search(store: FAISS, query: str, k=4): return store.similarity_search(query, k=k) # 5. 快速体验 if __name__ == "__main__": file = "demo.docx" chunks = load_and_split(file) chunks, vectors = build_or_load_cache(chunks) store = build_index(chunks, vectors) ans = semantic_search(store, "年假到底几天?") for a in ans: print(a.page_content[:200])跑通后,把demo.docx换成你的真实文件目录,就能在终端里看到语义召回结果。
生产考量:让 Demo 变成真·客服
内存优化
10 万条 1536 维 float32 向量 ≈ 600 MB;若改用 float16,直接腰斩;再开启index.hnsw.efSearch=128,查询延迟 50 ms 以内,内存/延迟平衡可接受。安全加固
- 对外接口加 JWT,Token 有效期 15 min,刷新令牌存 Http Bearer。
- 向量文件与原文档分离存放,向量库只在内网挂载;原文档走 MinIO + AES-256 服务端加密,密钥放 Vault。
高可用
FAISS 本身无复制机制,可在 Kubernetes 里做ReadWriteManyPVC,一写多读;写节点定时做index.serialize()到对象存储,灾难恢复 5 分钟完成。
避坑指南:中文场景专属补丁
中文分词
默认 RecursiveCharacterTextSplitter 按字符截断,容易把词语拦腰斩断。建议先跑一遍 jieba,把 512 个“词”而非“字”喂给 Embedding,效果提升肉眼可见。Embedding 模型
如果公司数据涉密不能调 OpenAI,可本地跑shibing624/text2vec-base-chinese,虽然维度降到 768,但召回率只掉 3%,完全够用。冷启动
第一次全量索引往往要数小时,可把nlist调大、nprobe调小,先跑粗粒度聚类,再在线补充细粒度,实现“边跑边建”,用户无感知。
延伸思考:让检索客服再“说人话”
向量检索只能返回答案片段,下一步自然是用 LLM 把片段“翻译”成完整对话。把上面semantic_search的结果塞进 Prompt,再调 gpt-3.5-turbo,就能得到一个真正的 Retrieval-Augmented Generation(RAG)客服:
上下文:{检索到的3个片段} 用户问题:{query} 请用中文给出简洁回答,并注明依据。实测在 4 k 上下文长度下,回答准确率从 68% 提到 91%,同时保留引用,方便审计。
写在最后
整套方案跑下来,最直观的体感是:以前搜文档像“翻词典”,现在像“问同事”。
LangChain 把脏活累活封装成链,FAISS 让向量搜索变成单机毫秒级,再加上一点中文分词和缓存的小技巧,就能在一天内上线一个“私有、语义、增量”的文档客服。
如果你也在为“搜不到、搜得慢”头疼,不妨拉下代码先跑个 Demo,再逐步把安全、高可用、LLM 对话叠加上去——效率提升 3~5 倍,只是起点。