点击开始动手实验


背景痛点:传统检索为什么总答非所问?

去年我给公司做内部 FAQ 机器人,最早用的是 ElasticSearch 的 BM25 打分。上线一周就被吐槽“鸡同鸭讲”——明明问的是“年假几天”,却返回“年假申请流程”。根本原因是:

  1. 关键词匹配无法感知语义,同义词/语序变化直接翻车
  2. 知识库一旦超过 5 万条,召回 Top10 里 7 条不相关,准确率跌到 45% 以下
  3. GPT-3.5 虽然懂语义,但 4k token 上限让“把整库塞进去”成了天方夜谭,成本也扛不住

于是目标很明确:让 LLM 只读“可能相关的几段”,而不是“整本书”。

技术选型:向量库 vs 直调 API 的性价比

我先后试了三种路线,结论先给:

方案 延迟 成本(百万条) 运维 适合场景
直调 ChatGPT Retrieval Plugin 1.2 s 0.08$/1k次 0 运维 原型、Demo
Pinecone 托管向量库 250 ms 70$/月 零运维 中小产品
FAISS + 自建 ES 混合 80 ms 仅服务器费用 需自己备份 对延迟敏感、数据保密

最终线上采用“FAISS + 自建”方案,把延迟压到 100 ms 以内,成本降 60%。下文代码均以该方案为例,方便你一键迁移到 Pinecone。

核心实现:30 行代码搞定多格式解析

1. 数据层:LangChain 一把梭

LangChain 的 DocumentLoader 对常用格式都做了封装,我封装了一个统一入口:

# loader.py
from pathlib import Path
from langchain.document_loaders import PyPDFLoader, UnstructuredHTMLLoader
from typing import List
from langchain.schema import Document

def load_folder(path: str) -> List[Document]:
    docs = []
    for p in Path(path).rglob("*"):
        if p.suffix == ".pdf":
            docs.extend(PyPDFLoader(str(p)).load())
        elif p.suffix == ".html":
            docs.extend(UnstructuredHTMLLoader(str(p)).load())
    return docs

2. 切分块 + 批量 Embedding

chunk_size 不是拍脑袋,后面会实测。先写个带缓存的生成器:

# embed.py
import hashlib, json, os, openai, tiktoken
from typing import List
from diskcache import Cache

cache = Cache("embed_cache")
ENC = tiktoken.encoding_for_model("text-embedding-ada-002")

def get_embedding(texts: List[str]) -> List[List[float]]:
    key = hashlib.md5("".join(texts).encode()).hexdigest()
    if key in cache:
        return cache[key]
    # 每次 100 条批量,防止长度超限
    embs = []
    for i in range(0, len(texts), 100):
        resp = openai.Embedding.create(
            input=texts[i : i+100],
            model="text-embedding-ada-002"
        )
        embs += [r["embedding"] for r in resp["data"]]
    cache[key] = embs
    return embs

3. 向量索引落盘

# build_index.py
import faiss, numpy as np
from loader import load_folder
from embed import get_embedding
from langchain.text_splitter import RecursiveCharacterTextSplitter

docs = load_folder("./data")
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
texts = splitter.split_documents(docs)
vectors = get_embedding([t.page_content for t in texts])
d = len(vectors[0])
index = faiss.IndexFlatIP(d)  # 内积归一化后 = cosine
index.add(np.array(vectors).astype(np.float32))
faiss.write_index(index, "faq.index")

4. Flask 后端:带 JWT 的 RESTful

# app.py
from flask import Flask, request, jsonify
from flask_jwt_extended import JWTManager, jwt_required, create_access_token
import faiss, numpy, openai, os

app = Flask(__name__)
app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET")
jwt = JWTManager(app)
index = faiss.read_index("faq.index")
texts = json.load(open("texts.json"))  # 同步落盘

def search(query: str, k: int = 5):
    qvec = get_embedding([query])[0]
    D, I = index.search(numpy.array([qvec]), k)
    return [texts[i] for i in I[0]]

@app.route("/login", methods=["POST"])
def login():
    username = request.json.get("username")
    password = request.json.get("password")
    # 仅示例,请用真实校验
    if username == "admin" and password == "pwd":
        return jsonify(access_token=create_access_token(identity=username))
    return jsonify({"msg": "Bad creds"}), 401

@app.route("/ask", methods=["POST"])
@jwt_required()
def ask():
    question = request.json.get("q")
    chunks = search(question)
    context = "\n".join(chunks)
    prompt = f"Use the following context to answer concisely.\nContext:\n{context}\n\nQ: {question}\nA:"
    ans = openai.ChatCompletion.create(
        model="gpt-3.5-turbo",
        messages=[{"role": "user", "content": prompt}],
        max_tokens=300,
        temperature=0.1
    )
    return jsonify(answer=ans["choices"][0]["message"]["content"])

跑起来:

export OPENAI_API_KEY=sk-xx
export JWT_SECRET=foo
python app.py

性能优化:Chunk Size 与限流实战

1. Chunk Size 对召回率的影响

我用 200 条人工标注 FAQ 做 MRR@5 测试:

chunk_size overlap MRR 备注
200 0 0.71 太小,断句被截断
500 50 0.83 平衡
1000 100 0.78 太大,引入噪声

结论:500+50 是中文场景甜点值,英文可再大一点。

2. 应对 GPT-3.5 20 次/秒限流

  • 后端加 asyncio.Semaphore(15) 做并发限速
  • 对相同问题缓存 10 分钟,Key 用问题向量 128bit 量化哈希
  • 压测显示缓存命中率 62%,QPS 从 8 → 24,翻三倍

避坑指南:特殊字符与权限

  1. 特殊字符:PDF 常见 \x0c 换页符会成“不可见 token”,导致同一段落 embedding 偏差 > 0.05。统一用 text = re.sub(r"\s+", " ", text) 先清洗
  2. 权限:知识库常含工资、人事敏感信息。
    • 向量文件放内网 MinIO,只对内网 Flask 开放
    • /ask 接口按部门做行级过滤,把部门编码写进 JWT payload,检索时先过滤标签再召回
    • 日志只保存问题哈希,不保存原文,防泄密

代码规范小结

  • 统一 Black 8 空格线宽,Black+isort 做 pre-commit
  • 公开函数必写 Google Style docstring,并附类型标注
  • 复杂业务函数拆成 search() / build_prompt() / call_llm() 三步,单测好写

延伸思考:LlamaIndex 混合检索

如果知识库再膨胀到千万级,纯向量召回也会“跑偏”。可以试 LlamaIndex 的 BM25+Embedding 混合检索:

from llama_index.retrievers import HybridRetriever
retriever = HybridRetriever(
    vector_index=index,
    keyword_index=keyword_index,
    alpha=0.6  # 向量权重
)

实测在 100 w 条 Wiki 数据下,Top5 准确率再提 6%,延迟只加 15 ms,值得一试。

写在最后:把实验搬到“豆包”上

整套流程跑通后,我把同样思路迁移到火山引擎的豆包语音模型,发现官方已经封装好 ASR→LLM→TTS 的实时通话闭环,半小时就能在网页里跟“数字同事”聊天气。如果你想快速体验,又不打算自己踩向量库的坑,可以顺手试试这个动手实验:

从0打造个人豆包实时通话AI

我跟着文档跑了一遍,从注册到第一次语音通话大概 20 分钟,UI 也开源,改两行 JS 就能换上自己的知识库。对中级 Pythoner 来说,算是一次“语音交互”低成本入门。祝你玩得开心,早日让 AI 开口说话!

点击开始动手实验


Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐