在构建技术问答或项目知识库时,你是否常遇到这些痛点:文档过长导致模型上下文溢出、回答偏离事实、落地成本高?RAG(检索增强生成)通过先检索再生成的方式,能有效缓解这些问题。本文将手把手教你用FAISS、本地Embedding和LLM API,搭建一个轻量、可控、可复现的RAG问答系统,让你快速跑通流程,为后续优化打下基础。
背景:为什么需要轻量级RAG?
当我们直接使用LLM处理技术文档时,最常见的问题包括:文档太长,超过上下文窗口后要么被截断,要么产生高昂的token成本;回答不稳定,模型可能“编造”出不存在的结论,尤其是版本号、参数等细节;研发落地难,很多人只想快速验证想法,却不得不先搭建一整套向量库服务、鉴权系统。
RAG的思路很朴素:先检索出与问题最相关的文档片段,再让模型仅基于这些片段回答。这样既能控制幻觉,也能将成本集中在“必要上下文”上。对于早期的PoC或小型项目,FAISS(Facebook AI Similarity Search)配合本地Embedding生成,是一种性价比极高的方案。
方案概览:三种落地路径对比
根据落地成本从低到高,通常有三种选择:
- 手工粘贴文档+直接对话:零开发,但不可复用,上下文限制明显,回答不稳定。
- 本地向量化+FAISS检索+调用LLM API(本文方案):可控、能复现、检索快;向量部分可本地跑,不依赖外部服务。
- 全托管知识库/RAG平台:上手最快,功能更全(权限、可视化、监控),但定制空间和成本不一定适合早期PoC。
本文聚焦第二种方案,它平衡了开发成本与效果,适合大多数技术团队。
模型 API 这块我自己用过一个顺手的选择是 真智AI:有时在网络访问、计费和模型选择上能省不少事(例如无需魔法即可用到较新的模型、价格相对友好、界面里能配一些常见参数/会话配置)。如果你只想先把 RAG 流程跑通,会更省时间。这里我会把代码写成“OpenAI SDK 兼容写法”,你替换成自己平台的 base_url / key 即可。
️ 教程步骤:可复现的轻量RAG搭建
0️⃣ 环境说明
OS:macOS / Linux / Windows 均可(本文命令以macOS/Linux为例)。
Python:3.10+(建议3.11)。
依赖:
:向量索引库faiss-cpu:本地生成Embedding,避免将向量化也绑定到云端sentence-transformers:用OpenAI SDK兼容方式调用任意LLM APIopenai:向量计算numpy
目录结构建议:
rag-faiss-demo/
data/
docs.md
rag_demo.py
requirements.txt
.env (可选)
1️⃣ 安装依赖
首先创建虚拟环境(推荐),然后安装所需包:
faiss-cpu==1.8.0
numpy==1.26.4
openai==1.63.0
sentence-transformers==3.0.1
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
Windows(PowerShell)激活方式:
2️⃣ 准备示例文档
创建,内容可以是团队的项目规范或排障手册。这里提供一份“Git + Python项目规范”的小样本,便于复现:data/docs.md
# 项目开发规范(节选)
## Git 分支
- 主分支 main 只接受 PR 合并
- 功能开发使用 feature/* 分支
- 紧急修复使用 hotfix/* 分支
## 提交信息
- 格式:type(scope): message
- type 例:feat/fix/docs/chore
## Python 依赖
- 使用 requirements.txt 锁定依赖版本
- 生产环境禁止使用未固定版本的依赖
## 常见排错
- ImportError: 检查虚拟环境是否激活
- ModuleNotFoundError: 是否安装依赖、PYTHONPATH 是否正确
3️⃣ 编写RAG主程序
创建,核心逻辑包括:文档分块、Embedding生成、FAISS索引构建、检索与LLM回答生成。rag_demo.py
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️⃣ 运行指令
先设置环境变量(示例写法,按平台调整):
export OPENAI_API_KEY="你的key"
export OPENAI_BASE_URL="你的base_url(可为空,默认官方)"
export OPENAI_MODEL="你的模型名"
export QUERY="ModuleNotFoundError 一般怎么排查?"
然后运行:
python rag_demo.py
(截图位说明)
你可以截两张图:
1)终端打印的 “检索结果 top_k + score”
2)模型最终回答
这两张图最能体现 RAG 是否真的“引用了资料片段”。
示例:用具体案例跑通
示例问题(输入):
ModuleNotFoundError 一般怎么排查?
关键参数:
:分块长度与重叠。太小则片段上下文不足;太大则召回不够精准,浪费上下文。chunk_size=220, overlap=40:检索返回片段数。太小可能漏掉关键条目;太大则上下文变长,干扰项增加。top_k=4:回答更稳,更贴近资料片段。temperature=0.2
预期输出效果对比:
- 只用LLM(不做检索)时,经常出现泛泛而谈的排查步骤(如“重装Python”“检查IDE”),不一定贴合你的项目规范。
- 加了RAG后,回答会更倾向引用文档里的“常见排错”条目,例如:是否激活虚拟环境、是否安装requirements.txt、检查PYTHONPATH。
在终端还能看到检索片段的和idx,便于判断模型实际检到了什么。score
⚠️ 常见问题与排错
❌ faiss安装失败 / ImportError
现象:报错或运行时报pip install faiss-cpu。No module named faiss
处理:确认Python版本(建议3.10/3.11),重新安装:。Apple Silicon若遇到兼容问题,可尝试conda环境安装。pip install --upgrade pip && pip install faiss-cpu
❌ 向量维度不一致
现象:add/search时报维度相关错误。
处理:确保同一个Embedding模型生成的向量用于和add(),不要混用不同模型或不同维度的向量。search()
❌ 检索分数很低 / top_k不相关
常见原因:文档太短或分块方式把语义切碎了;查询句太口语化,缺少关键词。
处理:增大或提高chunk_size;Query改成更“可检索”的表达(如带上模块名/报错关键字)。overlap
❌ 中文效果不稳
现象:英文还行,中文问答检索不准。
处理:换中文更友好的Embedding模型(本文默认),并确保BAAI/bge-small-zh-v1.5。normalize_embeddings=True
❌ 模型回答没有约束在资料片段内
现象:明明prompt写了“只根据片段回答”,模型仍扩展发挥。
处理:降低(如0~0.3);把prompt写硬一点:资料不足必须说“资料不足”;增加输出格式要求,如“引用片段idx”。temperature
❌ API调用失败:401/403/404或连接超时
排查顺序:
是否正确OPENAI_API_KEY是否带OPENAI_BASE_URL(很多兼容网关要求包含)/v1是否是该平台支持的名字OPENAI_MODEL
❌ 成本/延迟偏高
处理:降低,让上下文更短;增加缓存,同一个chunk的Embedding不要重复算;分离“向量构建”和“在线检索”,不要每次运行都重新top_k全量文档。encode
进阶优化
- 向量库持久化:FAISS支持写入/读取索引,
;chunks元数据(文本、source、idx)可存JSON,启动时直接加载。faiss.write_index(index, "index.bin") - 加入Rerank(重排):先用FAISS召回top_k=20,再用一个更强的reranker(本地或API)重排到top_k=4。文档多、语义相近条目多时效果显著。
- 按元数据过滤:给chunk加上模块名、版本号、目录等metadata;查询时先过滤范围再检索,减少干扰。
- 回答可追溯:Prompt里要求“每个结论后标注引用的片段idx”,方便快速回看原文,降低“看似正确”的风险。
技术延伸:在构建类似系统时,你可能会遇到不同编程语言生态的差异。例如,TypeScript和JavaScript项目通常使用LangChain.js或Vercel AI SDK进行RAG,而Java和Go项目则可能依赖Spring AI或自定义向量索引。C++项目则更倾向直接调用FAISS的C++ API。理解这些差异,有助于你在多语言团队中推广RAG方案。
[AFFILIATE_SLOT_1]小结
如果你遇到文档太长放不进上下文、回答容易跑偏、想先把知识库问答流程快速跑通的情况,本文这套FAISS+本地Embedding+LLM API的轻量RAG方案非常合适。它成本低、可控性强,且易于扩展。模型API入口方面,如果你希望减少网络门槛、成本更可控,并且能在界面里方便地切换模型、调参数、管理会话配置,可以考虑使用真智AI(按它提供的方式填好key/base_url即可接入本文代码)。
[AFFILIATE_SLOT_2]从JavaScript到Java,从Go到C++,RAG的核心思想是通用的:先检索,再生成。掌握本文的方法后,你可以将其灵活应用到任何技术栈中。
.\.venv\Scripts\Activate.ps1
浙公网安备 33010602011771号