20 行代码,用大模型做一个「文档问答」

13 阅读2分钟

📚 本文是《AIGC 人话系列》第 5 篇 > 系列目录:01 内容理解|02 RAG|03 三招选型|04 工具清单|05 动手实战(本篇)|06 踩坑|07 行业观察

前四篇讲了原理和选型,这篇直接动手——用最少的代码,跑通一个能用的文档问答。

目标

给它一份文档,它就能回答关于这份文档的问题。

全流程就 6 步

day5-六步.png

完整代码

from openai import OpenAI
import numpy as np

client = OpenAI()

# 1. 读文件(PDF 可以用 pypdf 先把文字抽出来)
text = open("handbook.txt", encoding="utf-8").read()

# 2. 切分:按段落拼,每块约 500 字,块之间留 50 字重叠
def split(t, size=500, overlap=50):
    paras = [p for p in t.split("\n") if p.strip()]
    chunks, cur = [], ""
    for p in paras:
        if len(cur) + len(p) > size:
            chunks.append(cur)
            cur = cur[-overlap:] + p          # 保留尾巴,避免语义被切断
        else:
            cur += p + "\n"
    return chunks + [cur]

chunks = split(text)

# 3. 向量化
def embed(texts):
    r = client.embeddings.create(model="text-embedding-3-small", input=texts)
    return [d.embedding for d in r.data]

vecs = np.array(embed(chunks))
vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)   # 归一化,后面用点积算相似度

# 4. 检索
def search(q, k=3):
    qv = np.array(embed([q])[0])
    qv /= np.linalg.norm(qv)
    scores = vecs @ qv                                 # 余弦相似度
    top = scores.argsort()[::-1][:k]
    return [chunks[i] for i in top]

# 5. 生成
def ask(q):
    hits = search(q)
    context = "\n".join(hits)
    prompt = (
        "严格根据以下资料回答问题,资料里没有的就直说不知道。\n\n"
        f"【资料】\n{context}\n\n【问题】{q}"
    )
    return client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
    ).choices[0].message.content

# 6. 试试
print(ask("年假有多少天?"))

把 import 和注释去掉,核心逻辑也就 20 来行。

三个最容易踩的坑

坑现象解决
忘了归一化检索结果乱七八糟算相似度前先除以向量模长
切分太粗暴关键信息被切断按段落切,块之间留 overlap
没写「不知道」检索不到也硬编Prompt 里明确要求拒答

想再进一步

跑通之后,按这个顺序升级:

  1. 换向量数据库(Chroma / FAISS),免去每次重新计算
  2. 加重排(Rerank),让检索更准
  3. 把切分改成按标题结构切——这一步效果提升最明显
  4. 加多轮对话,把历史一起塞进 prompt

写在最后

这个 20 行的版本当然不能直接上生产,但它能让你真正摸到 RAG 的手感——知道每一行在干嘛,比调一个现成框架有用得多。

对照第 2 篇《RAG 到底是什么?》再看一遍这份代码,你会发现原理和代码是一一对应的。

代码跑不通的话,把报错贴评论区,我帮你看。