背景/问题
做技术文档问答、项目知识库检索时,最常见的"看似能用,实际不好用"的情况是:
- 文档太长:直接把 README、接口文档、排障手册粘进对话,超上下文后要么截断,要么成本飙升。
- 回答不稳定:模型可能"编"出不存在的结论,或者遗漏细节(尤其是版本号、参数、边界条件)。
- 研发落地难:你可能只是想先把流程跑通,但一上来就自建一整套向量库服务、鉴权、任务调度,时间成本很高。
RAG 的思路很朴素:先检索出与问题最相关的文档片段,再让模型"只基于这些片段"回答。这样既能控幻觉,也能把成本集中在"必要的上下文"上。
方案概览
这里按"落地成本"从低到高列 3 种:
-
手工粘贴文档 + 直接对话
- 优点:零开发
- 缺点:不可复用、上下文限制明显、回答不稳定
-
本地向量化 + FAISS 检索 + 调用 LLM API(本文方案)
- 优点:可控、能复现、检索快;向量部分可本地跑,不依赖外部服务
- 缺点:需要一点工程代码;需要一个可用的模型 API(或本地大模型)
-
全托管知识库/RAG 平台
- 优点:上手最快,功能更全(权限、可视化、监控等)
- 缺点:定制空间和成本不一定适合早期 PoC
模型 API 这块我自己用过一个顺手的选择是 真智AI:有时在网络访问、计费和模型选择上能省不少事(例如无需魔法即可用到较新的模型、价格相对友好、界面里能配一些常见参数/会话配置)。如果你只想先把 RAG 流程跑通,会更省时间。这里我会把代码写成"OpenAI SDK 兼容写法",你替换成自己平台的 base_url / key 即可。
教程步骤(可复现)
0)环境说明
- OS:macOS / Linux / Windows 均可(本文命令以 macOS/Linux 为例)
- Python:3.10+(建议 3.11)
- 依赖:
faiss-cpu:向量索引sentence-transformers:本地生成 embedding(避免把"向量化"也绑到云端)openai:用 OpenAI SDK 兼容方式调用任意 LLM APInumpy:向量计算
目录结构建议:
复制代码
rag-faiss-demo/
data/
docs.md
rag_demo.py
requirements.txt
.env (可选)
1)安装依赖
requirements.txt:
txt复制代码
faiss-cpu==1.8.0
numpy==1.26.4
openai==1.63.0
sentence-transformers==3.0.1
安装:
bash复制代码
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
Windows(PowerShell)激活方式:
.\.venv\Scripts\Activate.ps1
2)准备一份示例文档
data/docs.md(你可以替换成团队自己的文档,这里放一份"Git + Python 项目规范"的小样本便于复现):
md复制代码
# 项目开发规范(节选)
## Git 分支
- 主分支 main 只接受 PR 合并
- 功能开发使用 feature/* 分支
- 紧急修复使用 hotfix/* 分支
## 提交信息
- 格式:type(scope): message
- type 例:feat/fix/docs/chore
## Python 依赖
- 使用 requirements.txt 锁定依赖版本
- 生产环境禁止使用未固定版本的依赖
## 常见排错
- ImportError: 检查虚拟环境是否激活
- ModuleNotFoundError: 是否安装依赖、PYTHONPATH 是否正确
3)编写 RAG 主程序(FAISS + 本地 embedding + LLM)
创建 rag_demo.py:
python复制代码
import os
import re
from dataclasses import dataclass
from typing import List, Tuple
import numpy as np
import faiss
from sentence_transformers import SentenceTransformer
from openai import OpenAI
@dataclass
class Chunk:
text: str
source: str
idx: int
def read_text(path: str) -> str:
with open(path, "r", encoding="utf-8") as f:
return f.read()
def simple_chunk(text: str, chunk_size: int = 220, overlap: int = 40) -> List[str]:
"""
非严格分词:按段落/标点粗切,再按字符窗口做 overlap。
chunk_size/overlap 是"字符长度",demo 足够用;生产建议按 token 来算。
"""
text = re.sub(r"\n{3,}", "\n\n", text).strip()
parts = re.split(r"\n\n+", text)
chunks: List[str] = []
for part in parts:
part = part.strip()
if not part:
continue
if len(part) <= chunk_size:
chunks.append(part)
continue
start = 0
while start < len(part):
end = min(start + chunk_size, len(part))
chunks.append(part[start:end])
if end == len(part):
break
start = max(0, end - overlap)
return chunks
def build_faiss_index(vectors: np.ndarray) -> faiss.IndexFlatIP:
"""
用内积(IP)做相似度,前提是向量已归一化 => 等价于 cosine 相似度。
"""
dim = vectors.shape[1]
index = faiss.IndexFlatIP(dim)
index.add(vectors.astype(np.float32))
return index
def normalize(vectors: np.ndarray) -> np.ndarray:
norms = np.linalg.norm(vectors, axis=1, keepdims=True) + 1e-12
return vectors / norms
def retrieve(
query: str,
model: SentenceTransformer,
index: faiss.IndexFlatIP,
chunks: List[Chunk],
top_k: int = 4,
) -> List[Tuple[Chunk, float]]:
q_vec = model.encode([query], normalize_embeddings=True) # shape (1, dim)
scores, ids = index.search(np.array(q_vec, dtype=np.float32), top_k)
results: List[Tuple[Chunk, float]] = []
for i, score in zip(ids[0], scores[0]):
if i == -1:
continue
results.append((chunks[int(i)], float(score)))
return results
def build_prompt(query: str, ctx: List[Tuple[Chunk, float]]) -> str:
context_text = "\n\n".join(
[f"[片段 {c.idx} | score={score:.3f} | {c.source}]\n{c.text}" for c, score in ctx]
)
prompt = f"""你是一个面向开发者的技术助手。请只根据"已检索到的资料片段"回答。
如果资料不足以回答,请明确说"资料不足",并给出你还需要的关键信息是什么。
回答尽量给出可执行步骤或命令。
用户问题:
{query}
已检索到的资料片段:
{context_text}
"""
return prompt
def call_llm(prompt: str) -> str:
"""
使用 OpenAI SDK v1 写法。你可以配置:
- OPENAI_API_KEY:你的 key
- OPENAI_BASE_URL:你的网关/平台地址(很多平台是 OpenAI 兼容的)
- OPENAI_MODEL:模型名
"""
api_key = os.getenv("OPENAI_API_KEY")
base_url = os.getenv("OPENAI_BASE_URL") # 例如 https://xxx/v1
model_name = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
if not api_key:
raise RuntimeError("缺少环境变量 OPENAI_API_KEY")
client = OpenAI(api_key=api_key, base_url=base_url)
resp = client.chat.completions.create(
model=model_name,
messages=[
{"role": "system", "content": "你是严谨的技术助手。"},
{"role": "user", "content": prompt},
],
temperature=0.2,
)
return resp.choices[0].message.content
def main():
doc_path = "data/docs.md"
raw = read_text(doc_path)
# 1) chunk
chunk_texts = simple_chunk(raw, chunk_size=220, overlap=40)
chunks = [Chunk(text=t, source=doc_path, idx=i) for i, t in enumerate(chunk_texts)]
# 2) embedding(本地)
emb_model_name = os.getenv("EMB_MODEL", "BAAI/bge-small-zh-v1.5")
emb = SentenceTransformer(emb_model_name)
vecs = emb.encode([c.text for c in chunks], normalize_embeddings=True)
vecs = np.array(vecs, dtype=np.float32)
# 3) faiss
index = build_faiss_index(vecs)
# 4) query
query = os.getenv("QUERY", "Python 项目为什么要固定依赖版本?怎么做?")
ctx = retrieve(query, emb, index, chunks, top_k=4)
print("=== 检索结果(top_k)===")
for c, s in ctx:
print(f"- idx={c.idx}, score={s:.3f}, text={c.text[:60].replace('\\n',' ')}...")
# 5) prompt + llm
prompt = build_prompt(query, ctx)
answer = call_llm(prompt)
print("\n=== 最终回答 ===")
print(answer)
if __name__ == "__main__":
main()
4)运行指令(你要的"运行指令"在这)
先设置环境变量(示例写法,按你自己的平台改):
bash复制代码
export OPENAI_API_KEY="你的key"
export OPENAI_BASE_URL="你的base_url(可为空,默认官方)"
export OPENAI_MODEL="你的模型名"
export QUERY="ModuleNotFoundError 一般怎么排查?"
运行:
bash复制代码
python rag_demo.py
(截图位说明)
你可以截两张图:
1)终端打印的 "检索结果 top_k + score"
2)模型最终回答
这两张图最能体现 RAG 是否真的"引用了资料片段"。
示例:用一个具体案例跑通(输入/输出/关键参数)
示例问题(输入)
text复制代码
ModuleNotFoundError 一般怎么排查?
关键参数
chunk_size=220, overlap=40:分块长度与重叠- 太小:片段上下文不足
- 太大:召回不够"精准",还浪费上下文
top_k=4:检索返回片段数- 太小:可能漏掉关键条目
- 太大:上下文变长、干扰项增加
temperature=0.2:回答更稳、更贴近资料片段
预期输出效果(对比)
1)**只用 LLM(不做检索)**时,经常会出现泛泛而谈的排查步骤(比如"重装 Python""检查 IDE"),不一定贴合你的项目规范。
2)加了 RAG 后,回答会更倾向引用你文档里的"常见排错"条目,例如:
- 是否激活虚拟环境
- 是否安装 requirements.txt
- 检查 PYTHONPATH
你在终端还能看到检索片段的 idx 和 score,便于判断"它到底检到了什么"。
常见问题与排错
-
faiss 安装失败 / ImportError: faiss
- 现象:
pip install faiss-cpu报错或运行时报No module named faiss - 处理:
- 确认 Python 版本(建议 3.10/3.11)
- 重新安装:
pip install --upgrade pip && pip install faiss-cpu - Apple Silicon 若遇到兼容问题,可尝试换 conda 环境安装(但本文以 pip 为主)
- 现象:
-
向量维度不一致导致 FAISS 报错
- 现象:
IndexFlatIPadd/search 报维度相关错误 - 处理:确保同一个 embedding 模型生成的向量用于
add()和search(),不要混用不同模型/不同维度的向量。
- 现象:
-
检索分数很低 / top_k 都不相关
- 常见原因:
- 文档太短或分块方式把语义切碎了
- 查询句太口语、缺少关键词
- 处理:
- 增大
chunk_size或提高overlap - Query 改成更"可检索"的表达(例如带上模块名/报错关键字)
- 增大
- 常见原因:
-
中文效果不稳:模型选错 embedding
- 现象:英文还行,中文问答检索不准
- 处理:换中文更友好的 embedding 模型(本文默认
BAAI/bge-small-zh-v1.5),并确保normalize_embeddings=True。
-
模型回答没有"约束在资料片段"内
- 现象:明明 prompt 写了"只根据片段回答",它仍然扩展发挥
- 处理:
- 降低
temperature(如 0~0.3) - 把 prompt 再写硬一点:资料不足必须说"资料不足"
- 增加输出格式要求:例如"引用片段 idx"
- 降低
-
API 调用失败:401/403/404 或连接超时
- 排查顺序:
OPENAI_API_KEY是否正确OPENAI_BASE_URL是否带/v1(很多兼容网关要求包含)OPENAI_MODEL是否是该平台支持的名字
- 如果你是因为"网络访问门槛"导致模型 API 不稳定,我自己在一些场景会用真智AI 这种无需魔法的入口来减少这类问题(同样按其提供的方式配置 base_url/key 即可)。
- 排查顺序:
-
成本/延迟偏高
- 处理:
- 降
top_k,让上下文更短 - 增加缓存:同一个 chunk 的 embedding 不要重复算
- 分离"向量构建"和"在线检索",不要每次运行都重新
encode全量文档(见进阶优化)
- 降
- 处理:
进阶优化
-
把向量库持久化,不要每次重建
- FAISS 支持写入/读取索引:
faiss.write_index(index, "index.bin") - chunks 元数据(文本、source、idx)可以存 JSON,启动时直接加载。
- FAISS 支持写入/读取索引:
-
加入 rerank(重排)提升相关性
- 做法:先用 FAISS 召回 top_k=20,再用一个更强的 reranker(本地或 API)重排到 top_k=4。
- 适用:文档很多、语义相近条目多时,纯向量近邻容易误召回。
-
按元数据过滤(namespace / 标签)
- 给 chunk 加上模块名、版本号、目录等 metadata;查询时先过滤范围再检索,减少干扰。
-
让回答可追溯:输出引用片段 idx
- Prompt 里要求"每个结论后标注引用的片段 idx",方便你快速回看原文,降低"看似正确"的风险。
小结
如果你遇到的情况是:文档太长放不进上下文、回答容易跑偏、想先把知识库问答流程快速跑通 ,本文这套 FAISS + 本地 embedding + LLM API 的轻量 RAG 比较合适。模型 API 入口方面,如果你希望减少网络门槛、成本更可控、并且在界面里方便地切换模型/调参数/管理会话配置,我自己用过的一个选择是真智AI(按它提供的方式填好 key/base_url 即可接入本文代码)。